No-login Amazon snapshot: product face, rating aggregates with the 5-star histogram and the top reviews with full text (up to 8 US plus up to 5 other-country). $0.02 per snapshot, $0.008 ratings-only. Failed lookups are free, and so is any product with no rating data at all.
Build 0.1.18 (2026-10-26) — the published draw-rate statistics were stale, and 512 MB is now provably unreachable
No src/ change between 0.1.16 and 0.1.18. Every runtime finding recorded against
0.1.16 holds for 0.1.18; the two builds in between moved documentation and
package.json, which is why this entry covers all three.
0.1.17 corrected two buyer-facing statistics that our own evidence no longer
supported.README.md ("How the review draw works") and the maxVariantRetries
description in .actor/input_schema.json quoted a first-draw review hit rate of
57% and an eventual rate of 89% over four draws, from a pool of 54 products
measured up to 2026-10-24. Re-deriving the same statistic from the 23 evidence
runs that used the default maxVariantRetries=3 gives 101 products, 163 draws,
37% on the first draw and 59% eventual, 1.61 draws per product — and 2026-10-26
was a heavy-wall day (run QPdJNGiqH7QLJQ2wa found 28 of 50 products blocked),
so the two pools are not the same population. Both fields now quote the larger,
more recent pool and state the drift instead of hiding it. docs/cost-model.md
§5b was rewritten to match and withdraws two of its own earlier conclusions:
"four draws reach 88–91%" is now 59%, and "the draws after the fourth are nearly
worthless (17% on n=6)" cannot be read off the current pool at all (draw 4
measured 29% on n=7). maxVariantRetries=3 stays the default as an owner ruling,
not as something the table still proves.
cloud-tests/cost-report.mjs now splits the lottery by maxVariantRetries and
prints an all-runs line as well. Pooling the two maxVariantRetries=1 runs into
the default's statistic understated it, and a reader cannot tell a mixed pool from
a per-setting one.
S-6 is now proven on the platform, not just declared.minMemoryMbytes: 1024
is baked into the build, but neither GET /v2/acts/{id} nor the build record
echoes it back, so the declaration was unverifiable from the API. Three probe runs
with an empty asins list settle it: requesting ?memory=512 and ?memory=256
both come back with options.memoryMbytes: 1024 (runs CdSxhVztYez54mAEd,
PVYDjtUZgEbsu4fGF), and ?memory=1024 is honoured as asked
(1ja2yZAWfVk3eMyLq). The 512 MB trap tier the gatekeeper flagged is unreachable.
Two earlier probes were void and are recorded here so nobody re-reads them as
evidence: memoryMbytes as a query parameter is silently ignored (runs
QcwBrteA5BZwy6oXk, zLlwzu3QE3WqMjSxF both came back at the 2048 MB default);
the parameter name is memory. All five probes charged zero events.
0.1.18 exists only to close a version gap. 0.1.17 was cut with the corrected
README and input schema but with package.json still reading 0.1.16 and no
changelog entry for itself, so the build tagged latest was undocumented in its
own history. This build re-uploads the same src/ and .actor/ with both
aligned. cloud-tests/check-parity.mjs reports 0 diffs across all 17 uploaded
files.
Offline suite 103 tests, 0 failures.
Build 0.1.16 (2026-10-26) — S-2 was not actually fixed in 0.1.15, and one S-5 row lied
Two additions on top of 0.1.15, both found by re-running 0.1.15 on the platform
rather than by reading it.
S-2 follow-up (P0 for the fix, not for the Actor) — the signal handler never
runs on a plain abort. 0.1.15 shipped installSignalLedgerDump() and the
widened gate, and cloud run AcYXt0VlSzqwtRpwK (case S2-abort40, aborted at
43.1 s with 3 snapshots already charged) still produced noRUN_SUMMARY
and no OUTPUT: the container log ends at "ACTOR: The Actor run was aborted by
the user" and the handler's own first line —
SIGTERM received; writing the charge ledger before exiting — never came out.
So the gatekeeper's prescribed remedy (call persistPartialSummary from
SIGINT/SIGTERM) cannot work on its own: the platform's abort gives the process
no window at all, and Actor.on('persistState') is a ~60 s tick, which is why
the archived 90.4 s abort looked fine and the 43 s one never did. The ledger is
now also refreshed from the loop while the run is alive:
CHECKPOINT_INTERVAL_MS = 10000, decided by the pure dueForCheckpoint() and
awaited at each item boundary, so at worst a killed run's copy is one item
stale, for one key-value write per 10 s. The signal handler stays as a
secondary path — it does help on a graceful stop — but it is no longer the
load-bearing one. Same wiring ported to the eBay bridge (E-3) and to the Etsy
bridge (Y-2, which had no fallback of any kind).
S-5 follow-up — a budget-forced downgrade reported the tier the buyer asked
for, not the tier they got. The pre-gate fix reserves the cheapest tier a
product can be billed at and skips the review lottery when the room will not
cover a snapshot. The row still carried mode: "snapshot" with reviews: [],
which reads as "the lottery missed" rather than "we never drew". mode is now
derived from the settings actually used for that product, so a forced
downgrade says mode: "ratings" and is billed at the ratings price; the count
is logged per product and summarised in RUN_SUMMARY.notes
(
Nproduct(s) were fetched as ratings-only because the remaining budget could not cover the snapshot tier
). Those products stay excluded from the variant
hit-rate telemetry, which would otherwise record a deliberate non-draw as a
lottery miss.
Offline suite 103 tests, 0 failures (was 102): the dueForCheckpoint cadence
rule.
package.json is aligned to the cloud build number from this build on. It had
been tracking a separate local semver (0.1.4 against cloud 0.1.15), which is
how a reviewer ends up comparing the wrong two things.
Seven items from the gatekeeper's 20261025-整改单-未发布三Actor-交开发agent.md, all
reproduced from cloud runs on 0.1.10. Two owner rulings are in this build: the
Store title, and the fuse threshold. No price changed.
S-1 (P0) — an empty ratings tier was charged as a ratings delivery. Cloud
run fvcqKhzUfz2wXEihc (S4-deadonly8) delivered B000000012 and
B000000013 with ratingAverage, ratingCount and all five histogram
buckets null, reviews: [] and billedEvent: "ratings" — a charge for the
one thing the row did not carry, on exactly the old/new/unrated products a
bulk list is full of. buildRow now bills ratings only when
hasRatingAggregates() finds an average, a count or any bucket; such a row is
delivered free with freeReason: "no_rating_data", counted in the new
delivered.okUncharged and coverage.noRatingData, and explained in
RUN_SUMMARY.notes. Review text without aggregates still bills snapshot:
the reviews are the expensive half of that tier, and giving them away would
cannibalise it. Chosen over the alternative (a third priced "product face
only" tier) because a new event means a new price to justify, and the honest
default here is free.
S-2 (P1) — a kill inside the first minute left money charged and no ledger.nffKgqO0u46tNJOUc was aborted at 43.0 s with the platform already showing
{snapshot: 2, ratings: 1} and 4 dataset rows, and the key-value store held
nothing but INPUT. The SDK installs no signal handler at all (grep over
node_modules/apify and node_modules/@apify: zero hits for SIGTERM/SIGINT),
so Node's default action killed the process before any async write; persistState
only fires about every 60 s, and the archived 90.4 s abort looked fine purely
because a tick had happened to land first. installSignalLedgerDump() now
writes the ledger straight from SIGTERM/SIGINT under a 5 s budget and then
re-raises the signal with its default action restored, so the platform still
reports the run ABORTED rather than reading a synthetic exit code. The
checkpoint gate also widened from chargedUsd > 0 to
chargedUsd > 0 || intendedUsd > 0 || deliveredRows > 0, because the
platform's charged counter can lag our own push. Same fix in the eBay bridge
(E-3) and the Etsy bridge (Y-2, which had no fallback at all).
S-3 (P1) — an unread pricing record was indistinguishable from a correct
read.readPricing started from {...LIST_PRICES} and overwrote only what
it could read, so run YLkdR9a2U8eQU2fBx reported chargedUsd 0.204 while
the platform's own event counts priced the same run at 2.4× that, with
notes: []. billing.pricingSource is now platform,
platform-read-failed or inactive, billing.pricingReadError carries the
reason, PRICING_READ_FAILED lands in notes and OUTPUT, and the startup
log says it at error level. A failed read also reserves
PRICING_UNCERTAINTY_FACTOR (3×, above the measured 2.4× divergence) per
event in the budget pre-gate: the platform charges its own price whatever we
believe, so the only lever we hold is stopping earlier. An undelivered tail is
reported in skippedBy.budget; an overspend cannot be handed back.
S-4 (P1, owner-ruled) — the fuse no longer swallows a large batch. A fixed
threshold of 2 stopped run YLkdR9a2U8eQU2fBx at 14 of 50 products with
skippedBy.fuse = 36: 36 products the buyer asked for were never attempted, a
24% delivery rate by request, named only by a field key. fuseLimit() now
returns max(2, ceil(10% of the batch)) — 2 up to 20 products, 5 at 50, 10 at
the 100 default — measured against the run's own queue, so a 200-ASIN list
with maxItems: 20 still fuses at 2. The walls in that run were scattered, not
clustered, so the scaled threshold attempts all 50 while a genuinely walled
exit still stops at 10% instead of burning the whole batch. fuseLimit is
published in RUN_SUMMARY, and the fuse reason line now states how many
products were never attempted (the gatekeeper's option ③ as well as ①).
Counting discipline unchanged: a 404 still neither feeds nor clears the
counter.
S-5 (P2) — the $0.04 platform minimum bought nothing. The pre-gate
reserved the worst-case tier for every product, so S11-cap-floor (3 ASINs,
cap $0.04) delivered 0 rows at 0 charge. It now reserves the cheapest tier a
product can actually be billed at — ratings, since a snapshot whose draws
never carry reviews is billed as ratings anyway — and when the room covers
ratings but not a snapshot the review lottery is skipped for that product
rather than drawing reviews the cap would then refuse to deliver. Same money
for the buyer, less proxy spend for us, and the product arrives instead of
being dropped. Those forced single draws are excluded from the variant
hit-rate telemetry, which would otherwise record them as lottery misses.
S-6 (P2) — 512 MB is no longer offered.minMemoryMbytes was 512 while our
own 37-row batch peaked at 862 MB, so the "sometimes works" setting was the
most dangerous one on the menu. The floor is now 1024 MB; the default stays
2048 MB.
S-7 (P2) — contract disclosure.RUN_SUMMARY.billing was already
documented in the key-value store schema; it now also carries pricingSource
and pricingReadError, and the schema gained delivered.okUncharged,
coverage.noRatingData, fuseLimit and the signal:SIGTERM value of
persistReason. The dataset schema documents freeReason and adds it to the
overview view. The input schema's "up to 8 … plus up to 5 …, and never
more" was an absolute promise we cannot keep — Amazon can change that block
at any time — and is now "measured up to 13"; no hard maxItems was added to
the row schema, because a validation failure there would drop a real delivery.
The stale "4 of 11 / 9 of 11" draw statistics the gatekeeper saw on 0.1.10 were
already replaced by the pooled n=54 figures in 0.1.12.
Store title (owner-ruled: maximise traffic). Apify's Store search rewards
an exact title phrase over popularity: for amazon ratings the top three hits
are Actors with 2, 3 and 5 users whose titles contain that phrase, ranked above
a 6,726-user Actor titled "Amazon Reviews Scraper", which does not appear until
fourth because its title has no "ratings" token. The head phrase is
unwinnable for a new Actor (971 results, three of them above 6,000 users), so
the display title now reads "Amazon Reviews & Ratings Scraper: Histogram,
Top Reviews" (56 chars) — it keeps amazon reviews contiguous for the head
query, adds the contiguous ratings scraper phrase for the thin family, and
keeps histogram, where only 46 Actors compete at all. meta.title (the
Google-facing seoTitle) keeps the exact head phrase
"Amazon Reviews Scraper: Ratings, Histogram & Top Reviews", where external
search volume is concentrated. The Actor name still tokenises to
amazon reviews snapshot, so the head phrase is covered on both fields.
Offline suite 102 tests, 0 failures (was 90): fuseLimit scaling and both
batch shapes, the free no_rating_data row and its tallies, snapshot billing
when reviews exist without aggregates, the widened checkpoint gate, three
signal-dump cases (writes and re-raises, gives up at the budget, logs a failing
write and still dies), and three pricingSource cases including the 3×
reservation.
Build 0.1.13 (2026-10-26)
One defect, shared with two sibling Actors that were built from this code.
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 the sibling
xiaohongshu-note-tracker's cloud run yrrpPGvPgHcjPVlxr, which published
partial: true, persistReason: "persistState" for a complete 5/5 run; this
Actor's own archive shows it only on case-h-abort, because whether the write
lands before the container dies is a container-exit race — which is exactly why
it survived review. Latched with markRunSummaryFinal() plus an exported
shouldCheckpoint() pure function, and pinned by the last test in
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 88 tests, 0 failures.
Same fix applied in ebay-to-1688-sourcing-bridge (build 0.2.8) and
xiaohongshu-note-tracker (build 0.1.3).
partial: true was also reported for runs that had finished. The flag was
trigger !== 'done' || skippedTotal > 0, and skippedTotal counts
invalidInput, so a submission of one good ASIN plus five unparseable strings
published trigger: "done" next to partial: true with the reason "5 of 6
products were not fetched" — our phrasing of the buyer's typo as our shortfall.
Every other skip bucket is work a re-run could do (raise maxItems, raise the
cap, split the list, wait out the wall); an unparseable entry gets the same
verdict every time. unfinishedTotal (which excludes invalidInput) now drives
the flag, the completeness identity still counts all inputs, the reason line
names the real cause and says the entries were free, and a resource stop that
coincides with bad input reports both instead of dropping one. Found on the
sibling's cloud case E; UNFINISHED_SKIP_KEYS is exported next to SKIP_KEYS
with the reasoning, and two tests in tests/billing-summary.test.js pin it.
Offline suite now 90 tests, 0 failures.
No behaviour other than the checkpoint gate and the partial/reason wording
changed.
0.1.3 (2026-10-24)
Price re-rule only. No parsing, fetching or accounting behaviour changed; src/ differs from 0.1.2 in one frozen constant and one comment. Offline suite 87 tests, 0 failures.
Prices re-ruled $0.05 → $0.02 (snapshot) and $0.015 → $0.008 (ratings), flat, no volume ladder. The 2026-10-23 ruling was priced against the PRD's estimated COGS of ~$0.025/ASIN; the M1 batch measured $0.0019 per delivered row on the shipping configuration (2048 MB, datacenter), 13× lower. A live read of the Store on 2026-10-24 put this submarket at $0.0007–$0.0030 per review and $0.003 per product, and M1 measured 10.2 reviews per paid snapshot row — so $0.05 was $0.0049 per review, dearer than every visible competitor while carrying a tenth of their review depth. $0.02 is $0.0020 per review, inside the band, and still keeps 88% of our 80% share against a $0.0024 break-even (docs/cost-model.md §5/§6). ratings keeps 70%.
No ladder was committed.pricingInfos are append-only, so a volume discount granted before there is any real volume could never be withdrawn. The re-rule is record #3, appended by cloud-tests/deploy.mjs --repricing (PUT the sanitized full history plus one new record with startedAt = now); a decrease takes effect immediately with no 14-day notification window, and the two superseded records stay readable on the actor.
The registered snapshot event description was corrected in the same append. It said "8 from the US marketplace" where M1 measured up to 8 (one product delivered 9 = 8 US + 1 Saudi Arabia, another delivered 1). Correcting prose also means appending a record, so it rode along rather than costing a fourth one.
Nothing else can drift with the price.LIST_PRICES in src/billing.js is only the local/offline fallback — on the platform readPricing overwrites it with the prices the run was actually issued (ChargingManager.getPricingInfo().perEventPrices) — and tests/billing-summary.test.js now fakes a run issued at prices deliberately different from LIST_PRICES (with an assertion that they differ), so the offline suite proves the platform price wins instead of quietly agreeing with the constant. cloud-tests/cost-report.mjs imports LIST_PRICES rather than re-declaring it.
Price-relative acceptance criteria.cloud-tests/CASES.md cases a/b/f used to hard-code chargedUsd = 0.05 and skippedBy.budget = 4; they now assert against billing.prices from the run summary plus the budget gate's tightness (chargedUsd + prices.snapshot > cap). How many rows fit inside case f's $0.06 cap legitimately moves with the price — 1 delivered at $0.05, up to 3 at $0.02.
The pooled review-lottery sample grew, and the listing now quotes the new one. The 0.1.8–0.1.11 re-verification and re-pricing runs added ten snapshot-mode runs to cloud-tests/evidence/, so the pool cost-report.mjs derives moved from 39 products / 63 draws to 54 products / 96 draws: first draw 62% → 57%, four-draw ceiling 90% → 89%, 1.62 → 1.78 draws per product. Within noise, and the conclusion (maxVariantRetries=3) is unchanged, but README, .actor/input_schema.json and docs/cost-model.md §5b quoted the smaller pool and would have disagreed with the tool that re-derives them. Both places now carry the sample size and an explicit warning that the pooled figures are a sample statistic which moves with every run added to evidence/ — the M1 arm alone (n=32, 62.5% / 90.6% / 1.69) is the stable citation, because that run is fixed history.
Docs: README pricing table, lottery table and troubleshooting row, .actor/actor.json, package.json, .actor/input_schema.json (three descriptions — maxVariantRetries also drops the superseded n=11 lottery figures, and maxTotalChargeUsd now states honestly that a run can cost at most maxItems × $0.02, so the default $20 cap cannot bind), .actor/dataset_schema.json (billedEvent), docs/cost-model.md §1/§5/§5b/§6, docs/failure-semantics.md.
0.1.2 (2026-10-24)
One delivery defect found by the acceptance matrix itself, plus two disclosure corrections the M1 batch falsified. Prices, limits and the parsing contract are unchanged. Offline suite 87 tests, 0 failures. This change set ships on tag latest; the runs that evidence it are named below with their own buildNumber, since Apify's build numbers are a platform sequence and not this semver.
Dead ASINs no longer fuse a batch. The zero-delivery counter incremented on anyfetchStatus != "ok", so two 404s at the head of a list tripped the fuse and every live product behind them was skipped unattempted and unbilled. Cloud case e is the proof (run R8H9j88xC5LztQkFN, build 0.1.4): B000000001 and B000000002 came back notFound with attempts=1, and the golden control B09B8V1LZ3 — which had delivered in every previous run — landed in skippedBy.fuse. The fuse exists to notice that Amazon is walling our exit; a 404 is Amazon answering normally about a product that does not exist, which is evidence against a wall. The rule is now the exported pure function nextFuseCount(current, fetchStatus) in src/main.js: ok clears the counter, blocked/error increment it, notFound leaves it untouched — so blocked, notFound, blocked still fuses, and a list of dead ASINs costs one cheap request each instead of the rest of the batch. The offline test used to mirror the loop inline, which is exactly why the defect was invisible to it; it now drives the exported function.
Review counts are not always 8 or 13. M1's 29 paid rows measured 13 (×14), 8 (×13), 9 (B0C3FTCYZL: 8 US + 1 Saudi Arabia — the other-country block is not always 5) and 1 (B0BL849JC9, a product with 8 ratings in total, which has 1 review). The real rule is min(8, US reviews) + 0..5 foreign. README (3 places), .actor/actor.json, .actor/input_schema.json (2 places) and .actor/dataset_schema.json said "8" or "8 or 13" as if it were fixed; they now say "up to 8 … up to 5" with the measured distribution. The registered snapshot event description still says "8 from the US marketplace" and cannot be edited in place — pricingInfos are append-only, so the corrected wording rides along with the owner's pending price re-rule rather than adding a third record for prose alone.
Fixed and re-measured in the cloud, twice: the same case-e input on build 0.1.8 (run 1tLHcfR7lG7aCw8ff) and again on 0.1.9 (run gXq3qERVtSdeuTOY1, 32.0 s, evidence cloud-tests/evidence/e-notfound-nofuse-*) now processes all four inputs — rows.total=4 (2 ok + 2 free notFound), trigger="done", zeroDeliveryFuse=false, skippedBy.fuse=0, completeness 4=4 — and the golden control B09B8V1LZ3 behind the dead ASINs delivered 8 reviews and billed snapshot; intended == charged == $0.10, chargeErrors=0, platform settled {snapshot: 2}. The run log carries the new line verbatim: B000000001 notFound (not_found) — free row, a 404 is not a wall; fuse counter stays at 0/2. It also documents why a notFound row may show attempts > 1: that ASIN took two signin_walls before Amazon returned the 404 on attempt 3, and no request follows a 404.
Docs: docs/failure-semantics.md §4 (trigger table + the 404 rule) and its review-block paragraph, cloud-tests/CASES.md case e (expected outcome inverted; the fuse-on-walls path is now evidenced by c-m1-datacenter, which fused on 5 real walls), docs/cost-model.md §4/§5/§6/§8.
M1 calibration — builds 0.1.4 (datacenter), 0.1.5 tag res (residential), 0.1.6 (memory raise), 2026-10-24
Measurement only; src/ is byte-identical between the two arms (the residential arm is the same source built with AMAZON_SNAPSHOT_PROXY_GROUP=RESIDENTIAL baked in, because task-level options.envVars is rejected by the API and actor-level envVars is rejected by PUT /acts/{id} — the supported path is POST /acts/{id}/versions/{version}/env-vars). Evidence: cloud-tests/evidence/c-m1-*, re-derivable with node cloud-tests/cost-report.mjs.
The 50-ASIN datacenter arm (WrgFps9vWRKPLhw0b, 648 s, 862 MB peak, $0.03818): 37 products attempted, 32 ok, 5 blocked (2 signin_wall, 3 robot_wall), 95 requests (2.6 per product), 118.4 MB decompressed. $0.00103 per delivered row. Review lottery at n=32: first draw 62.5%, four-draw ceiling 90.6%, 1.69 draws per product. No end-of-batch session decay — the walls were scattered.
The same 50 ASINs on residential (xYOjb2YmpFaCPkgCS, 87 s, $0.01311): 4 products attempted, 2 ok, 2 robot_wall, then the zero-delivery fuse stopped the run (46 ASINs skipped, unbilled). $0.00328 per delivered row, 3.2× datacenter. Residential recovered nothing, cost more, and cannot be calibrated at batch scale while the fuse is armed — the flag stays a measurement tool and the default stays datacenter. Measured wire/decompressed ratio on this arm: 4.58 (previous four runs gave 5.33–6.23), so the cost model's rule of thumb moved from ÷5.9 to ÷5.
Both arms ended on trigger: "fuse" with the completeness identity intact (37 + 13 = 50 and 4 + 46 = 50), which is the strongest evidence yet that a fuse stop is an honest partial, not a loss. Open product question for the owner, recorded in docs/cost-model.md §8: on a 50-ASIN batch two consecutive walls stop the run with a third of the list unattempted, so the limit may deserve to scale with the batch (max(2, 10% of requested)). Not changed here — everything skipped is reported and free.
Margins at the owner's prices, on measured COGS: snapshot keeps 98% of our 80% share on datacenter (92% on residential), ratings 92% (73%). Break-even list prices are $0.0012 datacenter / $0.0041 residential.
Default memory raised 1024 → 2048 MB (build 0.1.6, tag latest). M1 gate item 5 tripped: the 37-row datacenter batch peaked at 862 MB, 84% of the allocation, and the published maxItems default is 100, so a customer's first large batch would have been the OOM test. Compute is deducted from our payout, not added to the customer's bill, and it is 94% of the datacenter arm's platform usage, so the raise moves datacenter COGS from $0.0010 to ≈$0.0019 per delivered row and the margins from 98% / 92% to 95% / 84% (docs/cost-model.md §5 and §8). maxMemoryMbytes was already 2048. Smoke-tested on 0.1.6 at 2048 MB: run lRQ2m7GmvIsEOxxo0, SUCCEEDED in 18.3 s, snapshot × 1 = $0.05, usage.ACTOR_COMPUTE_UNITS 0.010161 = 18.29 s × 2 GB ÷ 3600 exactly, 190 MB peak.
Docs updated from these numbers: the README's lottery table (was n=11, now n=39 pooled with the M1 arm broken out), its datacenter/residential bullet (was "the residential sample is small, so we do not claim it is more likely to be walled" — the paired 50-ASIN test now says the residential arm walled 2 of its first 4 and fused), and docs/cost-model.md §3/§4/§5/§5b/§8. Gate-by-gate verdicts are in cloud-tests/CASES.md.
Build 0.1.6 carries 0.1.4's src/ unchanged — all seven source files predate 0.1.4's build finish (2026-10-24T02:52:33Z), and everything added since is metadata (defaultRunOptions.memoryMbytes) and prose (README, CHANGELOG, two schema descriptions). cloud-tests/check-parity.mjs reports cloud files: 17 | diffs: 0 | cloud-only: 0 against the live version; tests/schema.test.js now pins 2048 and asserts a real power of two (the old % 512 === 0 check would have passed 1536).
0.1.1
Driven entirely by the build-0.1.1 cloud acceptance matrix of 2026-10-24 (7 cases, evidence in cloud-tests/evidence/, re-derivable with node cloud-tests/cost-report.mjs). No feature was added; every change below is a defect the cloud exposed or a claim the cloud falsified.
Delivery defects
Ratings-mode rows were shipping review text. Case b (3CkVbyDma57X0dxAz) delivered 8, 0 and 13 reviews on rows billed ratings — the $0.015 tier was handing over the $0.05 tier's payload, which is both a contract breach and a revenue leak (why would anyone pay $0.05?). buildRow now strips reviews unless the mode is snapshot, and a test asserts no author name can reach a ratings row while the aggregates that tier does sell survive.
Walls were treated as terminal, and they are not. Case a (bIofcl4ltjAXcCzkt) made one attempt, got a 3,781-byte "continue shopping" interstitial, and reported blocked/region_redirect — the whole acceptance case delivered nothing. Two more observations on build 0.1.2 settled the general rule: B0DF472VMZ came back signin_wall in ThssIi7PVNCmnMu1k and delivered 13 reviews in Wbf34jiUGadccO6Dt; B09B8V1LZ3 delivered 8 reviews in 5pNbiwzeA6GmGYtlO and came back signin_wall two minutes later in zdvx5tBNXtPDgWnbJ. Same build, same exit type, minutes apart.
A wall is a verdict about our exit IP and fingerprint, not about the ASIN, so every wall shape is now retry — guard, the new robot_wall, arbitration, signin_wall, region_redirect, soft_wall and the no_product_face catch-all — and only HTTP 404 stays terminal. The loop rotates the proxy session and re-fetches up to MAX_TRANSIENT_RETRIES + 1 = 4 attempts, then records a free blocked row carrying the reason it last saw. Retrying costs ~18 KB of wire for an interstitial and ~50 KB for a sign-in shell against a $0.05 delivery; no_product_face being retryable is deliberate, because that bucket is where the next new Amazon wall shape will land.
Retry budget widened and unified.MAX_TRANSPORT_RETRIES = 2 → MAX_TRANSIENT_RETRIES = 3, covering transport failures, the edgex/guard interstitial and robot_wall alike (4 attempts, none of them a review-variant draw). The counter is shared, so a product cannot spend 4 transport retries and 4 draws.
Cost and exit policy
Both modes now default to the DATACENTER exit. Build 0.1.1 sent snapshot mode through residential at $8/GB and ratings mode through datacenter. Case b then proved the datacenter exit works and is unmetered on this plan: 3 products, 7.33 MB decompressed, 1.38 MB received by the container, no PROXY_DATACENTER_TRANSFER_GBYTES key in the run's usage at all, proxy charge $0. Measured COGS per delivered row: $0.0005 datacenter vs $0.0063 residential (h-abort, 8 rows / 13 requests). The review block arrived through both — 2 of 3 datacenter pages and 9 of 11 delivered residential products carried it — so residential bought nothing except cost. AMAZON_SNAPSHOT_PROXY_GROUP=RESIDENTIAL forces the other arm; the M1 batch runs both to settle it at n=50 instead of n=3.
docs/cost-model.md (new). Every rate, ratio and margin derived from the run objects: residential $8.00/GB and compute $0.20/CU as implied by usageUsd ÷ usage, wire ≈ decompressed ÷ 5.9 (6.23 / 5.92 / 5.88 / 5.33 across four runs), the per-case cost table, and the margin at the ruling's prices. It also records who pays what: platform usage is deducted from the developer's payout and Apify keeps 20%, so the customer's bill is the event prices and nothing else.
Listing accuracy — two claims the cloud falsified
"Top 8 reviews" is wrong. 13 review rows came back on 6 of the 8 products delivered in the matrix: Amazon renders its own top-review block (8) plus a "top reviews from other countries" block (up to 5). The P0 material's "硬上限 = 8 条" was measured on one product. Every buyer-facing string was rewritten — README first screen and limits, actor.json title/description/seoDescription, all four schema descriptions, package.json, and the registered snapshot event description — and a new test scans all eight listing surfaces for the falsified claim so it cannot creep back. reviews[].country is documented as the way to tell the two blocks apart, and reviewsCount is documented as 0–13.
The lottery table was modelled, not measured. "56% → 81% → 92% → 96%" assumed independent draws at the probe's 5/9 rate. The cloud measured 4/11 first draws (36%) and 9/11 eventual hits (82%) at 1.64 draws per product, with the per-draw curve 36% → 60% → 100%. The README now states the measured numbers and their sample size.
Report and telemetry honesty
variantStats no longer counts ratings-mode draws. Case b reported draws: 3, hits: 2, hitRate: 0.667 for a mode that never retries the lottery — pure dilution of the one number a buyer uses to tune maxVariantRetries.
An explicitly set humanReadableReason now wins over the computed tally. Case d exited with errorCode: VALIDATION_ERROR but the reason text was the generic skip summary.
RUN_SUMMARY.billing is present on every exit path (frozen ZERO_BILLING shape), so downstream readers get a stable key set even on a validation exit that never built a ledger.
Blocked and retry log lines now carry the byte count and the page <title>, which is what case a's log was missing.
Validation exits mark every valid item unattempted, so the completeness identity holds on an empty run too.
Platform caps learned the hard way
Two 400s from POST /acts: seoTitle ≤ 60 characters and seoDescription ≤ 200. Both PRD title candidates (68 and 74 chars) exceed the cap, so the shipped titles are new; tests/schema.test.js now asserts both limits locally.
Harness fixes (test scaffolding, not product)
Case g fused before its 70 s deadline could bite (trigger: fuse, 6 skipped by fuse, 0 by deadline), so it never tested the deadline path; case h SUCCEEDED in 102 s, well before the 150 s abort it was meant to exercise. Both cases are being re-cut with an isolating deadline case and a batch long enough for a persistState tick to land after charging starts.
run-case.mjs records chargedEventCounts twice (at finish and after a settle window), because the counter is eventually consistent: case b read {snapshot: 0, ratings: 2} at finish and {snapshot: 0, ratings: 3} settled.
Pricing note for the owner
The 2026-10-23 ruling ($0.05 / $0.015) was made against the PRD's estimated COGS of ~$0.025/ASIN. Measurement puts it at $0.0005–$0.0063, i.e. the estimate was 4–50× too high — it plugged decompressed bytes into the per-GB rate and assumed a residential exit. At the standing prices the snapshot event keeps 87–99% of our 80% share. pricingInfos are append-only, so no volume tier is committed until the owner re-rules; cloud-tests/deploy.mjs carries that reasoning inline.
Tests
86 unit tests, all offline (npm test): the golden fixture regression, the new retry/robot_wall classification and its recovery-then-exhaustion path, transient retries and variant draws as separate budgets, the ratings-mode review strip, resolveProxyGroup, the residential arm's counters, plus two listing-consistency gates (no falsified review count anywhere; every emittable blockReason disclosed in the README).
0.1.0
First implementation of strategy/2026-10-23_Amazon评论快照Actor_PRD.md. HTTP-only (no browser), anonymous (no login, no cookies, no challenge bypass), one dataset row per ASIN.
Pricing (owner ruling, 2026-10-23)
snapshot$0.05 / ratings$0.015, no run base fee, failed lookups free. The PRD's proposed $0.03 / $0.01 was rejected at review: against the measured residential cost of a review draw (~2–3 MB decompressed per draw, ~1.8 draws per hit, $8.016/GB on this account) $0.03 is a structural per-ASIN loss — the same trap that produced the 1688 product Actor's negative-margin months. At $0.05 the expected net is ~$0.039 against ~$0.025 COGS per ASIN.
Superseded by measurement in 0.1.1 (kept here for traceability): the ~$0.025 COGS figure plugged decompressed bytes into the per-GB rate and assumed a residential exit. docs/cost-model.md measures $0.0005–$0.0063 per delivered row. The prices stand until the owner re-rules.
A snapshot whose page variant never carried reviews is delivered and billed as ratings, not snapshot. Retrying the draw never raises the bill.
Both events are registered through the pricing API before any charge call (the unknown event trap).
What the golden fixture changed about the PRD
Every anchor was verified against a real 2,033,463-byte residential-exit dump (tests/fixtures/dp-with-reviews.html, B09B8V1LZ3, probe run ujNAssBYSFdeYKLBG). Six PRD anchors were wrong and are corrected here:
categories and bestSellersRank are dropped.wayfinding-breadcrumbs_feature_div, detailBullets_feature_div, #SalesRank and the literal string "Best Sellers Rank" all return 0 hits on a current desktop dp page. Shipping fields that are always null would have been a listing lie.
#histogramTable is a <ul> of five <li>, not a <table>.li.text() self-pollutes to 82%11%3%1%3%82% because Amazon renders the whole histogram inside every row. The primary anchor is each row's a[aria-label] ("82 percent of reviews have 5 stars"), which is self-describing and order-independent; a positional last-percent fallback is kept and unit-tested.
Review hooks are camelCase.reviewTitle / reviewText / review-by-line, not the kebab-case the PRD assumed.
review-by-line is the date wrapper, not the author. The author is .a-profile-name, the profile URL is a[href*="/gp/profile"].
reviewText is unusable. It carries Amazon's mobile UI scaffolding (Brief content visible, double tap to read full content., Read more). Bodies come from reviewRichContentContainer only, and a test asserts the scaffolding never leaks.
reviewId comes from the review container's own id attribute. The in-container link is /portal/customer-reviews/srp/-/R…, not the /customer-reviews/R… the PRD expected.
product-variation-attributes nests the Verified Purchase badge. Without removing it the variation reads Color: CharcoalSize: Pack of 1Configuration: Device onlyVerified Purchase. The badge is stripped and the remaining spans are joined with " | ".
#availability embeds a JSON blob. Raw text is In Stock {"isInternal":false,…}; the parser reads .primary-availability-message and cuts at the first {.
Anchors the PRD did not cover
The price anchor in the PRD is a whitespace spacer.#corePriceDisplay_desktop_feature_div .a-offscreen holds " " on current markup — Amazon's "reinvent price" variant puts the real value in .a-price-symbol/.a-price-whole/.a-price-fraction and leaves the offscreen span empty. The parser now walks four scoped containers (#corePrice_feature_div → #corePriceDisplay_desktop_feature_div → #tp_price_block_total_price_ww → #apex_desktop), skips .a-text-price (that is the struck-through list price), and takes the first numeric offscreen value.
priceIsRange (new field). A variation parent renders .a-price-range, whose low end is not the price of any purchasable variation. Without the flag a buyer would read "from $19.99" as "$19.99". Not verifiable on this fixture (no range present); detection is a class test.
Carousel contamination is a real trap on this page. The fixture embeds a recommendation card reading 4.7 out of 5 stars, 571,289 ratings for a different product (Amazon Smart Plug). Rating aggregates are therefore read only from this product's own unique #acrPopover / #acrCustomerReviewText / #histogramTable, and a golden test asserts neither 571289 nor Smart Plug can reach the row. Same class of bug as the eBay bridge's soldCount carousel leak.
country per review (new field). The date line reads "Reviewed in the United States on …" and the reviewer's marketplace can differ from the storefront queried. Free to extract, so it is.
Input and accounting deviations from the PRD
Bare-ASIN validation relaxed from ^B0[A-Z0-9]{8}$ to ^[A-Z0-9]{10}$ so ISBN-10 book ASINs work. Non-ASIN junk is still rejected, free, into invalidInputs.
proxyCountry is a single-value enum, not a textfield, so an unsupported country is rejected by input validation instead of failing mid-run.
skippedBy keys differ. Because every fetched ASIN produces a row here, blocked / notFound / error are delivered rows counted in delivered.failed (and free), not skip buckets. skippedBy holds only ASINs that never got a row: {unattempted, budget, deadline, fuse, invalidInput}. The identity is delivered.rows + sum(skippedBy) === requested.dedupedItems, verified before exit and failed loudly if broken.
No maxRequestsPerRun input.maxItems × (1 + maxVariantRetries) already bounds the request count, so a second quota would only add a knob.
A page carrying #productTitle but yielding no title text is an error/parse row, free, and stops the lottery immediately — redrawing cannot fix moved markup, and letting it trip the zero-delivery fuse is cheaper than four draws per ASIN.
Protection mechanisms
Budget pre-gate before each fetch (the run never spends proxy money on a product it cannot bill), plus the platform-side maxTotalChargeUsd.
Zero-delivery fuse after CONSECUTIVE_WIPEOUT_LIMIT = 2 consecutive undeliverable products.
Soft deadline derived from ACTOR_TIMEOUT_AT with a 48 s finish reserve and a 3,510 s self-cap under the 3,600 s platform default — never a hardcoded overrun.
persistState checkpoint ledger: once anything has been charged, every tick refreshes RUN_SUMMARY so an abort / OOM / hard kill still leaves a readable ledger (persistReason marks the copy, which is a lower bound rather than the final value).
Charge failure still delivers the row unbilled and increments chargeErrors — paid-for work is never lost to a billing error.
Every field parses inside its own try; a markup change nulls that field and names it in RUN_SUMMARY.notes as PARSE_ERRORS instead of short-circuiting the row.
Proxy session ids are snap<ASIN><salt><n> — word characters only, no hyphens (Apify rejects hyphens and fails every request in the session).
Tests
66 unit tests, all offline (npm test), including a full golden regression on the real dump: 8 reviews with every field populated, histogram summing to 100, rating count 201302 with the carousel's 571289 provably absent, bodies free of UI scaffolding, variations free of the verified badge, plus the variant lottery, guard/transport retry accounting, deadline behaviour, budget gate, fuse rule, billing ledger and the completeness identity.
Not done (V1 boundaries, per PRD §11)
No pagination or full review export (the anonymous channel does not have one), no browser channel, no EU storefronts, no cookie/account input, no translation or sentiment packaging.