Find 1688.com suppliers for eBay items by image search. Each row: wholesale and consign price in CNY, MOQ, factory signals, soldCount and an estimated per-unit margin. $0.005 per eBay lookup, $0.008 per supplier row, $0.00005 run base fee. Failed lookups are free.
0.2.16 (2026-10-26) — the fuse reason still told the buyer the walled items were free
One src/summary.js string; this ends the byte-identical src/ chain that ran
0.2.11 → 0.2.15. Runtime behaviour is unchanged apart from the text of
RUN_SUMMARY.humanReadableReason on a fused run.
Found by re-reading the runtime text after 0.2.15 corrected the same claim in
README.md and the KV-store schema. fuseReason() emitted
Nitem(s) were attempted and walled — they are in the dataset with fetchStatus="blocked" and were not charged.
Both halves are wrong for the 1688 leg: those items have no
dataset row (their source row is ok), and they were charged one
ebay-item-lookup. Measured on the Etsy twin, where the split is unambiguous,
everyskippedBy.blocked item in both runs was 1688-side (sourcesByFetchStatus
came back {ok, error} with zero blocked), so on a fused run this text would
have been wrong for 100% of the count it was explaining. It now names the leg test
and the charge, matching the 0.2.15 disclosure.
etsy-to-1688-sourcing-bridge had the identical string and gets the identical fix
in 0.1.11. The two published siblings have no fuseReason(); their only "not
charged" sentence is about skippedBy.deadline, which is genuinely free.
amazon-reviews-snapshot is single-legged, so its equivalent sentence is correct
and was left alone.
Offline suite 179 → 179, with three new assertions pinning the corrected wording.
0.2.15 (2026-10-26) — one billing disclosure was wrong, and the README lagged the 0.2.11 reserve clamp
README.md and .actor/key_value_store_schema.json only; no src/ change, so
src/ is byte-identical across 0.2.11 → 0.2.15 and every runtime conclusion
measured on 0.2.11 still holds. This is the fourth documentation-only build of the
session (0.2.12, 0.2.13, 0.2.14, 0.2.15) and should be read as one disclosure pass.
skippedBy.blocked does not say which leg was walled, and the two legs bill
differently. The README, the KV-store schema and the Disclosure block all said
blocked items are "never billed". That is true of an eBay-side block
(src/main.js:627 and :646) — it also emits a source row with
fetchStatus: "blocked", is delivered free, and logs — free. It is not true
of a 1688 image-search RISK_CONTROL (src/main.js:822), nor of the tail that
markSkipped(…, 'blocked') absorbs after the fuse opens (src/main.js:882): in
both cases the eBay fetch had already succeeded, so the item is charged one
ebay-item-lookup and produces no match rows and no summary row. Measured on the
Etsy twin of this Actor, where the split is unambiguous because zero source rows
came back blocked: run Gprvh0dRh5e446sH1 charged 38 lookups and reported
skippedBy.blocked: 3 (8%), run z6VzuDLdfwjZ6avZq charged 52 and reported
skippedBy.blocked: 10 (19%). Behaviour is unchanged by owner ruling — the
charge is for a fetch that succeeded — but all three documents now state the
exception and give the leg test: skippedBy.blocked minus the source rows whose
fetchStatus is "blocked" is the 1688-side count.
The README still described the pre-0.2.11 reserve. It said the eBay phase
"reserves the last ~35% of the window", which is the arithmetic the E-4 clamp
replaced; at a short timeout the old formula could reserve more than the whole
window. It now says roughly the last 35% and never more than half of it, and
explains deadlineKind's two opposite remedies (platform-timeout → smaller
batch; phase-window → longer run timeout), which 0.2.12 had already put in the
KV-store schema but not in the README.
Follow-up on 0.2.11's last bullet. The two published siblings
(aliexpress-to-1688-sourcing-bridge, amazon-fba-sourcing-bridge) were
reported rather than rebuilt. The owner has since ruled, and both were shipped as
0.1.7 and 0.1.8 with the same invokedDirectly entry-point guard and the
same *_PHASE_RESERVE_MAX_SHARE = 0.5 clamp, proved by a pinned-build A/B at a
348 s timeout: aliexpress went 0 rows / $0 / trigger:"deadline" → 4 rows /
$0.01895 / clean, and amazon-fba 0 rows / $0 / trigger:"deadline" → 6 rows /
$0.02345 / clean. Neither Actor's Store metadata or isPublic flag was touched.
Both also received this build's skippedBy.blocked disclosure.
Offline suite unchanged at 179 tests, 0 failures (no src/ edit).
0.2.14 (2026-10-26) — two buyer-facing field definitions that read like the wrong thing
.actor/ only; no src/ change, so every runtime conclusion measured on 0.2.11
still holds. This is the third documentation-only build of the session (0.2.12,
0.2.13, 0.2.14); all three are folded into what you should read as one disclosure
pass, and src/ is byte-identical across 0.2.11 → 0.2.14.
Both were found while writing the Etsy bridge's acceptance matrix and then
checking whether this repository had the same problem.
freeRows was listed in the schema and never defined, and its name reads like
the wrong thing. It counts dataset rows delivered without charge — summary
rows and failed-source rows — not items and not lookups. A run in which every
lookup was charged still reports a non-zero freeRows, because every item also
gets a free summary row. The Etsy twin's case-a2-urls-b018 charged all 4
lookups and reported freeRows: 4; I misread it as "4 items were free" while
writing the matrix, which is the evidence that a buyer would too.
relevantMatch is structurally false on every image-only row. The
heuristic compares the source title to the 1688 title, and an imageUrls item
has no source title (buildMatchRow passes preparedItem.title, which is null
for image items), so meaningfulTokens(null) is empty and no overlap is
possible. imageUrls is advertised as a first-class input mode that skips the
eBay lookup entirely, so a buyer using it alone gets relevantMatch: false on
100% of rows and would reasonably conclude the matcher is broken — or, worse,
filter those rows out as bad matches. Now disclosed in dataset_schema.json
(relevantMatch) and in the imageUrls input description.
Whether relevantMatch should publish null ("not comparable from titles")
instead of false for image-only rows is the more honest shape and matches the
principle applied to variationPriceRange in E-1 — but it is a contract-shape
change to a non-nullable boolean on two Actors at once, it never affects billing,
and it is raised as a decision item rather than done unilaterally on the eve of
handover.
Note on Y-8 (the Etsy maxItems cap regression fixed in the twin's 0.1.9): this
repository is not affected. buildPlan here resolves input.maxItems and
truncates the deduped union, and three tests pin it. The two published siblings
(aliexpress-, amazon-fba-) have the Etsy-style MAX_TOTAL_ITEMS-only
truncation but publish no maxItems field, so no promise is broken there either.
0.2.13 (2026-10-26) — the zero-input RUN_SUMMARY omits a field the schema promised
.actor/key_value_store_schema.json only; no src/ change, so every runtime
conclusion measured on 0.2.11 and 0.2.12 carries over unchanged.
Found by running the empty-input boundary case for the Etsy bridge's acceptance
matrix and then checking whether this repository behaves the same way. It does,
and has since the first build: v3-empty (run 0wTnJTl0eJlYdmpXb, build 0.1.1)
has no billing block either.
The zero-input exit at src/main.js:448 fires beforereadPricing() at
src/main.js:457, so no ledger is ever created and finalizeRunSummary() has no
snapshot to attach. The published KV schema lists billing unconditionally,
which means a buyer parsing RUN_SUMMARY.billing.chargedUsd gets undefined on
exactly one path — the free one, where top-level chargedUsd is 0.
Two ways to close that: attach an empty ledger on the zero-input path, or say so
in the contract. This takes the second, because the first would publish a
billing block whose prices are meaningless for a run that never read pricing.
The schema now names the exception explicitly and states that every other exit —
including "keyword discovery found nothing usable", which does have a ledger —
carries billing.
Scope check: this is a bridge-family shape, not a family-wide one.
amazon-reviews-snapshot routes all three of its validation exits through a
single exitEmpty() whose finalizeRunSummary(summary, {}) attaches a zeroed
billing block unconditionally, so its RUN_SUMMARY always has the field. The two
bridges attach billing from the module-level active ledger, which does not exist
yet on the zero-input path. Both bridges get the same sentence; amazon needs none.
0.2.12 (2026-10-26) — the deadlineKind disclosure described the pre-fix behaviour
.actor/key_value_store_schema.json only. No src/ change, so every runtime
conclusion measured on 0.2.11 carries over unchanged.
0.2.11 introduced RUN_SUMMARY.deadlineKind and described "phase-window" as
"this run's timeout was too short for the eBay phase to open at all". That was
true of the defect and false of the fix: with the reserve now capped at half
the window that actually exists, the eBay phase always opens, and phase-window
means it closed before every remaining item was processed. A batch of 100 items
on the default 1200 s timeout reaches that state legitimately, so the buyer-facing
contract was describing a case that can no longer occur and omitting one that can.
The description now also states the two remedies separately (they point in
opposite directions: raise the timeout for phase-window, shrink the batch for
platform-timeout) and documents the platformWindow=<secs> reserve=<secs>
numbers already emitted in notes.
0.2.11 (2026-10-26) — E-4: a short run timeout silently killed the whole eBay leg, and the S-4 fuse reason was still the vague one
Two defects found while building the Etsy bridge's acceptance matrix, both in code
this repository shares with its three siblings. Neither was in the gatekeeper's
20261025 remediation sheet.
E-4 (P0 for short runs) — the 1688-phase reserve could exceed the entire run
window.EBAY_PHASE_RESERVE_MIN_MS is an absolute 300 s, but effectiveMs
follows the platform's real timeout (createDeadline reads ACTOR_TIMEOUT_AT
and subtracts FINISH_RESERVE_SECS = 48). On any run timeout below ~348 s the
floor alone was larger than the window, so ebayPhaseEndAt landed before the
run had started: every eBay item was skipped without a single request being
made, the run finished in seconds having delivered nothing, and RUN_SUMMARY
then reported trigger: "deadline" with "Stopped at the run deadline" — a
deadline that never happened. Proved on the Etsy twin (identical constants and
identical arithmetic) at cloud run s7eLqlMdx9lxeYVfb: timeout 300 s, 7 s wall
clock, 0 rows, $0 charged, skippedBy.deadline: 2.
phaseReserveMs() now clamps the reserve to at most half of the window that
actually exists. The default path is byte-identical: at the 1110 s hardcode
(effective ~1062 s) all three terms give 371700 ms, the same number the old
two-term formula gave, so no existing evidence changes. At a 300 s timeout the
source phase now keeps 126 s instead of −48 s.
E-4 (honesty) — the two time budgets are now told apart.deadlineKind is
"platform-timeout" when the platform really ran out of time and
"phase-window" when our own split left no room; only the second prints the
"raise the run timeout" remedy, because "try again later" is useless advice for
it. The split is also recorded in notes as
EBAY_PHASE_WINDOW_CLOSED platformWindow=<s> reserve=<s> and logged once with
the seconds still on the clock. Disclosed in the KV-store schema.
S-4 completion — the fuse reason was still the vague one. 0.2.9/0.2.10
published skippedBy.unattempted but the fused text was still the static
"Stopped after repeated 1688 risk-control failures; affected items are listed in skippedBy.blocked"
— no number, and pointing at the wrong bucket, since
the never-attempted tail lands in unattempted. That is exactly the gatekeeper's
S-4 complaint ("买家只看到一个字段名"). fuseReason() now names the threshold,
the walled count and the never-attempted count, says neither was charged, and
tells the buyer to re-run the tail in a smaller batch. RUN_SUMMARY.fuseLimit
carries the threshold itself, and image-search.js documents why option ① of
S-4 (max(2, ceil(batch × 10%))) has no safe reading here: the counter is in
search groups of up to 8 images, not in items, so in image units the formula
is stricter than today for every legal batch, and in group units it would send
~80 images at an exit IP that had just risk-controlled us twice.
Offline suite 179 tests, 0 failures (was 176): the reserve clamp and its
byte-identical default, both deadlineKind wordings, the fuse reason's counts
and bucket, plus source-level wiring assertions.
The same E-4 defect exists verbatim in the two published siblings
(aliexpress-to-1688-sourcing-bridge, amazon-fba-sourcing-bridge). Those were
reported to the owner rather than rebuilt unilaterally.
0.2.10
Cut from 0.2.9 plus two things found while verifying 0.2.9 on the platform. All
E-1/E-2/E-3 evidence below was measured on 0.2.9 (runs 2RxXYP3y3XwkaTB7e
and wwg2BUkhKtVlGcBA4); neither of these two changes touches src/ebay-parse.js,
src/relevant-match.js or the checkpoint writer, so that evidence carries over —
and 0.2.10 was re-run anyway rather than asking a reviewer to take that on faith.
S-3 ported (P1, family-level, not on this Actor's sheet).readPricing
started from {...LIST_PRICES} and overwrote only the events it could read, so
a run that could not read its own pricing silently reported list prices as if
they were the issued ones. The sibling amazon-reviews-snapshot measured the
consequence on cloud run YLkdR9a2U8eQU2fBx: chargedUsd 0.204 in the report
against 2.4× that from the platform's own event counts, with notes: []. This
Actor had the identical code and had simply never been caught in the act — its
0.2.9 run reconciled exactly ({1,17,170} → $1.30055) because the read
succeeded. billing.pricingSource is now platform / platform-read-failed /
inactive, billing.pricingReadError carries the reason, PRICING_READ_FAILED
lands in notes and the startup log says it at error level, and the budget gate
reserves PRICING_UNCERTAINTY_FACTOR (3×) per event when the read failed: the
platform charges its own price whatever we believe, so the only lever is
stopping earlier. An undelivered tail is reported in skippedBy.budget; an
overspend cannot be handed back.
inputKind on an image-only source row claimed ebay.makeSourceRow
hardcoded it while makeSummaryRow and buildMatchRow derived it from the
item, so a buyer who submitted imageUrls got source rows saying they came from
the eBay input list. Inside the schema enum, so it never threw — it was just
wrong data on a charged row. Now derived like the others. Found by porting the
Etsy bridge's crash (below) and reading the same line in every sibling.
New tests/dataset-schema.test.js: every row the three builders emit is
validated against the published.actor/dataset_schema.json (types, enums,
required), not against a copy of it in the test. That is the guard that was
missing on the Etsy side.
Offline suite 176 tests, 0 failures (was 168): five S-3 pricingSource cases
plus the 3× gate reservation, and the schema-conformance set.
0.2.9
Release-gatekeeper remediation E-1/E-2/E-3 from 20261025-整改单-未发布三Actor-交开发agent.md, plus one defect found while writing the tests for it. No pricing, matching-engine or listing-copy change beyond what E-1/E-2 require.
E-1 (P0, release gate) — variation prices were compared across currencies, and the flag came out backwards. On .co.uk item 126530835779 the page displays £5.00, which is the cheapest of its £5/£10/£25/£50/£200 variations, but eBay served the variationsMap view-model converted to USD, so the code compared 5.00 against 6.70 and published priceIsVariationMinimum: false next to variationPriceRange: {min: 6.7, currency: "USD"} on a row whose priceCurrency is GBP. A wrong false is the dangerous direction: it tells a buyer the displayed price is a real deal price when it is only a variation minimum, and margin computed from it can invert the verdict. extractVariation now resolves every variation in the listing's own currency first — findPriceInCurrency() searches the variation's view-model at bounded depth for any {value, currency} pair in that currency, so a native price sitting beside the converted one is used whatever its field name is called. The flag is computed only inside a single currency; when the model carries nothing but a converted currency the flag is null (undeterminable) rather than a guess, currencyMatchesListing: false says so, and the range keeps the currency it is actually in. variationsTotal was added beside count so a range covering only some of the variations cannot pass for the whole set. The gatekeeper's fallback ("if you cannot normalise, withdraw the fields") is what the null branch implements — the fields stay only where they are true.
E-2 (P1) — .de listings could never be flagged relevant.WORD_MAP is EN→CN, so on a German source title not one token could reach a Chinese equivalent and relevantMatch was structurally always false; the gatekeeper's rank-1 车载手机支架 against "Auto Handyhalterung" (item 317859319194) is the sample. Rather than maintain a second DE→CN list, German is bridged onto the English tokens WORD_MAP already knows: DE_STEMS maps ~70 German stems to their English equivalents and a token is mapped when it contains a stem, which is what German compounds need ("Handyhalterung" = Handy + Halterung → phone + holder → 手机 + 支架). transliterateGerman() runs before tokenisation because an umlaut otherwise cuts a word in half — fußmatte tokenised to fu + matte and größe to gr + e, so half the vocabulary was unreachable even in principle. German function words joined the stopwords. Coverage is now English and German and is stated as such in the field description, the README and the flag's own docstring: a French or Italian title can still only match on latin pass-through tokens, and the flag reads false on an offer that may well be the same product. That was the gatekeeper's other acceptable answer, and it now appears in both places rather than being left for buyers to infer. The stale dataset-schema text that still described the pre-0.2.3 rule ("similarity rank <= 3 or shared tokens") was corrected at the same time — the contract described a behaviour the code stopped having two releases ago.
E-3 (P1, family) — a killed run left money charged and no ledger, and a signal handler alone does not fix it. Run 1irDrHTTT0OB267YM was aborted at 44 s with the platform already showing {run-base-fee: 1, ebay-item-lookup: 6, supplier-match: 3} and 10 dataset rows, and the key-value store held nothing but INPUT. Two changes were needed, and the second one only became visible after the first was measured:
installSignalLedgerDump() writes the ledger from SIGTERM/SIGINT and then re-raises the signal with its default action restored, so the platform still reports the run ABORTED instead of reading a synthetic exit code. The checkpoint gate widened from chargedUsd > 0 to chargedUsd > 0 || intendedUsd > 0 || deliveredRows > 0, since the platform's charged counter can lag our own push.
The signal handler is not sufficient on its own. The sibling amazon-reviews-snapshot build 0.1.15 was aborted at 43.1 s with 3 snapshots charged and its handler installed: the container log ends at "The Actor run was aborted by the user" and the handler's own first line never came out. A plain platform abort gives the process no window at all, so waiting for a signal means waiting for nothing. The item loop now refreshes the ledger itself on a wall-clock cadence (CHECKPOINT_INTERVAL_MS = 10 s, gated by the same pure rule via dueForCheckpoint()), which bounds how stale a lower-bound copy can be at about one item for one key-value write per 10 s. Same fix in amazon-reviews-snapshot; the Etsy bridge had no fallback of any kind and gets both.
A test file that ran nothing.tests/pipeline.test.js imports src/main.js, and src/main.js called await run() unconditionally at import — so importing it ran the whole Actor, Actor.exit() took the process down before the first assertion, and the runner reported the file as passing with zero subtests. The checkpoint-latch test added in 0.2.8 was therefore never executed, while the suite still read 154/154 and the wiring assertions it contained were the evidence quoted for that build. src/main.js now has the invokedDirectly guard its two sibling Actors already had, the file runs its 5 tests, and the suite is 168 tests / 0 failures with two new wiring assertions (await maybeWriteCheckpoint() and invokedDirectly) so the guard itself cannot be dropped silently. Lesson recorded rather than patched around: a green suite only means the tests that ran passed, and a file contributing zero tests looks exactly like a file contributing passing ones.
Offline suite 168 tests, 0 failures (was 154, of which 1 was a phantom): E-1 currency discipline in five cases (converted-only model, native price beside a converted one, mixed currencies against a third listing currency, partial native coverage, depth/cycle/boolean guards on the search), E-2 in five (the gatekeeper's German pair plus four more, umlaut transliteration, German stopwords, an uncovered language staying false, and the service filter still winning over a German vocabulary hit), E-3 in three (gate truth table, signal dump writes and re-raises, hanging and failing writes still die by signal) plus the loop cadence rule.
0.2.8
One defect, shared with the two sibling Actors built from the same code. No parsing, matching, pricing or listing change.
A finished RUN_SUMMARY could be overwritten by a checkpoint.Actor.exit() emits one final persistState after the run body returns (node_modules/apify/dist/actor.js: "Close the event manager and emit the final PERSIST_STATE event"), and the checkpoint handler rewrote the completed report as a partial: true lower bound. Proven on xiaohongshu-note-tracker's cloud run yrrpPGvPgHcjPVlxr, which published partial: true, persistReason: "persistState" for a complete 5/5 run. Whether the write lands before the container dies is a container-exit race, which is why this Actor's own archive never showed it on a complete run. Latched with markRunSummaryFinal() plus an exported shouldCheckpoint() pure function, and pinned by tests/pipeline.test.js, which asserts the truth table and greps src/main.js for the three wiring points — drop any one and the bug returns while the truth table still passes. Offline suite 154 tests, 0 failures.
The legitimate checkpoint path is untouched, which matters because this Actor's 100-item runs genuinely do get cut by the 1,110 s self-cap. Verified on the sibling amazon-reviews-snapshot build 0.1.13 in both directions: a complete run reports partial: false, and a run aborted at 1 of 30 items still leaves partial: true, persistReason: "persistState" with completeness 1/30 — a readable lower bound, exactly as docs/failure-semantics.md promises.
cloud-tests/setup-canary.mjs was create-only. A second run 409'd on schedule-name-not-unique and --disable could never reach an existing schedule, so the script's own header ("Creates (or toggles)") was false. It now looks the schedule up by name and PUTs, and carries the live schedule's explicit runInput verbatim (read back over the API on 2026-10-25) instead of a bare RUN_ACTOR action — a bare action relies on the platform injecting the input-schema prefill, which it does not do reliably (this canary ran a 4-second empty run on that configuration on 2026-10-23, and an empty canary charges nothing, which canary-auditor reads as red). Re-running it is now idempotent and was verified to leave the live schedule unchanged. Note that GET /v2/schedules does not return actions[].runInput, so every schedule looks bare in a listing; reading back the single schedule is the only way to tell.
Same latch fix applied in amazon-reviews-snapshot (build 0.1.13) and xiaohongshu-note-tracker (build 0.1.3).
0.2.7
Documentation and listing-copy follow-up to 0.2.6. No src/ change — the source files are byte-identical to build 0.2.6 (yHTps7Ny4z2UzLhOJ, SHA-256 verified), so every 0.2.6 verification run stands for this build.
D-12 measured, and the memory claim corrected. A full maxItems=100 run over 100 live ebay.com listings (run Ma8343rd1ZZpfKro4, maxSuppliersPerItem=5, 1024 MB) peaked at 457 MB (memMaxBytes 478834688) — no OOM, ~55% headroom. The binding limit at the top of the range is the run window, not memory: the same run delivered 100 source rows and 89 complete shortlists, then cut 7 items with trigger: "deadline", completeness 100/100, {1, 99, 445} events / $3.64955 / chargeErrors 0. README and the maxItems description had claimed 1024 MB "covers the full 100-item cap" — true about memory, misleading about the batch. Both now carry the measured peak and the window ceiling.
The 1,110 s self-cap is now disclosed. The Actor clamps its working budget to ACTOR_MAX_RUN_SECS (1,110 s) irrespective of the platform timeout, so raising the run timeout past 1,200 s buys no extra items. That was undocumented; the README Run window caveat now states it and advises splitting large jobs across runs.
skippedBy.blocked mixes two billing outcomes — now stated.docs/failure-semantics.md records that the bucket holds both eBay-leg blocks (no source row, free) and 1688-leg risk-control fuses (source row delivered and billed, zero match rows), and points at challengeStats plus per-item fetchStatus to tell them apart. In the 100-item run the bucket was 4 = 1 eBay http_403 + 3 1688 fuses.
Listing copy caught up with D-6.ebayUrls and README still advertised "five supported marketplaces" after 0.2.6 dropped ca from the enum; both now say four and name the free unsupported_marketplace_ebay_ca rejection.
Fictional values removed from the Console/API examples. The ebayUrls example carried /itm/123456789012 (a made-up ID that yields a free notFound row for anyone who copies the snippet) and imageUrls carried i.ebayimg.com/.../abcAAOSwXYZ/s-l1600.jpg plus example.com/product-photo.jpg, neither of which resolves. All three are now URLs captured from a delivered cloud run.
input_schema.json is strict JSON again. A trailing comma in the marketplace enum survived because Apify normalizes the schema at push; removed so JSON.parse accepts the repo file. The deployed schema object was already identical (enum verified ["com","co.uk","de","au"]).
Test-harness fixes.cloud-tests/case-r4-mem.json was silently measuring 20 items rather than 36 — it omitted maxItems, so the platform default applied; fixed, and superseded by case-r4b-mem100.json for the memory measurement. run-case.mjs also reads the platform's chargedEventCounts before it settles: the 100-item report file records supplier-match: 440 while the settled value (re-fetched two minutes later) is 445, matching RUN_SUMMARY.billing.chargedEvents and the dataset row count. Trust the ledger, not the harness snapshot.
0.2.6
D-6 resolved by removing ebay.ca, not by re-routing it. The per-marketplace residential exits shipped in 0.2.3 were verified in the cloud (run b8p40i6SgXoeQ2l6y, CA exit, 4 live .ca items): 4/4 still interactive_challenge, 24 eBay requests, zero deliveries, zero charge. Combined with the gatekeeper's 6/6 through a US exit, that is 10/10 across two exit countries, so the geographically matched exit — the ticket's option ① — does not rescue the site. Option ② is taken instead: ca is out of the marketplace enum, an ebay.ca URL is rejected before any request with the named reason unsupported_marketplace_ebay_ca (free, listed in RUN_SUMMARY.invalidInputs, completeness identity intact), and README/Store copy now says four marketplaces with the measurement stated. The unused CA proxy config is no longer created at startup.
Quantity sources merged field by field instead of view-model-first. 0.2.5 preferred the embedded QuantityViewModel over the DOM block; on a variation listing the two disagree (/itm/126530835779 carries 库存 3 件 in the DOM and outOfStock:true in the model), and view-model-first threw the displayed stock away. #qtyAvailability is what the buyer sees, so each field now comes from the DOM when the DOM has it and from the view-model when it does not. outOfStock is honoured on the view-model path only — borrowing it into the DOM path flips a plain "3 available" into a sold count.
This is the build handed to the release gatekeeper for re-verification. 0.2.3/0.2.4/0.2.5 were internal iterations, each deployed and cloud-verified; every defect they exposed is listed under its own entry below.
0.2.5
D-3c soldCount/availableQuantity now read the buybox view-model first. 0.2.4 read only the .x-quantity__availability DOM element, which cost real coverage: on the pages served to the cloud exits that element can be absent or empty while the QuantityViewModel the block is rendered from still carries both facts. Ground truth captured live on 2026-10-24 (ebay.com/itm/317859319194: ["最后一件","已售出 8 件"], outOfStock:false; /itm/126530835779: ["缺货","已售出 940 件"], outOfStock:true; /itm/800738509930: ["库存 10 件"], no sold fact) now parses to soldCount=8 / availableQuantity=null, soldCount=940 / availableQuantity=null and availableQuantity=10 / soldCount=null respectively. The DOM element remains the fallback; body-text scanning stays removed.
Same position rule for both sources: facts[0] is stock, facts[last] is the sold count; a lone fact is a sold count when it says so in any supported language or when the view-model reports outOfStock.
0.2.4
Cloud verification of 0.2.3 against the gatekeeper's own sample set (run xUeomRH3sVTEGahPl, 18 items, $1.08455, completeness 18/18) found four defects in the remediation itself. All four are fixed here; no billing semantics changed.
D-5 the flywheel guard was dead code. It tested APIFY_IS_BEING_RUN_ON_PLATFORM, a variable Apify never sets; the real platform flag is APIFY_IS_AT_HOME=1. Both call sites now go through the shared isCloudEnvironment() helper that main.js already used, and the test drives the real flag and asserts the SDK store is never opened. Cloud evidence for the 0.2.3 failure: cloud-tests/evidence/r1-d1234.log lines 27 and 30 still carried "Insufficient permissions for the key-value store".
D-3b soldCount leaked other listings' numbers. 0.2.3 un-short-circuited the parser but kept extractSoldCount($('body').text()), so a recommended-items carousel supplied the value: a listing with no sold history at all (186933747946) came back as soldCount=29, and a single-fact page (157995770313) as 159. Live DOM evidence (195907826307 / 157995770313, 2026-10-24): .x-quantity__availability holds one <span> per fact, availability first, sold second, and there is no .x-sold-count element on current pages. extractQuantity($, html) now reads only that element, decides by POSITION (never by localized phrase), resolves the single-fact case with a sold-phrase test and the embedded QuantityViewModel.outOfStock flag, and handles 2.5K/1.2M suffixes. Body-text scanning is gone; the fixture now carries a carousel trap so the leak cannot return silently.
D-1 every 3+ character word-map entry was dead. Mapped CN phrases were tested against b.cjk, which only ever holds 2-grams, so 置物架, 固定架, 仪表台 and friends never matched — a spice rack against a kitchen-rack offer was scored irrelevant. Mapped phrases are now substring-tested against the offer title. All six of the gatekeeper's pathological pairs stay false and the three benign controls stay true.
D-2 bestOfferIndication also accepts a boolean value. 0.2.3 matched only the object form; {…} or true now both fire while the i18n word table's string entry still does not.
D-12 run memory default raised to 1024 MB. The 0.2.3 peak-shaving (sharp.cache(false), base64-only prepared items) is kept, and the default now covers the documented 100-item cap instead of leaving the README's "raise the budget for full batches" advice pointing at an OOM. maxItems and the README state the default and the 2048 MB step for high maxSuppliersPerItem.
availableQuantity semantics documented. It is the displayed figure, and eBay caps that display at 10 ("More than 10 available"), so 10 means "at least 10". The embedded NumberValidation.maxValue carries the true depth but doubles as the per-buyer purchase cap, so it is deliberately not used.
Cloud-test harness fixed for D-13. The platform ignores /runs?timeoutSecs=; the honoured parameter is timeout. cloud-tests/run-case.mjs now sends timeout, accepts --timeout/--abort-after, and records memMaxBytes, chargedEventCounts, exitCode and the run log in every evidence bundle. cloud-tests/probe-ebay-dom.mjs is a new selector-rot probe: it fetches an item page through a US residential exit and reports which best-offer/quantity/seller structures are present, without ever printing the token.
0.2.3
Release-gate remediation (gatekeeper findings D-1…D-13 on build 0.2.2). No billing-semantics changes: charging, zero-charge-on-failure, dedup, budget/quota/deadline stops and the completeness identity all behave exactly as in 0.2.2 (all re-verified by the gatekeeper).
D-1 relevantMatch rule tightened.similarityRank <= 3 is no longer a shortcut to true — the flag now requires a real EN↔CN vocabulary hit (word map or latin token). The old OR branch made the flag an alias of the rank exactly where buyers filter (rank 1-3), and 35% of rank-1 rows were visibly cross-category (scene-photo nearest neighbours). Service-type offers (加工/回收/加盟/代理 — processing, recycling, franchise/agency) are always false. Word map extended (phone/holder/car/magnetic/garden/tool/…) so genuine matches keep their true.
D-2 bestOffer selector rebuilt.[data-testid="x-best-offer"] is dead (0 hits on positive and negative pages alike). Replaced with three live-verified structural verdicts: the embedded bestOfferIndication view-model key (object-valued — the i18n word table carries a same-named string entry and is deliberately not matched), the .x-price-primary__orBestOffer span inside the price block, and the x-offer-action CTA. Never text-based: the page is served in the visitor's language.
D-3 parser de-short-circuited. Every detail-page field is extracted in its own try — one dead block can no longer null out the fields after it (a page plainly showing "库存 3 件" + seller awan0309 previously returned both as null). availableQuantity reads the first number in .x-quantity__availability (language-independent; sold-only lines are not misread as stock). sellerName is now the account username (from the seller card's /sch/{username}/m.html link or the embedded userAccountName), with the store slug split into the new storeSlug field; lookups are scoped to the seller card because the page also carries /str/ links of PROMOTED listings from other sellers. listingType is a structural verdict (embedded binModel/auctionModel + bid widgets) instead of localized button text — it was null on 36/36 sampled pages.
D-4 variation-minimum price disclosed. Multi-variation listings show their cheapest variation before selection (£5.00 displayed on a £5–£200 listing). Source rows now carry priceIsVariationMinimum and variationPriceRange {min, max, count, currency} parsed from the embedded variationsMap, so margin computed off the minimum is visibly flagged.
D-5 shared warm-token flywheel: explicitly disabled, not configured. The Actor has no MTOP_KV_API_TOKEN/MTOP_KV_STORE_ID, and the secret's value cannot be read back from the platform (only its valueHash), so it cannot be copied from the sibling actors. Instead of running with a half-working assumption, the cache now logs flywheel disabled: missing MTOP_KV_* env once, in internal terms, instead of leaking the SDK's "Make sure you're passing a correct API token" permission error. Identities self-bootstrap over HTTP (no browser), which is what the three sibling bridges do as well. NOTE: the 0.2.3 guard tested APIFY_IS_BEING_RUN_ON_PLATFORM, a variable Apify never sets, so the cloud run still logged both permission failures — fixed in 0.2.4.
D-6 ebay.ca unblocked: per-marketplace exit countries. The eBay leg exited US for all five marketplaces; .ca measured 6/6 interactive_challenge while .com/.co.uk/.de/.au delivered fine in the same runs. Each marketplace now exits residentially in its own country (US/GB/DE/AU/CA), with US as the fallback when a country config is unavailable.
D-11 killed runs keep their ledger. A persistState hook rewrites a partial RUN_SUMMARY (billing + completeness included) once anything billable has been charged, and the fatal-exit path now persists the same enriched snapshot instead of a bare {errorCode: FATAL}. Charged money ⇒ a readable ledger (persistReason marks non-normal exits).
D-12 peak memory shaved.sharp.cache(false) (the LRU is pure overhead in a one-shot run) and phase-2 prepared items keep only the base64 upload payload, not the raw JPEG buffer alongside it. maxItems docs now advise 1024 MB+ for 50-item batches (~12 MB/item measured).
D-8 proxyConfiguration input removed. The platform validates proxy groups at startup (a bogus group 400s the run) while the code never read the field — "informational only" was false advertising. Residential exits are enforced internally.
D-7/D-9 docs honesty pass.maxTotalChargeUsd documents the micro-cap two-phase caveat; maxRequestsPerRun documents per-item slot metering; marketplace no longer claims to affect image inputs; the key-value store schema declares RUN_SUMMARY.billing/completeness; docs/failure-semantics.md (referenced by source comments but missing) now exists.
D-10 repo hygiene. CHANGELOG back-filled for 0.2.1/0.2.2; package.json version aligned with the deployed build. Git initialisation is pending the repository owner's authorisation; in the meantime cloud-tests/deploy.mjs pushes SOURCE_FILES and the local↔cloud parity of all 25 source files is verified by SHA-256 before every verification run.
0.2.2
ebayUrls input prefill: three real canary items (.com ×2, .co.uk ×1) so the daily canary schedule and the Console Try form start from listings with verified delivery.
Dataset schema: marginCurrency description reworded to the 0.2.x currency semantics (the currency the listing's price is denominated in, normally the marketplace currency; a foreign-currency listing keeps its own).
0.2.1
Disclosure wording aligned with the 0.2.0 multi-currency margin fix across input_schema (marketplace), README (margin math, prices-vary caveat) and the supplier-match pricing-event description: margins are estimated in the currency the listing's price is denominated in — normally the marketplace currency (USD, GBP, EUR, AUD or CAD); a listing priced in a foreign currency keeps its own.
0.2.0
Fixed a margin-currency mismatch found in the cloud P0 matrix: listings priced in a currency foreign to their marketplace (a GBP price served on ebay.com — 3 of 16 matrix items) had their margin computed with the marketplace's expected currency (USD), skewing per-unit margins by the FX gap. The margin leg now follows the price's actual DOM currency; marginCurrency falls back to the marketplace currency only when the price text carries no marker.
Cleaned conditionLabel noise: the x-item-condition block can concatenate its visible label twice plus "More information - About this item condition" (e.g. "NewNewMore information - …"). Labels are now trimmed of the trailing boilerplate and de-duplicated ("New").
Dataset schema: bestOffer description now states it is best-effort from static HTML (the buy box is client-rendered on many listings, so the field is null there); wording matches listingType/sellerName.
0.1.0
Initial private release. Fourth instance of the sourcing-bridge engine (after aliexpress-to-1688, amazon-fba-sourcing-bridge and etsy-to-1688-sourcing-bridge): eBay leg + shared 1688 reverse image-search leg.
Three input kinds: ebayUrls (/itm/{id} and /p/{id}, all five v1 marketplaces), itemIds, imageUrls. No keyword discovery mode — eBay bridge semantics live in the URL inputs. Global cap maxItems (default 20, max 100) on the deduplicated union, first come first served.
Five marketplaces (com, co.uk, de, au, ca) with per-item marketplace attribution: URL entries carry their host marketplace, bare IDs use the marketplace setting. Prices are read in the marketplace currency.
eBay leg: pure HTTP with sticky residential sessions (hyphen-free session IDs, homepage warm-up, full navigation headers). splashui passive proof-of-work challenges are waited out (2 s polls, ≤15 s, first attempt only); interactive challenge forms rotate the session immediately and are never solved; ≤2 rotations before blocked. 404/410 → notFound; 403/429/503 → rotation.
Source rows carry eBay procurement fields: price + currency, condition, soldCount, availableQuantity, listingType (buyItNow / auction / auctionWithBin), bestOffer, seller storefront name/URL. Currency trap handled: DOM price block is authoritative; JSON-LD price is a fallback only when its declared currency matches the marketplace.
Image handling: main image rewritten to s-l1600.jpg (forces a real full-size JPEG), magic-byte verification, sub-10 KB placeholder detection with an s-l500.jpg downgrade retry, WebP conversion fallback.
Match rows carry the relevantMatch boolean (EN↔CN title-overlap heuristic or similarityRank ≤ 3) plus full supplier economics with multi-currency margins: per-item marginCurrency from the marketplace, a single CNY→local FX snapshot per run, fxUsdCny/fxCnyToLocal disclosed on every row (marginBasis: consign / wholesale / none).
Pay-per-event billing: run-base-fee $0.00005 + ebay-item-lookup $0.005 + supplier-match $0.008 (six-tier plan discounts). Failed, blocked and empty lookups are never billed; the base fee is charged only when at least one billable row is delivered.
Protections: per-run budget cap, request quota, 1,110 s internal deadline with a reserved 1688 phase window (eBay phase reserve 300–420 s), session rotation (never solving challenges), 1688 risk-control canary + circuit breaker, completeness identity verified before exit, challengeStats (seen / interactive / waitPassed / blockedByChallenge) in RUN_SUMMARY.
Intentional divergence from the sibling bridges: fx.js adds AUD to the FX currency set (bounds 0.08–0.4) and maps the au marketplace to AUD. The other bridges do not carry AUD; if this bridge's fx.js is ever diffed against them, this addition is by design, not drift.