Rebuild on the 2scraper template: three engines, three modes, 230 offline checks - #1
Merged
Conversation
…line checks
Replaces the previous requests+lxml scraper with the architecture used across
this family. Nothing from the old code is source- or output-compatible; it is
kept outside the repo as a reference.
WHAT WAS PORTED VERBATIM
output_writer.py, proxy_pool.py, env_config.py, diff_runs.py,
captcha_solver.py, fingerprint_client.py, scraper_api_client.py, both CI
workflows, issue templates, CONTRIBUTING/SECURITY/CODE_OF_CONDUCT,
Dockerfile, pyproject.toml.
WHAT IS NEW OR AMAZON-SPECIFIC
product_parser.py the site. No JSON-LD path, because Amazon publishes
none (0 application/ld+json blocks on search pages,
best-seller grids and product pages, measured
2026-09-08). Rows come from data-asin, with a
/dp/{ASIN} URL-pattern fallback.
page_flow.py the page-state policy, shared by all three engines so
they cannot drift on what a page means. Amazon answers
in five ways and four want different responses.
three modes --mode listing (search + best-seller grids, paginated),
--mode product, --mode reviews.
21 marketplaces derived from --url's hostname; no --marketplace flag,
so a flag and a URL cannot disagree.
DELIBERATE DEVIATIONS FROM THE FAMILY DOCUMENT
* No JSON-LD reader, and the tile-price overlay is NOT ported: there is no
second source of truth to reconcile. price_source now records which DOM
node was read (offscreen / split / detail).
* Two row schemas: Product for listing and product modes, Review for
reviews. <out>.meta.json records mode and source, and diff_runs.py
refuses a reviews run and a mode mismatch.
* page_flow.py is shared rather than duplicated per engine — the same
argument the family already makes for finish_run(), applied one step
earlier, because Amazon's triage is far more involved than Farfetch's.
* A `page` column (data-index restarts on every page, so position alone is
ambiguous) and NO `prime` column (0 Prime markers on any captured tile
from a cross-border exit — the column would be null on every row).
FIVE ANSWERS, FOUR RESPONSES
HTTP 202 + awswaf challenge.js wait; a browser clears it in 3.9s, unbilled
HTTP 503 "Sorry"/"Tut uns Leid" retry, then a different exit
/errors/validateCaptcha the only paid path (2Captcha image task)
final URL has /ap/signin stop; no proxy or solve helps
anything else parse
BUGS FOUND BY RUNNING IT LIVE, WHICH READING THE CODE DID NOT FIND
1. review_count was garbage on every row of every mode: an aria-label
reading "4.4 out of 5 stars, 279,961 ratings" was read by stripping all
digits, giving 445279961. Coverage looked like 100%.
2. Every best-seller grid reported "needs sign-in" — the header's
/ap/signin link matched an HTML-based detector. Signin is now decided
from the final URL, which is the only place it appears.
3. A best-seller page returned 30 of 50 cards and reported complete. The
scroll now jumps to document.body.scrollHeight and requires count AND
height to hold still for three rounds; ranks are cross-checked after
merging, because a per-page check cannot see a gap between pages.
4. --mode reviews was a coin flip. Amazon serves two page variants and the
variant is sticky to the SESSION (six reloads in a review-less context
stayed empty). Only a fresh browser re-rolls it, at a measured ~55% hit
rate, so the engines spend up to five sessions on it.
5. The detail price was the struck-through list price: the desktop price
container is present and EMPTY while the next one holds the number.
Fixed with :not(.a-text-price) and "try until one PARSES", not EXISTS.
6. The detail review_count came from a "customers also viewed" carousel
item; now scoped to #averageCustomerReviews.
7. Ratings were null on all 60 amazon.co.jp rows — Japanese states the
scale first ("5つ星のうち4.2").
8. "199 kr" parsed as no price at all: the symbol was in the
marketplace-resolution table but not in the matching alternation.
9. pyppeteer cannot launch from a worker thread (it installs signal
handlers), and its bundled x86_64 Chromium 117 will not start on an
arm64 Mac at all. Hence handleSIGINT=False and --chromium-path.
MEASURED, AND IN THE README AS THE CANARY'S FLOORS
3 pages of /s?k=bluetooth+headphones&i=electronics -> 90 rows, 77 after
dedupe, status=complete, exit 0, with no key, no proxy and no account.
16-30 organic tiles per page on .com, 16 on .de, 60 on .co.jp. Best-seller
grids 50/page, ranks 1-100 contiguous. 13 reviews per product page. Price
present on 71-83% of search rows (Amazon withholds the rest) and 100% of
grid rows. Cross-border prices are CONVERTED and drift with the rate: two
runs ten minutes apart differed on 10 of 43 ASINs by 0.04%, which is what
diff_runs.py --price-tolerance-pct exists for (default 0).
WHAT IS NOT VERIFIED
The image-captcha solve path. 47 navigations across five marketplaces
produced WAF challenges and 503s and never an image captcha, so it is
exercised only by an offline fixture written from Amazon's documented
markup. README, TROUBLESHOOTING.md and smoke_test.py all say so.
scraper_api_client.py is likewise untested against amazon.com.
TESTING
230 offline checks, fixtures cut from real captures and verified to parse
identically to the untrimmed originals. Suite passes with no engine
installed and reports what it skipped; engine-smoke installs all three and
fails on any skip. tests.yml on 3.9 and 3.12. canary.yml runs one real
3-page listing daily with floors set from the measurements above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cm7Ebf8wfGq2BvsxFa7rxY
Found by reading the first CI run's log rather than its badge: engine-smoke was green while verifying almost nothing about one of the three engines. Two faults, and the second caused the first. 1. `puppeteer_scraper` imported its driver INSIDE _Session.open(), so the module imported cleanly with no pyppeteer installed at all. The offline suite guards `import puppeteer_scraper` and reports a skip when it fails, and CI fails on any reported skip — that whole mechanism was vacuous for this engine, because the import could not fail. Its two siblings, and the rest of this family, import at module level; now so does this one. 2. Because nothing ever imported pyppeteer, CI did not notice that `pip install playwright pyppeteer selenium` — unpinned, into one environment — resolved the three engines' mutually unsatisfiable pins (pyee>=13 vs <12, urllib3>=2.6 vs <2) by reaching for **pyppeteer 0.0.25**, a stub release, instead of the >=1.0.2 requirements-puppeteer.txt asks for. CI was green against a version the repo does not support. engine-smoke is now a matrix of one venv per engine, installing from that engine's own requirements file — which is both what the README tells users to do and the only way to get the versions they actually get. `pip check` runs per venv too, so each requirements file is asserted self-consistent. Verified locally: playwright 1.60.0 / pyee 13, pyppeteer 2.0.0 / pyee 11 / urllib3 1.26, selenium 4.36.0 — three clean installs, three passing `pip check`s, and the suite passing in each with only the OTHER two engines reporting skips. Also added a guard for the guard: a check that all three engines import their driver at module level, so this cannot drift back silently. Confirmed the CI grep fires in the negative case rather than only staying quiet in the positive one. 239 offline checks (was 230). Counts updated in README, CHANGELOG, CLAUDE.md and pyproject.toml, and the CHANGELOG records what this fixed. pyppeteer engine re-run live afterwards: 51 rows over 2 pages, status=complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cm7Ebf8wfGq2BvsxFa7rxY
jehrr
added a commit
that referenced
this pull request
Sep 8, 2026
…#2) The fixtures are real page captures, and a real capture carries the session that fetched it plus, on a review, a real customer's name, profile permalink, photo ids and words. All of it is now an obvious placeholder; everything Amazon generates around it is untouched, because that is what the checks read. A guard fails the suite if a real session id, CSRF token or account id ever arrives with a future capture — patterns, not literals, and confirmed to fire rather than only to stay quiet. Also: both Claude workflows now skip when CLAUDE_CODE_OAUTH_TOKEN is absent instead of failing, and 0.1.1 is split out of the changelog because these landed after v0.1.0 was tagged. 244 offline checks. The pre-scrub values remain in this repo's history — the v0.1.0 tag and PR #1's refs — which no commit on top can reach.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Replaces the previous
requests+lxmlscraper with the architecture usedacross this family. Nothing from the old code is source- or output-compatible;
it is kept outside the repo as a reference, not deleted from history.
48 files, +11,225 / −2,697. 239 offline checks green, and every mode
verified against the live site.
What was ported verbatim
output_writer.py,proxy_pool.py,env_config.py,diff_runs.py,captcha_solver.py,fingerprint_client.py,scraper_api_client.py, both CIworkflows, issue templates, CONTRIBUTING/SECURITY/CODE_OF_CONDUCT, Dockerfile,
pyproject.toml.What is new or Amazon-specific
product_parser.pyapplication/ld+jsonblocks on search pages, best-seller grids and product pages, measured 2026-09-08). Rows come fromdata-asin, with a/dp/{ASIN}URL-pattern fallback.page_flow.py--mode listing(search + best-seller grids, paginated),--mode product,--mode reviews.--url's hostname; no--marketplaceflag, so a flag and a URL cannot disagree.Deliberate deviations from the family document
Four, each with its reason, because an audit reading the family document will
flag all of them:
no second source of truth to reconcile against, so porting the overlay
would be dead code that looks load-bearing.
price_sourceinstead recordswhich DOM node was read (
offscreen/split/detail).Productfor listing and productmodes,
Reviewfor reviews.<out>.meta.jsonrecordsmodeandsource,and
diff_runs.pyrefuses a reviews run and a mode mismatch rather thanpretending it can compare them.
page_flow.pyis shared rather than duplicated per engine — the sameargument the family already makes for
finish_run(), applied one stepearlier, because Amazon's triage is far more involved than Farfetch's.
Deliberately no JavaScript crosses that boundary: Selenium's
execute_scriptneedsreturn exprwhile the other two take() => expr.pagecolumn, and noprimecolumn.data-indexrestarts on everypage, so
positionalone is ambiguous across a paginated run. No Primemarker appeared on any tile captured from a cross-border exit (0 of 22 on
.com, 0 of 16 on .de, 0 of 60 on .co.jp), so that column would have been
null on every row of every run.
Amazon's five answers, and the four different right responses
This is the part worth reviewing most carefully.
awswaf.com/challenge.js--retries, then a different exit./errors/validateCaptcha/ap/signinin the final URLSending the WAF challenge to the solver, or reporting it as a block, were the
first two mistakes made here. Signin is decided from the final URL and never
from the HTML: the real signin page contains
/ap/signinzero times,while every ordinary Amazon page links to it from its header.
Nine bugs found by running it live, which reading the code did not find
review_countwas garbage on every row of every mode. Anaria-labelreading
"4.4 out of 5 stars, 279,961 ratings"was read by stripping alldigits:
445279961. Coverage looked like a healthy 100%. The suite nowasserts values on four real fixtures, not presence.
/ap/signinlink matched an HTML-based detector, so that whole page kindwas unscrapeable.
by scrolling to
document.body.scrollHeight(not a fixed wheel distance)and requiring count and height to hold still for three rounds. Ranks are
cross-checked after merging, because a per-page check cannot see a gap
between pages.
--mode reviewswas a coin flip. Amazon serves two page variants andthe variant is sticky to the session — six reloads in a review-less
context stayed empty. Only a fresh browser re-rolls it, at a measured ~55%
hit rate, so the engines spend up to five sessions on it. Two wrong
conclusions were drawn before that (scroll harder; reload), both killed by
measurement.
#corePriceDisplay_desktop_feature_divis present and empty while thenext container holds the number, and the list price lives in the same
container. Fixed with
:not(.a-text-price)and "try selectors until oneparses", not "until one exists".
review_countcame from a "customers also viewed" carouselitem (13,930 instead of 87,349). Now scoped to
#averageCustomerReviews.scale first (
5つ星のうち4.2).199 krparsed as no price at all: the symbol was in themarketplace-resolution table but not in the matching alternation, so a
whole locale's prices were silently invisible.
handlers), and its bundled x86_64 Chromium 117 will not start on an arm64
Mac at all. Hence
handleSIGINT=Falseand--chromium-path.Measured, and used as the canary's floors
From a residential European exit, 2026-09-08, with no key, no proxy and no
account:
/s?k=bluetooth+headphones&i=electronics, 3 pages → 90 rows, 76–77 afterdedupe,
status=complete, exit 0..com, 16 on.de, 60 on.co.jp.products — and 100% of grid rows.
runs ten minutes apart differed on 10 of 43 shared ASINs by 0.039–0.058%.
That is what
diff_runs.py --price-tolerance-pctexists for, and itdefaults to 0 so nothing is swallowed silently.
What is NOT verified
produced WAF challenges and 503s and never an image captcha, so it is
exercised only by an offline fixture written from Amazon's documented
markup. README, TROUBLESHOOTING.md and
smoke_test.pyeach say so.scraper_api_client.pyagainst amazon.com. No Scraper API subscriptionwas available; its docstring says so and points at
--cdp-url.Testing
python3 smoke_test.py— 239 checks, no network, no browser, no key. Fixturesare cut from real captures with
<script>/<style>stripped, verified toparse identically to the untrimmed originals before being committed; exactly
one fixture is synthetic and the file names it.
Note
smoke_test.pyneeds its# -*- coding: utf-8 -*-line: CPython 3.9'stokenizer, with no declared encoding, fails on a multi-byte character far
inside a very long source line (the amazon.co.jp fixture is one
21,000-character line whose first Japanese character is at column 3,654) while
compile()on the same bytes succeeds.CI:
tests.ymloffline only on 3.9 + 3.12, plusengine-smokewhich installsall three engines and fails on any reported skip.
canary.ymlruns one real3-page listing daily with the floors above.
Fixed in the second commit, found by reading CI's log rather than its badge
The first run went green while
engine-smokeverified almost nothing aboutone of the three engines.
puppeteer_scraperimported its driver inside_Session.open(), so themodule imported cleanly with no pyppeteer installed at all. The suite guards
that import and reports a skip when it fails, and CI fails on any reported
skip — the whole mechanism was vacuous for this engine, because the import
could not fail. Now at module level, like its two siblings and the rest of
the family.
stub.
pip install playwright pyppeteer selenium, unpinned into oneenvironment, resolved the three engines' mutually unsatisfiable pins by
reaching for pyppeteer 0.0.25 instead of the
>=1.0.2the requirementsfile asks for.
engine-smokeis now a matrix of one venv per engine,installing from that engine's own requirements file — what the README tells
users to do, and the only way to get the versions they get.
pip checkrunsper venv as well.
CI now reports playwright 1.62.0 / pyee 13, pyppeteer 2.0.0 / pyee 11 /
urllib3 1.26, selenium 4.48.0 — and a new check asserts all three engines keep
importing their driver at module level, so this cannot drift back quietly.
Not changed
index.html) and the downloaded proxy list stay out ofthe repo —
.gitignorecovers both.the user's account and could not be covered by a canary.
private repo is a 404 for every visitor.
🤖 Generated with Claude Code