# Changelog of eBay to 1688 Sourcing Bridge: Suppliers & Margins (`crawleast/ebay-to-1688-sourcing-bridge`) Actor

- **URL**: https://apify.com/crawleast/ebay-to-1688-sourcing-bridge/changelog.md
- **Full Actor documentation**: https://apify.com/crawleast/ebay-to-1688-sourcing-bridge.md

## Changelog

### 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 `N item(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,
*every* `skippedBy.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 *before* `readPricing()` 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.
