diff --git a/.env.example b/.env.example index 28c9dff..1796af9 100644 --- a/.env.example +++ b/.env.example @@ -37,9 +37,10 @@ TWOCAPTCHA_KEY=your_2captcha_api_key_here # --- The Scraping Browser API (a managed browser over CDP) --------------- -# The endpoint SHAPE, not a working endpoint: a profile's credentials live -# about a day, so anything pasted here is stale by tomorrow and produces an -# auth error a long way from its cause. Get fresh ones from the dashboard. +# The endpoint SHAPE, not a working endpoint. A profile's credentials do +# expire (how long they last varies; do not plan around a fixed figure), and +# a stale endpoint produces an auth error (HTTP 401) a long way from its +# cause. Get fresh ones from the dashboard. # # country-xx picks the exit country. `country-jp` for a Japanese exit. # pid-... is a profile with persistent cookies. ONE live connection diff --git a/.github/ci_checks.py b/.github/ci_checks.py index c6adccb..de0fe26 100644 --- a/.github/ci_checks.py +++ b/.github/ci_checks.py @@ -300,7 +300,7 @@ def tracked_files(): # into pip's vendored code and flags a 32-hex string in `_elffile.py` as # key-shaped. Correct about the string, wrong about the file — and it is the # FIRST thing a new user sees from `python3 smoke_test.py`. A guard people -# have to argue with is one they learn to suppress (CLAUDE.md §22). +# have to argue with is one they learn to suppress. # # Deliberately NOT a narrowing of what gets scanned: an untracked file is # still read, because a key pasted into a scratch file beside the scripts is diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index ea5008a..93c38bc 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -20,10 +20,11 @@ name: canary # kind: a keyword search returned 45 products a page, and a three-page run # returned 135 rows with 100% price coverage and status `complete`. # -# The family's rule is that a canary which CANNOT pass must skip rather than -# fail, because a check that is always red teaches everyone to ignore checks. -# The other half of that rule applies here: a canary that CAN pass without a -# credential must never be gated on one. +# A canary that CAN pass without a credential must never be gated on one, +# and a run that did not actually test the site must never end green: a +# block (exit 3), a partial run (exit 6), content never obtained (exit 5) or +# zero rows all fail the job. A green badge here means a real scrape of +# rakuten.co.jp parsed real rows today, and nothing less. # # What a runner cannot test, and why there is no second job for it: # @@ -99,40 +100,28 @@ jobs: - name: Interpret the exit code id: verdict - # ACCESS CONDITIONS ARE NOT DEFECTS. Failing the job on any non-zero - # exit would make the canary red for things that are nobody's bug — - # so 3 (blocked) and 6 (partial) are WARNINGS, while a run that got - # in and found the wrong thing stays red. - # - # Note that on THIS site a blocked canary is genuinely surprising - # rather than routine, because the listing routes are not gated. If - # exit 3 starts appearing every day, that is news: either Rakuten has - # begun refusing GitHub's address ranges, or something in this repo - # has started sending a client whose claimed identity and TLS - # fingerprint disagree — which is the one thing Akamai was measured - # to refuse here. + # EVERY NON-ZERO EXIT FAILS THE JOB. This canary needs no secret and + # the listing routes were measured ungated from datacentre + # addresses, GitHub's runners included, so a run that did not get in + # is news rather than an access condition to wave through. The + # messages differ only in where to look first. run: | set -e code="${{ steps.run.outputs.exit_code }}" - tested=true case "$code" in 0) echo "OK — products parsed from a live run." ;; 3) - tested=false - echo "::warning::Blocked before parsing (exit 3) — reported as an ACCESS condition rather than a code defect, so this run is not failed. On this site that is NOT routine: the listing routes were measured ungated from a datacentre address. Check canary_run_page1_debug.html — if it is 43 bytes of 'Reference #', Akamai refused the client, and the first thing to suspect is a user agent or fingerprint that disagrees with the TLS handshake underneath it, not the address." - echo "### Canary did NOT test anything (exit 3, blocked)" >> "$GITHUB_STEP_SUMMARY" - echo "The site did not serve the request, so the parser was not exercised. Unusual here — see TROUBLESHOOTING.md, first section." >> "$GITHUB_STEP_SUMMARY" ;; + echo "::error::Blocked before parsing (exit 3). The listing routes were measured ungated from datacentre addresses, so this is news: either Rakuten has begun refusing GitHub's address ranges, or this repo has started sending a client whose claimed identity and TLS fingerprint disagree. Check canary_run_page1_debug.html — 43 bytes of 'Reference #' is Akamai refusing the client; a branded page under HTTP 403 is a route refusal." + exit 1 ;; 6) - tested=false - echo "::warning::Partial run (exit 6) — some pages were gathered and the run then stopped early. canary_run.meta.json names which pages failed. If the sidecar says stop_reason=end_of_listing this is not partial at all and the code is wrong; if it repeats every day, suspect the ?p= convention." - echo "### Canary tested only PART of the run (exit 6)" >> "$GITHUB_STEP_SUMMARY" - echo "See \`pages_failed\` in the sidecar artefact." >> "$GITHUB_STEP_SUMMARY" ;; + echo "::error::Partial run (exit 6): some pages were gathered and the run then stopped early, so the run did not test what it was asked to. canary_run.meta.json names which pages failed. If stop_reason is end_of_listing this is not partial at all and the code is wrong; a stop_reason of blocked_* or a throttle that never cleared means the site refused part of the run." + exit 1 ;; 4) echo "::error::Ran fine but parsed 0 rows (exit 4) from a genre listing that holds 45 products per page. Either window.__INITIAL_STATE__.state.data.ichibaSearch has moved, or the item-URL pattern the DOM fallback anchors on has. This is the regression this canary exists to catch — the HTML dump in the artefacts tells you which." exit 1 ;; 5) - echo "::error::Remote API error (exit 5) from a run that uses no remote API. That should be impossible here; read the log." + echo "::error::The content was never obtained (exit 5): a navigation timeout, a transport failure or a page that parsed to nothing although the site stated rows. This run uses no remote API and no credential, so diagnose the transport and the dump; the log names the stop_reason." exit 1 ;; 2) echo "::error::Bad usage (exit 2) — the workflow's own arguments are wrong, not the site." @@ -141,7 +130,7 @@ jobs: echo "::error::The scraper crashed (exit $code)." exit 1 ;; esac - echo "tested=$tested" >> "$GITHUB_OUTPUT" + echo "tested=true" >> "$GITHUB_OUTPUT" - name: Check what the run actually produced if: steps.verdict.outputs.tested == 'true' @@ -197,7 +186,7 @@ jobs: "this site is a mall; shop_code came from the payload on " "every measured row") - # THE ARITHMETIC BUG §18 NAMES: position restarts per page, so the + # THE ARITHMETIC BUG: position restarts per page, so the # pair is what has to be unique. pairs = [(r.get("page"), r.get("position")) for r in rows] dupes = [p for p, n in collections.Counter(pairs).items() if n > 1] @@ -218,7 +207,7 @@ jobs: and not r.get("review_count") for r in rows)) # NO NEGATIVE DISCOUNTS. Costs one line and catches the regression - # forever (§4). + # forever. want("a row has an original_price at or below its price", not any(r.get("original_price") is not None and r.get("price") is not None diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dde5f39..74737ab 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -83,52 +83,6 @@ jobs: sys.exit(1 if failed else 0) EOF - - name: Committed sample output is real - # The README links to these two files, so a push without them ships - # broken links. And a sample invented by hand drifts from the schema and - # teaches readers the wrong field names, so these are cut from a real - # run rather than written by hand. - run: | - python - <<'EOF' - import csv, json, sys - from pathlib import Path - - MARKERS = ("sample-product-", "example brand", "sample product", - "product description text", "lorem ipsum") - - fail = [] - for name in ("sample_output.json", "sample_output.csv"): - if not Path(name).is_file(): - fail.append(f"{name} is missing — regenerate it from a real run") - - if not fail: - rows = json.loads(Path("sample_output.json").read_text()) - if not rows: - fail.append("sample_output.json is empty") - blob = json.dumps(rows).lower() - hit = [m for m in MARKERS if m in blob] - if hit: - fail.append(f"sample_output.json looks fabricated: {hit}") - - # Column set must match what the writers actually produce. - from output_writer import Product - from dataclasses import asdict - expected = list(asdict(Product()).keys()) - for i, row in enumerate(rows): - if list(row.keys()) != expected: - fail.append(f"row {i} columns drifted from output_writer.Product") - break - - with open("sample_output.csv", newline="", encoding="utf-8") as f: - header = next(csv.reader(f)) - if header != expected: - fail.append("sample_output.csv header drifted from output_writer.Product") - - for line in fail: - print("FAILED:", line) - sys.exit(1 if fail else 0) - EOF - - name: Config reporter runs with no .env present run: python env_config.py @@ -154,10 +108,12 @@ jobs: # endpoint, a planted 32-hex key and a planted http proxy URL. run: python3 .github/ci_checks.py --secret-check - - name: The repo's own --help and sample checks agree with this workflow - # ci_checks.py implements all three checks. The two above are also - # done as workflow steps for the clearer failure output; running them - # here as well is what keeps the two from drifting apart again. + - name: The repo's own --help and sample checks + # One implementation, in ci_checks.py. The sample check used to be + # duplicated here as an inline heredoc importing the row class; it is + # now only run from the script, so the two cannot drift apart. The + # committed samples are cut from a real run, and this fails if they + # go missing, look fabricated, or drift from output_writer.Product. run: python3 .github/ci_checks.py --help-check --sample-check engine-smoke: diff --git a/.gitignore b/.gitignore index 2cd9ded..6559086 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,9 @@ run/ runs/ results/ scratch/ -*.page[0-9] -*.page[0-9][0-9] +# --dump-html X --pages N writes X.pageN, and --pages goes to 150, so the +# pattern covers any number of digits rather than stopping at 99. +*.page[0-9]* live_results.zip # --- debug artifacts the engines drop next to themselves ----------------- @@ -99,7 +100,7 @@ secrets* # recoverable only because that repo was private at the time. On 2026-09-17 # a sibling had an untracked `.env.bak` holding a 32-hex key in a PUBLIC # repository, one `git add -A` from publication. Craigslist was fixed then -# and the fix never reached anyone else, which is §16's rule unapplied: when +# and the fix never reached anyone else, which is the rule unapplied: when # you fix one, check the siblings. .env* !.env.example diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cc7ba..d6f4102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,32 @@ their output or their bill. ## [Unreleased] +> **Behaviour change: a 403 refusal is now a block, not a throttle.** No +> engine used to pass the HTTP status to the page classifier, and Rakuten +> renders its 403 refusal and its 503 rate-limit page with the same body, so +> every 403 was read as a throttle: waited out three times (about 51 s) at +> the same exit and then reported as a failed page. Playwright and pyppeteer +> now carry the navigation's status into the classifier, so a 403 is +> handled as a block (re-fetch or rotate, then exit 3) and a 503 is still +> waited out. Selenium has no status to give and keeps the recoverable +> reading. +> +> **Behaviour change: output files honour your umask.** `.json`, `.csv` and +> `.meta.json` were written mode 0600 since the atomic writer arrived in +> 0.2.1. A new file now gets `0666` minus the umask (0644 under the usual +> 022), and rewriting an existing file keeps that file's mode. +> +> **Behaviour change: more numeric flags are checked at the command line.** +> `--pages 0`, `--concurrency 0`, a negative `--delay`, `--retry-delay` or +> `--proxy-block-retries` are now usage errors (exit 2) naming the flag, in +> all three engines; `scraper_api_client.py` rejects a negative `--retries` +> or `--retry-delay` and a `--timeout` below 1. `--pages 0` used to fetch +> page 1 anyway and record `pages_requested: 0`. +> +> **Behaviour change: the canary is red whenever it did not test the site.** +> A blocked (exit 3) or partial (exit 6) run used to end the job green with +> a warning. Every non-zero exit now fails it. +> > **Behaviour change:** `--retries 0` is now a usage error (exit 2). It used > to be accepted and made the page-load loop unreachable, so nothing was > fetched and the engine reported the browser's own blank page as a result: @@ -25,9 +51,41 @@ their output or their bill. `range(1, retries + 1)`, so 0 never runs it and the engine judged `about:blank`. Three third-party audits of this family ran exactly that and reported a working scraper as broken. `--retries` now goes through - `page_flow.attempts` in every entry point; a check reads the source of each - one and calls the validator, so a new engine declaring the flag with a - plain int fails the suite. + `page_flow.attempts` in all three browser engines; a check reads the + source of each one and calls the validator, so a new engine declaring the + flag with a plain int fails the suite. (`scraper_api_client.py --retries` + counts EXTRA attempts, so 0 stays legal there.) +- **pyppeteer crashed with `AttributeError` when a page kept failing to load + and no proxy was configured.** Its load-failure branch called + `pool.advance()` without checking for a pool. It now rotates only for an + unusable exit and only when there is another exit to go to, as the + Playwright and Selenium engines already did. +- **A failing captcha solver could be asked twice for one page.** The first + of the two solve call sites counted only successful solves against the + per-page allowance. Both now count every attempted purchase. +- **The static reCAPTCHA detector missed a v2 widget** in a saved page that + carried only the `g-recaptcha` container or the `api2/anchor` frame, + without the literal `grecaptcha`, so a `--dump-html` capture of one was + reported as having no captcha. No captcha has been observed on Rakuten; + this only affects diagnosis. +- **Help text and comments described another site's refusal** (an HTTP/2 + stream reset, a headless-only "Access Denied" page). The `--solve-captcha` + help in all three engines and the exit-code comments now describe + Rakuten's own: a 43-byte Akamai deny under HTTP 200, a branded page under + HTTP 403, and the same page under HTTP 503 as a rate limit. +- **The canary's exit-5 message** said a remote API error was impossible; + exit 5 means the content was never obtained, which a transport failure can + cause on any run. +- `.gitignore` covered `--dump-html` page dumps only up to `.page99`; + `--pages` goes to 150. `requirements-selenium.txt` mentioned a + `--chromedriver` flag that does not exist. `.env.example` no longer gives + a fixed lifetime for Scraping Browser credentials. +- Comments and docstrings no longer cite the numbered sections of internal + notes the public cannot read; each rule is stated in this repository's own + words, and the suite fails if such a citation comes back. +- The CI sample-output check runs from one implementation + (`ci_checks.py --sample-check`) instead of also as an inline copy, and the + wording scan now covers `smoke_test.py` itself. ## [0.2.1] — 2026-09-30 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a3db7f..651f6fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,10 +105,10 @@ Then the rest of the presentation, in the order that matters: runs daily on a schedule, which is deliberate: the README's central claim is that you need no key, no proxy and no account to read this site's listings, and a scheduled, ungated, real three-page run is that claim - under test every morning. The family's rule that a canary which cannot - pass must SKIP has a second half — a canary that CAN pass without a - credential must never be gated on one, or the badge goes green every day - while testing nothing. + under test every morning. A canary that CAN pass without a credential + must never be gated on one, and any run that did not really test the + site (blocked, partial, nothing obtained, zero rows) fails the job, so a + green badge always means real rows were parsed that day. Note that `workflow_dispatch` needs the workflow to exist on the DEFAULT branch: from a feature branch `gh workflow run` answers diff --git a/README.md b/README.md index e80dcf5..ee1d379 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ Two more things follow from that table, and both are easy to get wrong: The canary in this repository is that claim under test: a real three-page scrape, daily, from a bare GitHub runner, with **no secrets**. If Rakuten ever puts the listing routes behind something, the badge above goes red the next -morning. +morning: a blocked, partial or empty run fails the job rather than passing +with a warning. ## Install diff --git a/captcha_solver.py b/captcha_solver.py index 535dfbf..b83cbbb 100644 --- a/captcha_solver.py +++ b/captcha_solver.py @@ -201,7 +201,37 @@ def detect_recaptcha_v3(html: str, page_url: str) -> Optional[CaptchaChallenge]: ) if "grecaptcha" not in html: - return None + # A rendered v2 widget can reach a saved page without the literal + # `grecaptcha` anywhere in it: only the hyphenated container class + # `g-recaptcha` with its `data-sitekey`, or the widget's own + # `api2/anchor` iframe carrying the key as `k=`. Bailing here + # misdiagnosed such a `--dump-html` capture as "no captcha". + # A challenge frame present settles the variant as v2: v3 renders + # no challenge frame at all. + anchor = re.search( + r"recaptcha/(?:api2|enterprise)/anchor\?[^\"'>]*?[?&]k=([\w-]{20,})", + html) + container = re.search( + r"<[^>]*class=[\"'][^\"']*\bg-recaptcha\b[^>]*>", html, re.IGNORECASE) + key = None + size = None + if container: + km = re.search(r"data-sitekey=[\"']([\w-]{20,})[\"']", + container.group(0)) + sm = re.search(r"data-size=[\"'](\w+)[\"']", container.group(0)) + key = km.group(1) if km else None + size = sm.group(1) if sm else None + if key is None and anchor: + key = anchor.group(1) + sm = re.search(r"[?&]size=(\w+)", + html[anchor.start():anchor.start() + 600]) + size = size or (sm.group(1) if sm else None) + if key is None: + return None + kind = ("recaptcha_v2_invisible" if size == "invisible" + else "recaptcha_v2") + return CaptchaChallenge(kind=kind, sitekey=key, page_url=page_url, + size=size) exec_match = re.search( r"grecaptcha\.execute\(\s*['\"]([\w-]{20,})['\"]\s*,\s*\{\s*action:\s*['\"]([\w_]+)['\"]", @@ -723,14 +753,15 @@ class CaptchaUnsolvable(RuntimeError): # a JPEG of distorted text and a GET form). Roughly 190 lines of it, and none # of it is ported here, because this site has no such page. # -# What this site does instead is refuse a HEADLESS browser. Measured -# 2026-09-10 from five different addresses, four of them residential: Akamai -# answers with HTTP 403 and a 394-byte "Access Denied" page carrying a -# reference id — no form, no image, no widget, nothing for a solver to -# answer. And the trigger is the CLIENT rather than the address: the very -# same addresses were served HTTP 200 and the full catalogue by a browser -# with a real window. So the response to a block here is `--headful` or -# `--cdp-endpoint`, not a solve and not a better proxy, and +# What this site does instead is refuse the CLIENT. Measured 2026-09-21 +# from one datacentre address: Akamai answers a client whose claimed +# User-Agent contradicts its TLS fingerprint with a 43-byte HTTP-200 body +# holding only a reference id, and the ranking route answers a headless +# browser with a branded HTTP-403 page — no form, no image, no widget, +# nothing for a solver to answer on either. The same address was served the +# full catalogue by plain curl and by a consistent browser. So the response +# to a block here is a consistent client (or `--headful` on the ranking +# route), not a solve and not a better proxy, and # product_parser.detect_page_state reports it as "blocked" rather than # "challenge" precisely so no solve is attempted and nothing is charged. # diff --git a/diff_runs.py b/diff_runs.py index 28faea5..2c8bd7b 100644 --- a/diff_runs.py +++ b/diff_runs.py @@ -160,8 +160,7 @@ def diff_products(old: List[dict], new: List[dict], # those sponsored entries never become rows at all: they carry a # click-tracking redirect instead of a product URL and `parse_products` # drops them. So there is no placement column for a lifecycle bucket to - # key on, and porting one would be dead code that looks load-bearing - # (§4). + # key on, and porting one would be dead code that looks load-bearing. changed, source_changed, within_tolerance = [], [], [] for sku in old_by_sku.keys() & new_by_sku.keys(): before, after = old_by_sku[sku], new_by_sku[sku] diff --git a/env_config.py b/env_config.py index 879fdf0..c0dccf0 100644 --- a/env_config.py +++ b/env_config.py @@ -67,7 +67,7 @@ # A literal-only check reported both of those as CONFIGURED, so `cp # .env.example .env` followed by a run connected to cb.2captcha.com with the # string `{login}-zone-…` as its username and got a 401 — a confusing failure -# a long way from its cause, which is precisely what §3's rule exists to +# a long way from its cause, which is precisely what the rule exists to # prevent. Any `{…}` left in a value means the line was never filled in. _BRACED_PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}") @@ -204,7 +204,7 @@ def _dests_given_on_argv(dests, argv=None): headful path and its Scraping Browser path are both first-class, and switching between them is routine rather than exceptional. - Scanning argv is the smallest fix that keeps §3's precedence intact -- + Scanning argv is the smallest fix that keeps the precedence intact -- explicit flag, then exported variable, then `.env`, then default -- with "explicit" now meaning what the caller typed rather than what happens to be truthy. diff --git a/fingerprint_client.py b/fingerprint_client.py index 677d2b2..93d0d8a 100644 --- a/fingerprint_client.py +++ b/fingerprint_client.py @@ -248,7 +248,7 @@ def playwright_context_kwargs(fp: dict) -> dict: # 2026-09-11: a fingerprint stating `deviceScaleFactor: 1.25` produced a # browser reporting `window.devicePixelRatio === 1`, so the identity # contradicted itself on an axis any fingerprinter reads for free. Same - # shape as the three defects §16 lists -- a key the API returns that + # shape as the three wrong-key defects once found in this client -- a key the API returns that # nothing applies -- and found the same way, by comparing the live # browser against the fingerprint rather than by reading the code. scale = (fp.get("screen") or {}).get("deviceScaleFactor") @@ -349,7 +349,7 @@ def main() -> int: # loader. It used to read `os.environ` alone, which meant a key put in # `.env` exactly as README and .env.example instruct worked for every # engine and failed here with "No API key" — a documented mechanism not - # applied on one path, which is the shape of half the defects §16 lists. + # applied on one path, which is the shape of half the defects once found in this client. # `.env` has to be LOADED before it can be read: `env_value` looks at # os.environ, which `load_env` is what fills from the file. Calling it # here rather than relying on an engine having called it is the whole diff --git a/make_fixtures.py b/make_fixtures.py index ab68dcf..bf9ca0f 100644 --- a/make_fixtures.py +++ b/make_fixtures.py @@ -162,7 +162,7 @@ def _scrub_obj(node): # a public URL. Not a credential and not secret — and it # trips every scanner that looks for a 32-hex string, # including this repo's own CI grep, which would greet a new - # user with a FAILED line on their first command (§23). + # user with a FAILED line on their first command. # # Scrubbed rather than allowlisted, because an allowlist # that forgives 32-hex inside a rakuten.co.jp URL is a hole @@ -245,7 +245,7 @@ def _unreviewed(hit): text, re.S): ld += block # THE SCRAPING BROWSER'S OWN EXTENSION TAGS, kept verbatim when the - # capture has them. This is the fixture §21 asks for: a guard is only as + # capture has them. This is the fixture the guard needs: a guard is only as # good as the fixture it runs against, and the one that matters is the # page fetched the way a real run fetches it. # diff --git a/output_writer.py b/output_writer.py index 1550523..8f60908 100644 --- a/output_writer.py +++ b/output_writer.py @@ -119,7 +119,7 @@ class Product: # ブランド. Populated on 240 of 405 measured rows (59%). # # `hit["brand"]` — the field that looks like the source — is null on - # 405 of 405 and would make this a dead column (§9). Deliberately NOT + # 405 of 405 and would make this a dead column. Deliberately NOT # derived by splitting the title: a Rakuten product name opens with # campaign text (`【9/24まで P10倍!】`) far more often than with a maker, # so a split would be a guess wearing a fact's clothes. @@ -137,7 +137,7 @@ class Product: # 405 rows), so the value comes from the page's own structured data: # `offers.priceCurrency` in the listing page's JSON-LD, or # `` on a detail page. Null rather than - # defaulted when a page states nothing (§4). + # defaulted when a page states nothing. currency: Optional[str] = None # The was-price, and ONLY where Rakuten's own # `doublePrice.referencePriceVerified` flag is true — Japan's @@ -335,6 +335,18 @@ def _atomic(path: str, newline: Optional[str] = None): yield handle handle.flush() os.fsync(handle.fileno()) + # NamedTemporaryFile creates the file 0600 and os.replace keeps that + # mode, so without this every output would be owner-only. A file that + # already exists keeps its own mode; a new one gets what a plain + # open(path, "w") would have given it, i.e. 0666 minus the umask. + # Never a flat 0644: under umask 002 that would narrow 0664. + try: + mode = os.stat(path).st_mode & 0o777 + except OSError: + umask = os.umask(0) + os.umask(umask) + mode = 0o666 & ~umask + os.chmod(handle.name, mode) os.replace(handle.name, path) except BaseException: try: @@ -436,27 +448,20 @@ def write_csv(rows: Sequence[Any], path: str, row_cls: Type = Product) -> int: # search genuinely matched nothing" from "something stood between us and the # content". See product_parser.detect_bot_challenge. # -# On this site this code specifically does NOT cover the three ways to get a -# real page with no products on it: a `/p/` discovery hub, which -# answers 200 with banners and carousels and no grid; a search whose query -# matches nothing ("Oops, produk nggak ditemukan"); and one page past the -# end of a category listing. All three are EXIT_NO_PRODUCTS — the request -# was served exactly as asked and simply has no products on it. Reporting -# any of them as blocked would send a user hunting for a proxy problem that -# does not exist. +# On this site this code specifically does NOT cover a real page with no +# products on it: a search whose query matches nothing, or a genre with no +# items. Those are EXIT_NO_PRODUCTS — the request was served exactly as +# asked and simply has no products on it. Reporting either as blocked would +# send a user hunting for a proxy problem that does not exist. # -# What EXIT_BLOCKED means here is unusually literal: this site refuses a -# address it has scored NOTHING at all. No status code, no interstitial, no -# vendor marker — the HTTP/2 stream is reset and the run sees a connection -# error rather than a page. +# What EXIT_BLOCKED means on Rakuten is one of two refusals, both carrying +# Akamai's reference id: a tiny HTTP-200 deny (about 43 bytes, given to a +# client whose claimed User-Agent contradicts its TLS fingerprint), or a +# branded refusal page under HTTP 403. The same branded body under HTTP 503 +# is a THROTTLE, not a block: it is waited out at the same exit, and only +# counts here if it never clears. EXIT_BLOCKED = 3 -# Exit code for a run that gathered SOME rows and then stopped early — a -# page-load timeout, a 503 throttle, or a challenge on page 3 of 10. The -# output file is still written (throwing away three good pages would be -# worse), but it is not a complete picture, and a consumer that cannot tell -# the difference will read the pages that were never fetched as products that -# disappeared from the catalogue. See write_run_meta. # A REMOTE service failed — the Scraping Browser refusing the connection # (`profile_locked` is the common one: a profile allows a single live # connection), or the Scraper API answering an error. Distinct from 1 (a @@ -467,6 +472,12 @@ def write_csv(rows: Sequence[Any], path: str, row_cls: Type = Product) -> int: # drifts. EXIT_API_ERROR = 5 +# Exit code for a run that gathered SOME rows and then stopped early — a +# page-load timeout, a 503 throttle that never cleared, or a refusal on page +# 3 of 10. The output file is still written (throwing away three good pages +# would be worse), but it is not a complete picture, and a consumer that +# cannot tell the difference will read the pages that were never fetched as +# products that disappeared from the catalogue. See write_run_meta. EXIT_PARTIAL = 6 diff --git a/page_flow.py b/page_flow.py index c66605b..3167764 100644 --- a/page_flow.py +++ b/page_flow.py @@ -2,7 +2,7 @@ Rakuten answers a request six ways and five of them want a different response, which is why this module exists rather than the same triage being -written three times inside three engines and drifting apart (§1): +written three times inside three engines and drifting apart: content the listing payload or the item island is in the response empty served exactly as asked and `numFound: 0` — the site's own @@ -35,7 +35,7 @@ content() -> Optional[str] current HTML, None if unavailable sleep(ms) -> None wait -No JavaScript crosses that boundary in either direction (§1): Selenium's +No JavaScript crosses that boundary in either direction: Selenium's `execute_script` takes a function BODY with an explicit `return` where Playwright and pyppeteer take `() => expr`, so the shared module names the OPERATION and the engine spells it in its own driver's dialect. @@ -80,7 +80,7 @@ # Readiness # --------------------------------------------------------------------------- # A price node WITH SOMETHING IN IT rather than a card count, for the reason -# §8 gives: a selector matching the wrong element is worse than one matching +# the rule is: a selector matching the wrong element is worse than one matching # nothing, and a tile's shell can exist while its amount is still blank. # # What a timed-out wait costs here is small and worth stating plainly, @@ -93,7 +93,7 @@ READY_SELECTOR_LISTING = '[class*="price--"]' READY_SELECTOR_PRODUCT = '[class*="price"], #priceCalculationConfig' -# Above 1, per §5: waiting for a single match resolves on an unrelated node +# Above 1: waiting for a single match resolves on an unrelated node # long before a grid paints. Below the smallest real page — a Rakuten # listing page is 45 products on 11 of 11 captures, and the LAST page of a # capped query is 45 too (page 150 came back `start: 6705`, exactly @@ -118,7 +118,7 @@ def min_matches(mode: str = "listing", expected: Optional[int] = None) -> int: `expected` is the page's own hit count where the caller knows it, because this site states it: a query with 5 total hits renders 5 tiles, and waiting for 8 of them would time out on a page that is fully - painted. Never above the floor and never below 2 (§5). + painted. Never above the floor and never below 2. """ floor = _MIN.get(mode, MIN_CARD_MATCHES) if expected is None: @@ -158,7 +158,7 @@ def wait_for_count(count: Callable[[str], int], sleep: Callable[[int], None], `EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive` on a site whose CSP has no `unsafe-eval`, and took the run down with exit 1 on that site's most - obvious URL (§18). Counting elements goes through the protocol, works + obvious URL. Counting elements goes through the protocol, works under any CSP, and spells the same in all three drivers. """ waited = 0 @@ -187,7 +187,7 @@ def classify(html: Optional[str], status: Optional[int] = None, Getting that wrong is not a style question: a sibling repo shipped two of three engines calling this as `classify(html, url=...)`, both crashed on their first fetch, and nothing short of a live run or a - signature-binding check saw it (§17). + signature-binding check saw it. On this site the status is more load-bearing than on any sibling: it is the only thing separating a 503 throttle from a 403 block, because @@ -199,7 +199,7 @@ def classify(html: Optional[str], status: Optional[int] = None, # The retry/solve/blocked decision as DATA rather than as three copies of an -# if-chain in three engines (§1). +# if-chain in three engines. # # parse is there anything on this page worth writing down? # retry would fetching it again plausibly help? @@ -215,7 +215,7 @@ def classify(html: Optional[str], status: Optional[int] = None, # nothing, and nothing downstream could tell them from results. "empty": {"parse": False, "retry": False, "solve": False, "blocked": False}, # Served, and still painting. Wants the readiness wait, not another - # fetch: refetching a shell just buys another shell (§18). + # fetch: refetching a shell just buys another shell. "shell": {"parse": True, "retry": False, "solve": False, "blocked": False}, # Retryable, NOT blocked, and not paid for. Rakuten's 503 is a rate # limit: it arrived while a probe was fetching with no delay between @@ -272,7 +272,7 @@ def is_unpainted(state: str, html: Optional[str]) -> bool: # answer and the advice below says what is. # # The engines read these constants rather than computing their own budget — -# a policy constant nothing consults is the same defect as dead code (§17), +# a policy constant nothing consults is the same defect as dead code, # and the suite asserts each one has a consumer. RETRY_ON_BLOCKED = True BLOCK_RETRIES_WITHOUT_POOL = 1 @@ -292,7 +292,7 @@ def is_unpainted(state: str, html: Optional[str]) -> bool: # through `solve_budget`, because a sibling repo discovered that this # constant had read like a limit in every repo in the family while every # engine called the solver twice per attempt and counted once — one page -# bought three Turnstile solves (§23). +# bought three Turnstile solves. SOLVES_PER_PAGE = 1 @@ -357,7 +357,7 @@ def block_advice(html: Optional[str], headless: bool, # --------------------------------------------------------------------------- # Pagination # --------------------------------------------------------------------------- -# Three layers, weakest last (§7). On this site layer 1 is MISSING, which is +# Three layers, weakest last. On this site layer 1 is MISSING, which is # worth stating because the family's template leads with it: # # 1. A standards-based `` — ABSENT. Rakuten publishes @@ -399,8 +399,8 @@ def requested_page_number(url: str, loop_index: int = 1) -> int: complete run with nothing in it. Caught by running `--url '...?p=2' --pages 1`, which is an obvious thing - for a user to type and which no test and no ordinary run had tried - (§15). Inside the loop the two numbers agree, because the URL is built + for a user to type and which no test and no ordinary run had tried. + Inside the loop the two numbers agree, because the URL is built by `page_url` from that same counter — which is exactly why the bug was invisible. """ @@ -426,7 +426,7 @@ def served_the_page_asked_for(html: Optional[str], payload states `pagination.start`: asked for page 151 and handed `start: 0` is the server saying outright that it served page 1. - That is §23's trap on a second site and in a second spelling — there the + That is the trap on a second site and in a second spelling — there the server's own page number hid in an Apollo cache key, here it is a plain integer — and the general rule is the transferable part: before trusting `?p=N`, fetch one page PAST the site's own last and read what comes @@ -475,7 +475,7 @@ def _as_hrefs(next_href: Union[str, Iterable[str], None]) -> List[str]: string is what took a sibling repo's FIRST live run down — `TypeError: Cannot mix str and non-str arguments`, from `urljoin` being handed a list — and nothing short of running it saw that, because the - offline checks called it the way its author was thinking (§17). + offline checks called it the way its author was thinking. """ if next_href is None: return [] @@ -567,7 +567,7 @@ def pages_at_cap(html: Optional[str]) -> bool: numbers itself: one measured query reported `numFound: 3,053,682` against a `subset` of 6,750. A 150-page run of that query is complete — it fetched everything Rakuten will serve — and it is a 0.2% sample. - Only saying so lets a consumer tell the two apart (§21), and it gives + Only saying so lets a consumer tell the two apart, and it gives `diff_runs.py` the third meaning of `removed`: not delisted, not un-fetched, but outside this run's slice of a capped result set. """ @@ -599,7 +599,7 @@ def concurrency_limit(url: str) -> Optional[int]: def concurrency_refusal(url: str) -> Optional[str]: """Why concurrency above 1 is refused for this URL, or None. - Refused WITH the reason (§18): a single product page and the mall's + Refused WITH the reason: a single product page and the mall's front page have no page 2 at all, so there is nothing for a second worker to fetch, and silently running one worker would look like the flag did something. @@ -669,3 +669,42 @@ def attempts(value) -> int: f"With 0 the load loop never runs, nothing is fetched, and the " f"engine reports the browser's blank page as a result.") return number + + +def at_least_one(value) -> int: + """argparse `type=` for counts that must be 1 or more (--pages, + --concurrency). `--pages 0` used to fetch page 1 anyway and record + `pages_requested: 0` in the sidecar, and `--concurrency 0` meant nothing + at all; both are usage errors now, named by argparse.""" + try: + number = int(value) + except (TypeError, ValueError): + raise _argparse.ArgumentTypeError(f"must be an integer, not {value!r}") + if number < 1: + raise _argparse.ArgumentTypeError(f"must be at least 1, not {number}") + return number + + +def non_negative_int(value) -> int: + """argparse `type=` for a count that may be 0 but not negative + (--proxy-block-retries).""" + try: + number = int(value) + except (TypeError, ValueError): + raise _argparse.ArgumentTypeError(f"must be an integer, not {value!r}") + if number < 0: + raise _argparse.ArgumentTypeError(f"must be 0 or more, not {number}") + return number + + +def non_negative_seconds(value) -> float: + """argparse `type=` for a delay in seconds (--delay, --retry-delay). A + negative sleep raises ValueError deep inside a run instead of at the + command line.""" + try: + number = float(value) + except (TypeError, ValueError): + raise _argparse.ArgumentTypeError(f"must be a number, not {value!r}") + if not number >= 0: # also refuses nan + raise _argparse.ArgumentTypeError(f"must be 0 or more, not {value}") + return number diff --git a/playwright_scraper.py b/playwright_scraper.py index ebb9780..5794411 100644 --- a/playwright_scraper.py +++ b/playwright_scraper.py @@ -190,7 +190,7 @@ class PageOutcome: # Rakuten's own arithmetic about the query: total_results, # pages_available, reachable_max, capped_by_site, pages_beyond_cap. In # the sidecar because on this site "complete" and "exhaustive" are wildly - # different words and a status alone would be lying by omission (§21). + # different words and a status alone would be lying by omission. cap: Optional[dict] = None # Whether the SERVER served the page we asked for, read off # `pagination.start` rather than trusted from the request. False means @@ -297,7 +297,7 @@ def _advertised_next_hrefs(page, page_num: int) -> List[str]: Rakuten DOES advertise its own page links — nine of them on page 1 of a 150-page query, absolute, spelling `?p=2` through `?p=9` — and it publishes **no `` at all**, on 3 of 3 captures. Only - `rel="canonical"`. So §7's most-durable layer simply does not exist on + `rel="canonical"`. So the most-durable layer simply does not exist on this site and the selector reaches for a build artefact because there is nothing better to reach for; you cannot order signals by durability when the site declines to publish the durable one. @@ -413,7 +413,7 @@ def _same_url(a: str, b: str) -> bool: reproduced inside one engine. On this site the comparison has to strip a long tracking tail: a listing - anchor arrives with `?extParam=…keyword=kopi&search_id=…&src=search` and + anchor arrives with a tracking query string (`?l2-id=…`, `scid=…` and similar) and a detail page's own canonical arrives with a UTM triple, so two views of one page never match unless both sides are cleaned. """ @@ -573,12 +573,10 @@ def _connect_remote(pw, args): # challenges inside the browser: https://2captcha.com/scraper/browser-api/api # Tried first when --cdp-endpoint is set; this script's own detect+solve # logic still runs as a fallback if the endpoint does not support it. - # Note it does NOT cover this site's refusal, which is not a challenge: - # Akamai answers a headless browser with a 394-byte "Access Denied" that - # has nothing to solve on it, and a real window rather than a better - # address is the answer. No challenge has ever been observed here; this is - # wired - # up because one can appear between deploys. + # Note it does NOT cover this site's refusals, which are not challenges: + # Akamai's 43-byte deny and the branded 403 page have nothing to solve on + # them. No challenge has ever been observed here; this is wired up + # because one can appear between deploys. try: cdp_session = context.new_cdp_session(page) cdp_session.send("Captcha.setAutoSolve", {"autoSolve": True, "options": [{"type": "*"}]}) @@ -659,9 +657,13 @@ def _content_when_settled(page, attempts: int = 4, pause_ms: int = 700): return None -def handle_captcha_if_present(page, args) -> bool: +def handle_captcha_if_present(page, args, purchases=None) -> bool: """Detect and solve a challenge. True if something was solved. + `purchases`, when given, gets one entry appended immediately BEFORE the + solver is asked for a token, so a caller can count attempted purchases + (successful or not) against the page's solve allowance. + Runs after EVERY navigation, for ANY page — not scoped to one URL. The static-HTML and runtime reCAPTCHA detectors are run and reconciled against each other rather than short-circuited, because they can disagree @@ -671,7 +673,7 @@ def handle_captcha_if_present(page, args) -> bool: honest sentences about it. **This repo implements reCAPTCHA v2/v3;** if Rakuten ever renders an enterprise reCAPTCHA or a Turnstile, 2Captcha solves both and this client would need the matching task type added — - that is a TODO here, not a limit of the product (§19). + that is a TODO here, not a limit of the product. What is measured is narrower: no captcha of any kind is configured anywhere on rakuten.co.jp. Zero reCAPTCHA, hCaptcha, Turnstile, @@ -724,6 +726,8 @@ def handle_captcha_if_present(page, args) -> bool: "solved — continuing with whatever the page already " "holds.") return False + if purchases is not None: + purchases.append(challenge.kind) try: token = solve_recaptcha(challenge, args.twocaptcha_key, api_version=args.captcha_api, @@ -758,7 +762,7 @@ def _parse_for_mode(html: str, url: str, args, page_num: int = 1) -> List: restarts at 1 on every page: without the page number beside it, a row from page 2 claims the same position as one from page 1 and the two are indistinguishable in the output. A sibling repo's first live run wrote - 120 rows all labelled page 1 for exactly that reason (§18). + 120 rows all labelled page 1 for exactly that reason. """ if args.mode == "product": return parse_product_page(html, url, page=page_num, @@ -828,9 +832,17 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # Retry a navigation timeout rather than ending the run on it. One # network flap on page 12 of 50 should not break the loop. load_failed, exit_failed = False, None + # The HTTP status of the navigation, carried into the classifier. + # On this site it is the only thing that separates a 403 refusal + # from a 503 throttle: Rakuten renders both with the same branded + # body, and without the status the classifier has to read that body + # as the recoverable one (a throttle). + status = None for attempt in range(1, args.retries + 1): try: - session.page.goto(url, wait_until="domcontentloaded", timeout=60000) + resp = session.page.goto(url, wait_until="domcontentloaded", + timeout=60000) + status = resp.status if resp is not None else None load_failed = False break except (PWTimeout, PWError) as e: @@ -868,17 +880,26 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # counted only at the second call site, so `SOLVES_PER_PAGE = 1` # read like an enforced cap in every repo of this family while one # page could buy two solves — measured at three on a site where a - # challenge rendered on every fetch (§23). Invisible wherever a + # challenge rendered on every fetch. Invisible wherever a # challenge is rare, which is everywhere until it is not. if page_flow.solve_budget(solves_bought): - if handle_captcha_if_present(session.page, args): - solves_bought += 1 + # Counted when a purchase is ATTEMPTED, not when it succeeds: a + # solver that raised was still asked to work, and counting only + # successes let one page reach the solver twice. A page with no + # captcha on it spends nothing here. + purchases = [] + solved = handle_captcha_if_present(session.page, args, + purchases=purchases) + solves_bought += len(purchases) + if solved: # A solve navigated the page. Give the destination a moment - # before judging what came back. + # before judging what came back, and forget the status of the + # navigation it replaced. + status = None session.page.wait_for_timeout(1000) html = _content_when_settled(session.page) or "" - state = _classify(session.page, html) + state = _classify(session.page, html, status) # "Not painted yet" is not a fault, and telling it apart from one is # what the first live search run of this engine got wrong. A CATEGORY @@ -913,13 +934,13 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome logger.info("The grid still had not painted after %.0fs " "(%d match(es)).", wait_timeout / 1000, found) html = _content_when_settled(session.page) or html - state = _classify(session.page, html) + state = _classify(session.page, html, status) # No interstitial-settling step here, and its absence is measured # rather than an omission. This site has no interstitial to settle: a - # refused request gets a 394-byte "Access Denied" with no markup at - # all, so there is nothing to wait out and nothing to reclassify. See - # page_flow's "There is no block page". + # refused request gets a 43-byte deny or a branded 403 page, so there + # is nothing to wait out and nothing to reclassify. A 503 throttle is + # handled below by waiting at the same exit. # # The paid path is reached only for state "challenge", which NO # capture of this site has ever produced. It is wired up because a @@ -930,9 +951,12 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome and page_flow.solve_budget(solves_bought)): solves_bought += 1 if handle_captcha_if_present(session.page, args): + # The solve reloaded the page; the status of the navigation + # before it no longer describes what is on screen. + status = None session.page.wait_for_timeout(1000) html = _content_when_settled(session.page) or html - state = _classify(session.page, html) + state = _classify(session.page, html, status) # The VERIFIED outcome, and the only one worth reporting: a # "ready" task result is not evidence the token works. This # line is what says whether the money bought anything. @@ -1020,8 +1044,10 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # solve, and that a proxy is probably not the answer either. Akamai # refuses this site with a 43-byte body whose whole content is # `Reference #18.…` — no widget, nothing for any solver at any - # price — and it arrives under **HTTP 200**, so the status code is - # not the signal. + # price — and it arrives under **HTTP 200**. A route Rakuten refuses + # outright answers instead with its branded page under HTTP 403, and + # that status is what tells it apart from the same page under 503, + # which is a throttle and never reaches this branch. # # The dump is written even when it is empty, because "43 bytes" is # itself the diagnosis here and a reader who finds no file at all @@ -1115,7 +1141,7 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # so this runs only for a state the policy has already given up on and # only REFINES the reason. # - # The marker set itself is chosen the same way (§18): every candidate + # The marker set itself is chosen the same way: every candidate # was counted on pages Rakuten plainly served before any of it was # trusted. `cf-turnstile` is excluded because the Scraping Browser's own # auto-solve extension injects it into every page it loads, and a bare @@ -1152,7 +1178,7 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # with 45 real products, and `/category/{id}/?p=2` answers 200 with page # 1 without even redirecting. A run that trusted its own request would # re-collect page 1 for as long as it was asked to and report a - # complete, entirely duplicate file (§23). + # complete, entirely duplicate file. if args.mode == "listing": # Compared against the page the fetched URL actually ASKS for, not # against the loop's counter — a run started on a URL that already @@ -1202,7 +1228,7 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # rather than guessed. Both numbers are recorded, because on this site # they are wildly different and only having both makes the run honest: # one measured query reported `numFound` 3,053,682 against a `subset` of - # 6,750, so a full run of it is complete AND a 0.2% sample (§21). + # 6,750, so a full run of it is complete AND a 0.2% sample. # # `numFound` is also a LIVING number and is recorded as the site's # answer at this moment rather than asserted against — three fetches of @@ -1265,8 +1291,7 @@ def _fetch_one_page(session, args, pool, page_num: int, url: str) -> PageOutcome # payload carries the price and the rendered tile prints that same # number formatted, so `price_source` is "state" on every listing row # and a confirmation threshold would describe nothing. Porting a - # sibling repo's overlay would be dead code that looks load-bearing - # (§4). + # sibling repo's overlay would be dead code that looks load-bearing. # # What IS worth reporting is the share of rows Rakuten publishes a # was-price for, because on the listing route the honest answer is @@ -1656,7 +1681,7 @@ def scrape(args) -> int: # the query, and on this site that is not optional decoration: a # `status: complete` run of a query the site caps at 6,750 of 3,053,682 # matches is complete and is a 0.2% sample, and a sidecar that said only - # "complete" would be lying by omission (§21). It also gives + # "complete" would be lying by omission. It also gives # `diff_runs.py` the third meaning of `removed` — not delisted, not # un-fetched, but outside this run's slice of a capped result set. extra = None @@ -1730,7 +1755,7 @@ def parse_args(): "the request — the browsed URL is in the run's " "sidecar. Pass this to override it with a label of " "your own.") - p.add_argument("--pages", type=int, default=1, + p.add_argument("--pages", type=page_flow.at_least_one, default=1, help="Number of listing pages to crawl (45 products " "each). Applies to --mode listing; a product page is " "one page. Rakuten caps EVERY query at 6,750 results " @@ -1738,8 +1763,8 @@ def parse_args(): "for page 151 gets page 1 again with HTTP 200, so a " "run stops at the cap and the sidecar records both " "numbers.") - p.add_argument("--delay", type=float, default=2.0, help="Delay between pages, seconds") - p.add_argument("--concurrency", type=int, default=1, metavar="N", + p.add_argument("--delay", type=page_flow.non_negative_seconds, default=2.0, help="Delay between pages, seconds") + p.add_argument("--concurrency", type=page_flow.at_least_one, default=1, metavar="N", help="Fetch pages through N parallel workers (default 1 — " "unchanged sequential behaviour). Each worker runs its " "own browser and holds its own proxy exit, so N>1 " @@ -1751,7 +1776,7 @@ def parse_args(): "that comes back EMPTY is not retried — see " "page_flow.STATE_POLICY — because an empty hub " "category is a correct answer, not a fault.") - p.add_argument("--retry-delay", type=float, default=2.0, + p.add_argument("--retry-delay", type=page_flow.non_negative_seconds, default=2.0, help="Seconds before the first page-load retry, doubling " "thereafter (default 2.0)") p.add_argument("--format", choices=["json", "csv", "both"], default="both") @@ -1781,7 +1806,7 @@ def parse_args(): p.add_argument("--proxy-shuffle", action="store_true", help="Shuffle the pool at startup, so concurrent runs do not " "all begin on the first exit in the file.") - p.add_argument("--proxy-block-retries", type=int, + p.add_argument("--proxy-block-retries", type=page_flow.non_negative_int, default=page_flow.BLOCK_RETRIES_WITH_POOL, help="When a page comes back refused, retry it from this " "many OTHER exits before giving up (default %d, from " @@ -1834,10 +1859,12 @@ def parse_args(): help="when-blocked (default): only pay to solve a " "reCAPTCHA if the content is not already readable. " "always: solve whenever one is detected. Neither " - "setting touches this site's refusal, which is not a " - "page at all — the HTTP/2 stream is reset and nothing " - "arrives — so no solve helps there and none is " - "attempted or billed. In fact NO challenge has ever " + "setting touches Rakuten's refusals (a 43-byte " + "Akamai deny under HTTP 200, a branded page under " + "HTTP 403, or the same page under HTTP 503 as a rate " + "limit): none carries a widget, so no solve helps " + "and none is attempted or billed. In fact NO " + "challenge has ever " "been observed on this site; the path is wired up " "because a bot manager can be switched on between " "deploys.") @@ -1857,7 +1884,7 @@ def parse_args(): "well as failure. Useful when the row count is right but " "a column comes back empty — see TROUBLESHOOTING.md.") # HEADLESS is the default, and here that is measured in BOTH directions - # rather than inherited — §19's rule, because a sibling site answered 0 + # rather than inherited — the rule, because a sibling site answered 0 # of 4 headless against 4 of 4 headful and the family default had been # headless simply because headless is what a scraper does. # @@ -1907,7 +1934,7 @@ def parse_args(): # Rakuten Group property like ranking.rakuten.co.jp or Rakuten Books # gets a different sentence from a typo, because "is not a Rakuten # site" would be FALSE for those and would send the reader hunting - # for a misspelling that is not there (§5). + # for a misspelling that is not there. p.error(unsupported_reason(args.url) or f"{args.url!r} is not a Rakuten Ichiba URL this scraper " f"reads.") diff --git a/product_parser.py b/product_parser.py index 298f167..1461027 100644 --- a/product_parser.py +++ b/product_parser.py @@ -2,7 +2,7 @@ This module IS the site. Everything that knows what Rakuten publishes, how it spells a product id, how it paginates and how it refuses lives here; the -engines carry a dozen named constants and nothing else (§1). +engines carry a dozen named constants and nothing else. Where the data actually is -------------------------- @@ -29,7 +29,7 @@ below). A DETAIL page publishes a different `@type` again — its only JSON-LD is a -`BreadcrumbList` (§20, proven here on a second site). The listing parser +`BreadcrumbList` (proven here on a second site). The listing parser finds nothing at all on a detail page, which is asserted in the suite rather than left to be discovered. @@ -82,7 +82,7 @@ HOSTS = (SEARCH_HOST, WWW_HOST, ITEM_HOST, "rakuten.co.jp") # Hosts that belong to Rakuten Group but are NOT Rakuten Ichiba. Refused with -# the reason rather than with a generic "unsupported host" — §5: telling a +# the reason rather than with a generic "unsupported host" — telling a # reader their URL "is not a Rakuten site" when it plainly is sends them # hunting for a typo. OTHER_RAKUTEN_HOSTS = { @@ -124,19 +124,19 @@ # # The listing payload states NO currency: zero occurrences of `priceCurrency`, # `currency` or the string `JPY` across 405 rows. So it is not a fact read -# off the row (§4 rung 1), and defaulting it would be exactly the "guess -# presented as a fact" §8 forbids. +# off the row, and defaulting it would be exactly the "guess +# presented as a fact" this project never writes. # # What the site does state, on the same page: `"priceCurrency": "JPY"` in the # listing page's own JSON-LD carousel block, and `` on every detail page — a written -# ISO code, §4 rung 2. `currency_from_page` reads those. This constant is the +# ISO code, the second-best source. `currency_from_page` reads those. This constant is the # allowlist of what a read is allowed to return, not a default, and a row # whose page states nothing gets `None`. CURRENCY = "JPY" CURRENCY_CODES = frozenset(("JPY",)) -# The positive structural signal (§8): a page Rakuten actually served is +# The positive structural signal: a page Rakuten actually served is # built out of Rakuten's own asset hosts, and an interstitial is not. # Measured across six served pages and three refusals: # @@ -153,7 +153,7 @@ _SEARCH_PATH_RE = re.compile(r"^/search/mall/(?P[^/]+)/?") SELECTORS = { - # Anchored on the URL pattern, never a class (§4). Rakuten's own class + # Anchored on the URL pattern, never a class. Rakuten's own class # names on the search grid are build hashes. "item_link": 'a[href*="item.rakuten.co.jp/"]', "tile_price": '[class*="price--"]', @@ -172,7 +172,7 @@ # 150 is exactly the last: it comes back `start: 6705`, which is 6705 + 45 = # 6750. Page 151 does not error — see `served_offset`. # -# This is §21's "complete and exhaustive are different words" with Rakuten's +# This is the "complete and exhaustive are different words" with Rakuten's # own arithmetic: one measured query reported `numFound: 3,053,682` against a # `subset` of 6,750, so a full 150-page run is a complete run and a 0.2% # sample. The sidecar records both numbers rather than only the status. @@ -203,7 +203,7 @@ # site's rendered markup: `¥1,234` (fullwidth yen), `¥1,234` (ASCII yen) and # `1,234円`. There are no decimal subunits in JPY, so a comma is always a # thousands separator here and the "whichever comes last is the decimal -# point" rule §4 describes does not apply — a bare `1,234` is 1234, never +# point" rule does not apply — a bare `1,234` is 1234, never # 1.234. Used only by the DOM fallback; the payload paths read integers. _AMOUNT = (r"\d{1,3}(?:[,%s]\d{3})+|\d+" % _GROUP_SPACES) _PRICE_RE = re.compile( @@ -238,7 +238,7 @@ # needs neither arrives under HTTP 200. Neither signal is sufficient alone # and each covers the other's blind spot. # -# Bounded to a prefix (§20): every refusal body is under 2 KB entire, so +# Bounded to a prefix: every refusal body is under 2 KB entire, so # matching in the first 8 KB covers all of them and cannot let a product # title deep in a 981 KB grid read as a marker. _MARKER_PREFIX_BYTES = 8192 @@ -246,7 +246,7 @@ # Whitespace-tolerant on purpose. The bytes Akamai actually sends are # `Reference #18.536a645f.1789976769.70a9bb4f` — with TWO ASCII spaces. A # literal `"Reference #"` marker matches none of the three refusals, which is -# §20's "a marker must survive both spellings of the same page" arriving in a +# the "a marker must survive both spellings of the same page" arriving in a # third costume. _AKAMAI_REF_RE = re.compile( r"Reference\s*#\s*\d+\.[0-9a-f]+\.\d+\.[0-9a-f]+", re.I) @@ -258,10 +258,10 @@ # Deliberately not carried, each for a measured reason: # -# cf-turnstile §8/§19 — the Scraping Browser's auto-solve extension +# cf-turnstile — the Scraping Browser's auto-solve extension # injects it into every page it loads, so it fires on good # pages. Measured useless on two sibling sites. -# akamai / akam §18 — a marker that matches every page is worse than no +# akamai / akam — a marker that matches every page is worse than no # marker. Rakuten is fronted by Akamai, and although its # served markup happens to reference no Akamai host (0 of 6 # captures), a CDN name is a fact about the site's @@ -269,7 +269,7 @@ # Reference # the literal spelling, for the two-space reason above. # No captcha is configured anywhere on this site, and that is a measurement -# rather than an assumption — §18's "did we meet one" is the wrong question, +# rather than an assumption — the "did we meet one" is the wrong question, # so the right one was asked: *is one configured, and would we recognise it # if it appeared?* # @@ -282,7 +282,7 @@ # So this set exists to RECOGNISE an escalation, not because one was seen — # and none of these strings appears on a served page, so none of them can # fire on a good one. What the narrow, honest word "unsolvable" applies to -# (§19) is that 43-byte deny page: it carries no widget, so there is nothing +# is that 43-byte deny page: it carries no widget, so there is nothing # on it for any solver at any price. That is a property of that page and says # nothing about what 2Captcha can solve. BOT_CHALLENGE_MARKERS = ( @@ -384,7 +384,7 @@ def site_host(url: str) -> Optional[str]: def unsupported_reason(url: str) -> Optional[str]: """Why this URL is not an Ichiba URL this scraper reads, or None. - Refused WITH the reason (§5). Every entry in `OTHER_RAKUTEN_HOSTS` is a + Refused WITH the reason. Every entry in `OTHER_RAKUTEN_HOSTS` is a real Rakuten property, so "not a Rakuten site" would be false and would send the reader looking for a typo they did not make. """ @@ -723,7 +723,7 @@ def served_offset(html: Any) -> Optional[int]: Both answer 200 with a full, well-formed page of real products. Asking for page 151 and being handed `start: 0` is the site stating outright that it served page 1, so "asked for N, got something else" is an - unambiguous end of listing (§23, on a second site and in a second + unambiguous end of listing (on a second site and in a second spelling). """ return _int(pagination(html).get("start")) @@ -846,7 +846,7 @@ def _normalize_amount(raw: str) -> Optional[float]: def prices_in(text: str) -> List[float]: """Every JPY amount in a piece of rendered text, in order. - Percentages are removed BEFORE matching, not rejected afterwards (§4): + Percentages are removed BEFORE matching, not rejected afterwards: Japanese discount badges write `50%OFF` and `-10%` with both ASCII and fullwidth signs, and a rejected match has still consumed its neighbouring yen symbol. @@ -884,7 +884,7 @@ def _without_extension_scripts(text: str) -> str: def detect_block_marker(html: Any) -> Optional[str]: """Which refusal this is, or None. - Matched over a bounded prefix and with entities unescaped, per §20 — an + Matched over a bounded prefix and with entities unescaped — an edge can entity-escape the punctuation of its own reference id on the way to an HTTP client while a browser parses it back out plain. """ @@ -907,7 +907,7 @@ def detect_bot_challenge(html: Any, url: str = "") -> Optional[str]: measurement (see `BOT_CHALLENGE_MARKERS`) rather than a shortcut: the scan runs, and it runs against the markup with extension-injected `' % _k, "u") + got_a = _static('' % _k, "u") + ok &= check("the static detector finds a v2 widget by its container " + "class alone", got_c is not None and got_c.kind == "recaptcha_v2" + and got_c.sitekey == _k) + ok &= check("...and by its anchor frame alone, with the frame's size", + got_a is not None and got_a.sitekey == _k + and got_a.kind == "recaptcha_v2_invisible") + served = [n for n in FIX if isinstance(FIX[n], dict) + and isinstance(FIX[n].get("html"), str) + and _static(FIX[n]["html"], FIX[n].get("url", ""))] + ok &= check("...and fires on none of this site's captures (%s)" + % (", ".join(served) or "none"), not served) + # Deliberately absent: no solver for a first-party image captcha. This - # site has no such page — measured 2026-09-10, its refusal is not a page - # at all: the HTTP/2 stream is reset and nothing arrives — so a solver + # site has no such page — its refusals are a 43-byte Akamai deny and a + # branded 403/503 page, none with a form or an image on it — so a solver # for one would be dead code that looks load-bearing. Pinned so that # reintroducing it is a decision rather than a drift. import captcha_solver @@ -2283,7 +2374,7 @@ def test_captcha(): # policy: which challenge a visitor meets depends on the exit country and # on what the address has been doing. # THE MARKER SET IS THE MEASUREMENT, and on this site the measurement - # is zero. §18's question is not "did we meet a captcha" but "is one + # is zero. the question is not "did we meet a captcha" but "is one # CONFIGURED, and would we recognise it if it appeared?" — so it was # asked properly: across all 13 captures, served and refused alike, # there are no reCAPTCHA, hCaptcha, Turnstile, DataDome, PerimeterX, @@ -2309,19 +2400,19 @@ def test_captcha(): # loads, so it fires on GOOD pages fetched over --cdp-endpoint — and on # two sibling sites it was measured to MISS the real challenge while # doing so. A marker that fires on good pages and is absent from the bad - # one is worse than no marker (§19). + # one is worse than no marker. ok &= check("cf-turnstile is not carried", not any("cf-turnstile" in m for m in markers)) # A bare CDN or vendor NAME is not carried either. Rakuten is fronted by # Akamai, and "this site uses Akamai" is a fact about its - # infrastructure rather than a signal about this response (§18). + # infrastructure rather than a signal about this response. ok &= check("a bare vendor/CDN name is not a marker", not any(m in ("akamai", "akam", "edgesuite", "cloudflare") for m in markers)) # NOT ONE of them may appear on a page the site served. This is the # check that would have caught the family's `cf-turnstile` mistake, run # against the fixtures that matter rather than against a 404 fetched by - # curl (§21: a guard is only as good as the fixture it runs against). + # curl (a guard is only as good as the fixture it runs against). for name in ("search_p1", "search_p1_browser", "category_p1", "search_deep", "search_sponsored", "item_page", "item_discounted", "search_no_results"): @@ -2339,7 +2430,7 @@ def test_captcha(): detect_bot_challenge(fx("search_p1")[0] + EXTENSION_TAGS) is None) # AND THE SAME THING AGAINST A PAGE ACTUALLY FETCHED THAT WAY, which is - # the fixture §21 says a guard needs: a sibling repo's version of this + # the fixture a guard needs: a sibling repo's version of this # check ran only against a curl-fetched 404, which carries no injection # at all, so it passed for the wrong reason. # @@ -2401,7 +2492,7 @@ def test_captcha(): ok &= check("...and its rows parse", len(parse_products(cdp_html, cdp_url)) >= 3) # And the honest sentence about the paid product, which is the one this - # family has got wrong before (§19). What may be written is "this repo + # family has got wrong before. What may be written is "this repo # does not implement X", never "X cannot be solved" — 2Captcha solves # enterprise reCAPTCHA (RecaptchaV2EnterpriseTaskProxyless) and # Cloudflare Turnstile (TurnstileTaskProxyless), whatever this client @@ -2690,7 +2781,7 @@ def test_proxy_pool(): # was written to show. Every caller here passes the URL as its own `%s` # argument for that reason, and `_mask_credentials()` is what handles # arbitrary text. Asserting the CURRENT behaviour makes a future swap a - # failing check rather than an unreadable log (§10). + # failing check rather than an unreadable log. sentence = "a http://u:supersecret@h.example:8080 b" ok &= check("mask() on a sentence loses the host — the documented limit", mask(sentence) == "?://?") @@ -2793,7 +2884,7 @@ def test_engines(skips): # "Not painted yet" is not a fault, and every engine has to make that # distinction the same way — the first live search run of the # Playwright engine reported 0 rows and exit 4 because it did not. - # §17's rule, applied to this repo's own additions: a policy + # the rule, applied to this repo's own additions: a policy # constant or a helper that nothing consults is the same defect as # dead code, and harder to see because the prose reads like # enforcement. All three of these were written, documented in the @@ -2857,7 +2948,7 @@ def test_engines(skips): # THE FLAG CONTRACT, and the exact ways the engines differ from it. # - # §9 lists the flags every engine must offer. Checked rather than + # The family contract lists the flags every engine must offer. Checked rather than # trusted, because a flag one engine has and another does not is the # drift page_flow.py and finish_run() exist to prevent, one level up — # and because the README documents these differences by name, so a new @@ -2890,7 +2981,7 @@ def test_engines(skips): # there is nothing left to except: the family's contract flags are # in all three engines. A sibling repo shipped twelve flags the # primary engine had and its twins did not, nine of them predating - # the work, while its README promised "same CLI" (§20) — so a new + # the work, while its README promised "same CLI" — so a new # unshared flag has to fail here. # # This closed two real gaps rather than documenting them: pyppeteer @@ -2910,7 +3001,7 @@ def test_engines(skips): # And a flag every engine declares must be one every engine READS. A # flag that is accepted and then ignored looks configurable and is # not, which is the defect this family keeps finding in its own - # copied core (§17). + # copied core. for eng in ("playwright_scraper", "puppeteer_scraper", "selenium_scraper"): esrc = open(os.path.join(REPO_ROOT, eng + ".py"), @@ -2947,7 +3038,7 @@ def test_engines(skips): if "args.fingerprint = False" in ln] ok &= check("%s forces it off in exactly one place" % eng, len(forced) == 1) - # THE BEHAVIOUR, not a comment saying so — §20's rule that an + # THE BEHAVIOUR, not a comment saying so — the rule that an # assertion which is a proxy for behaviour goes stale. Forcing # `args.fingerprint = False` only gates the identity if EVERY # path that applies one is itself behind that flag. @@ -2957,7 +3048,7 @@ def test_engines(skips): # and guard its CALL SITE. A version of this check that looked # only for calls lexically inside an `if ... fingerprint` block # reported those two as ungated, which was the check being wrong - # rather than the code (§22: ask what a check does when its + # rather than the code (ask what a check does when its # resolution fails). etree = ast.parse(esrc) applies = ("get_fingerprint", "_apply_fingerprint", @@ -3015,7 +3106,7 @@ def _calls_in(node): # `pages_still_growing` — so a Selenium run's sidecar was MISSING the # cap arithmetic, which on this site is the figure that keeps # `status: complete` honest: the site serves 6,750 of 3,000,000 matches, - # and a sidecar that says only "complete" is lying by omission (§21). + # and a sidecar that says only "complete" is lying by omission. # # Compared as the SET OF KEYS each engine builds, read off the source, # because the alternative is three live runs and a diff — which is how @@ -3067,14 +3158,14 @@ def _calls_in(node): ok &= check("%s merges page 1's cap summary into the sidecar" % eng, "page_one_cap" in esrc and "extra.update(page_one_cap)" in esrc) - # A dead dataclass field is the same defect as a dead column (§9). + # A dead dataclass field is the same defect as a dead column. ok &= check("%s has no dead `scroll` outcome field" % eng, "scroll: Optional" not in esrc) # `--fp-tags` MUST DEFAULT TO ONE OS-FAMILY TAG. It shipped in this # family as "Windows,Chrome,Desktop", which the fingerprint API rejects # with HTTP 400 — so --fingerprint failed on every invocation, which is - # one of the six defects §16 of the family notes lists. Measured + # one of the six defects found in the copied fingerprint client. Measured # 2026-09-10 against the live API: `Windows` succeeds; # `Windows,Chrome,Desktop`, `Chrome` and `Desktop` each 400. for name in _ENGINE_MODULES: @@ -3304,7 +3395,7 @@ def _unreachable_statements(path): def test_public_names_have_consumers(): group("no public name is dead code or unenforced policy") ok = True - # §17 #5: grep every public name in a shared module for a consumer + # Grep every public name in a shared module for a consumer # outside its own module, then READ what comes back. The ones that # encode SITE POLICY rather than plumbing are pinned here, so deleting # one fails instead of quietly changing behaviour — which is the failure @@ -3353,7 +3444,7 @@ def test_public_names_have_consumers(): ok &= check("SOURCE_DEFAULT is what a row's `source` actually says", Product().source == "rakuten.co.jp") # THE THROTTLE POLICY IS CONSULTED, not merely declared. This is the - # §17/§23 defect at its most expensive: `SOLVES_PER_PAGE` read like an + # The unenforced-cap defect at its most expensive: `SOLVES_PER_PAGE` read like an # enforced cap in every repo of this family while each engine called the # solver twice per attempt and counted once. ok &= check("the throttle budget and its backoff agree in length", @@ -3370,11 +3461,19 @@ def test_public_names_have_consumers(): ok &= check("%s consults RETRY_ON_BLOCKED rather than only " "documenting it" % eng, "RETRY_ON_BLOCKED" in src) # Count the solve call sites, the guards and the increments, and - # assert the three are equal. That is the check §23 asks for by - # name, and it is what a reading of the code does not give you. + # assert the three are equal: a call site the budget does not cover + # can buy a solve the cap was meant to refuse. The pre-classification + # site counts ATTEMPTED purchases (the `purchases` list the handler + # appends to before asking the solver), so a solver that raises + # cannot be asked twice for one page. calls = src.count("handle_captcha_if_present(") guards = src.count("page_flow.solve_budget(") - bumps = src.count("solves_bought += 1") + bumps = (src.count("solves_bought += 1") + + src.count("solves_bought += len(purchases)")) + ok &= check("%s counts an attempted purchase, not only a successful " + "one, at the pre-classification call" % eng, + "purchases=purchases" in src + and "purchases.append(" in src) # One of the "calls" is the function's own def. ok &= check("%s: every solve call site is behind the budget " "(%d call(s), %d guard(s), %d increment(s))" @@ -3383,6 +3482,289 @@ def test_public_names_have_consumers(): return ok + +def _load_engine_with_stub_driver(eng): + """Import an engine even when its driver library is absent. + + The fetch loop is pure Python around a handful of driver calls, so it can + be driven with a fake page. When the real driver is installed it is used; + otherwise a stub module stands in for the import only, and both the stub + and the engine are removed from sys.modules afterwards so the engine + groups elsewhere in this suite still see the library as absent. + """ + import importlib + import types + stubs = {} + if eng == "playwright_scraper": + try: + import playwright.sync_api # noqa: F401 + except ImportError: + api = types.ModuleType("playwright.sync_api") + + class Error(Exception): + pass + + class TimeoutError(Error): # noqa: A001 — mirrors the real name + pass + api.Error, api.TimeoutError = Error, TimeoutError + api.sync_playwright = lambda: None + stubs = {"playwright": types.ModuleType("playwright"), + "playwright.sync_api": api} + elif eng == "puppeteer_scraper": + try: + import pyppeteer # noqa: F401 + except ImportError: + mod = types.ModuleType("pyppeteer") + mod.launch = mod.connect = lambda *a, **k: None + stubs = {"pyppeteer": mod} + had = eng in sys.modules + if had and not stubs: + return sys.modules[eng] + saved = dict((k, sys.modules.get(k)) for k in stubs) + sys.modules.update(stubs) + try: + sys.modules.pop(eng, None) + engine = importlib.import_module(eng) + finally: + for k, v in saved.items(): + if v is None: + sys.modules.pop(k, None) + else: + sys.modules[k] = v + if stubs and not had: + sys.modules.pop(eng, None) + return engine + + +class _LoopFakeResponse: + def __init__(self, status): + self.status = status + + +class _LoopFakePage: + """Answers the driver calls both browser engines' fetch loops make. + + pyppeteer's calls go through `bridge.run(...)`, which the fake bridge + below passes straight through, so plain return values serve both. + """ + def __init__(self, url, html, status=200, goto_error=None): + self.url, self._html, self._status = url, html, status + self._goto_error = goto_error + self.gotos = 0 + + def goto(self, url, *a, **k): + self.gotos += 1 + if self._goto_error is not None: + raise self._goto_error + self.url = url + return _LoopFakeResponse(self._status) + + def content(self): + return self._html + + def query_selector_all(self, sel): + return [] + + querySelectorAll = query_selector_all + + def wait_for_timeout(self, ms): + return None + + def evaluate(self, *a, **k): + return None + + def reload(self, *a, **k): + return _LoopFakeResponse(self._status) + + +class _LoopFakeBridge: + def run(self, value): + return value + + +class _LoopFakeSession: + def __init__(self, page): + self.page, self.bridge, self.relaunches = page, _LoopFakeBridge(), 0 + + def relaunch(self): + self.relaunches += 1 + + +def test_engine_fetch_loop_offline(): + group("browser engines' fetch loop, driven offline with a fake page") + ok = True + import types + import argparse as _ap + import time as _time + import page_flow as _pf + no_sleep = types.SimpleNamespace(sleep=lambda s: None, time=_time.time, + monotonic=_time.monotonic) + blk_html, blk_url, _ = fx("block_403") + for eng in ("playwright_scraper", "puppeteer_scraper"): + try: + engine = _load_engine_with_stub_driver(eng) + except Exception as e: # noqa: BLE001 + ok &= check("%s loads with a stub driver (%s)" % (eng, e), False) + continue + real_time = engine.time + engine.time = no_sleep + try: + with tempfile.TemporaryDirectory() as d: + args = _ap.Namespace( + pages=1, retries=1, retry_delay=0.0, mode="listing", + out=os.path.join(d, "out"), dump_html=None, + proxy_block_retries=0, solve_captcha="when-blocked", + twocaptcha_key="", captcha_api="v2", min_score=0.7) + + # 1. No proxy at all, and every navigation fails. Must end + # as a load failure, never as an AttributeError on the + # absent pool. + err_cls = getattr(engine, "PWTimeout", Exception) + page = _LoopFakePage(blk_url, "", + goto_error=err_cls("Navigation Timeout Exceeded")) + try: + out = engine._fetch_one_page(_LoopFakeSession(page), args, + None, 1, blk_url) + raised = None + except Exception as e: # noqa: BLE001 + out, raised = None, e + ok &= check("%s: repeated load failures with no proxy pool " + "end as a load failure, not a crash (%r)" + % (eng, raised), + raised is None and out.load_failed) + + # 2. The site's branded refusal under HTTP 403 is a BLOCK. + # Without the status it would read as a 503 throttle and + # be waited out at the same exit. + page = _LoopFakePage(blk_url, blk_html, status=403) + out = engine._fetch_one_page(_LoopFakeSession(page), args, + None, 1, blk_url) + ok &= check("%s: the branded page under HTTP 403 is " + "classified blocked, so the status reached the " + "classifier (state=%s)" % (eng, out.state), + out.state == "blocked" + and out.blocked_by is not None) + page = _LoopFakePage(blk_url, blk_html, status=503) + out = engine._fetch_one_page(_LoopFakeSession(page), args, + None, 1, blk_url) + ok &= check("%s: the same page under HTTP 503 is a throttle, " + "waited out rather than reported blocked " + "(state=%s, %d fetches)" % (eng, out.state, + page.gotos), + out.state == "throttled" + and page.gotos == 1 + _pf.THROTTLE_RETRIES) + + # 3. A solver that RAISES still counts against the page's + # allowance: one page, a challenge on every fetch, a + # solver that fails every time -> at most SOLVES_PER_PAGE + # calls to it. + calls = [] + + def _solver(*a, **k): + calls.append(1) + raise RuntimeError("stub solver failure") + fake_challenge = types.SimpleNamespace( + kind="recaptcha_v2", source="html", sitekey="stub", + action=None) + saved = (engine.solve_recaptcha, engine.reconcile_detections, + _pf.classify) + engine.solve_recaptcha = _solver + engine.reconcile_detections = lambda *a: fake_challenge + _pf.classify = lambda *a, **k: "challenge" + args.twocaptcha_key = "stub-not-sent" + try: + page = _LoopFakePage(blk_url, "", status=200) + engine._fetch_one_page(_LoopFakeSession(page), args, None, 1, + blk_url) + finally: + (engine.solve_recaptcha, engine.reconcile_detections, + _pf.classify) = saved + args.twocaptcha_key = "" + ok &= check("%s: a failing solver is asked at most " + "SOLVES_PER_PAGE times for one page (%d call(s))" + % (eng, len(calls)), + 0 < len(calls) <= _pf.SOLVES_PER_PAGE) + finally: + engine.time = real_time + + # Every classify call in every engine names a status argument, even + # Selenium's, which passes None on purpose because WebDriver has none. + import ast as _ast + for eng in ("playwright_scraper", "puppeteer_scraper", "selenium_scraper"): + tree = _ast.parse(open(os.path.join(REPO_ROOT, eng + ".py"), + encoding="utf-8").read()) + bad, seen = [], 0 + for node in _ast.walk(tree): + if not isinstance(node, _ast.Call): + continue + f = node.func + name = getattr(f, "attr", None) or getattr(f, "id", None) + if name not in ("classify", "_classify"): + continue + seen += 1 + npos = len(node.args) - (1 if name == "_classify" else 0) + if npos < 2 and not any(k.arg == "status" for k in node.keywords): + bad.append(node.lineno) + ok &= check("%s: every classify call passes a status (%d call(s), " + "missing at lines %s)" % (eng, seen, bad), + seen > 0 and not bad) + + # Numeric flags are validated at the command line, by argparse, in every + # entry point: read off disk so the check runs with no driver installed. + want = {"--pages": "at_least_one", "--concurrency": "at_least_one", + "--delay": "non_negative_seconds", + "--retry-delay": "non_negative_seconds", + "--proxy-block-retries": "non_negative_int", + "--retries": "attempts"} + api_want = {"--retries": "non_negative_int", + "--retry-delay": "non_negative_int", + "--timeout": "at_least_one"} + for eng, table in (("playwright_scraper", want), + ("puppeteer_scraper", want), + ("selenium_scraper", want), + ("scraper_api_client", api_want)): + tree = _ast.parse(open(os.path.join(REPO_ROOT, eng + ".py"), + encoding="utf-8").read()) + types_ = {} + for node in _ast.walk(tree): + if (isinstance(node, _ast.Call) + and getattr(node.func, "attr", None) == "add_argument" + and node.args and isinstance(node.args[0], _ast.Constant)): + for k in node.keywords: + if k.arg == "type": + types_[node.args[0].value] = getattr( + k.value, "attr", getattr(k.value, "id", None)) + wrong = sorted(f for f, t in table.items() if types_.get(f) != t) + ok &= check("%s validates its numeric flags with page_flow's " + "validators (wrong: %s)" % (eng, wrong or "none"), + not wrong) + import argparse as _ap2 + probe = _ap2.ArgumentParser() + probe.add_argument("--pages", type=_pf.at_least_one) + probe.add_argument("--delay", type=_pf.non_negative_seconds) + probe.add_argument("--proxy-block-retries", type=_pf.non_negative_int) + + def _rejects(argv, flag): + err = io.StringIO() + try: + with redirect_stderr(err): + probe.parse_args(argv) + except SystemExit as e: + return e.code == 2 and ("argument %s" % flag) in err.getvalue() + return False + ok &= check("--pages 0 is a usage error naming the flag", + _rejects(["--pages", "0"], "--pages")) + ok &= check("--delay -5 is a usage error naming the flag", + _rejects(["--delay", "-5"], "--delay")) + ok &= check("--proxy-block-retries -1 is a usage error naming the flag", + _rejects(["--proxy-block-retries", "-1"], + "--proxy-block-retries")) + ok &= check("...while --pages 1, --delay 0 and --proxy-block-retries 0 " + "parse", probe.parse_args(["--pages", "1", "--delay", "0", + "--proxy-block-retries", "0"]).pages == 1) + return ok + + def test_log_format_strings_match_their_args(): group("every log call's placeholders match its arguments") ok = True @@ -3450,7 +3832,7 @@ def test_every_shared_call_binds(): group("every call into a shared module binds, including the paths a " "credential gates") ok = True - # §17's check #1, widened to the case that matters most here: the + # the check #1, widened to the case that matters most here: the # credential-gated modules. `fingerprint_client`, `scraper_api_client` # and `captcha_solver` are reached only with a 2Captcha key, and no key # was available to the work that built this repo — so a wrong call in @@ -3528,7 +3910,7 @@ def test_every_shared_call_binds(): # A name the module does NOT define is the loudest thing this # check can say, so it must not be skipped — a sibling repo # resolved with getattr(..., None), skipped anything - # not-callable, and stayed silent on exactly that case (§22). + # not-callable, and stayed silent on exactly that case. if target is None: bad.append("%s:%d %s.%s does not exist" % (caller, node.lineno, module_name, real)) @@ -3593,7 +3975,7 @@ def test_sponsored_slots_do_not_shift_position(): # a click-tracking URL yields no sku and `_row_from_hit` drops the entry # anyway — so that assertion alone would pass against a broken filter. # (Found by planting exactly that fault and watching the suite stay - # green; §22's "verify that your control actually failed".) + # green; the "verify that your control actually failed".) # # A sponsored entry wearing a product-shaped URL is the case only the # filter catches, and Rakuten already puts `cpc` on every one of them. @@ -3627,7 +4009,7 @@ def test_sponsored_slots_do_not_shift_position(): ok &= check("position starts at 1 even though the payload starts with ads", rows[0].position == 1) # page+position must be unique across a multi-page run — one line, and - # the column is worthless without it (§18). + # the column is worthless without it. multi = parse_products(html, url, page=1) + parse_products(html, url, page=2) pairs = [(r.page, r.position) for r in multi] @@ -3683,7 +4065,7 @@ def test_detail_pages(): # A WAS-PRICE, and only where Rakuten's own verification flag allows one. # Japan's double-pricing rules require a merchant's reference price to be # substantiated, and the site carries the outcome as a flag — which is - # the "use the site's own should-I-show-this flag" rule (§21). + # the "use the site's own should-I-show-this flag" rule. ok &= check("a verified reference price becomes original_price", row.original_price == 10398.0) ok &= check("and WHICH reference price it was is recorded", @@ -3691,7 +4073,7 @@ def test_detail_pages(): ok &= check("the discount is computed from the two prices", row.discount_pct == 50.0) # The other fixture has the flag FALSE, so no was-price at all — both - # values of the flag are exercised, which §20 asks for. + # values of the flag are exercised. plain = parse_product_page(*_fx_detail("item_page"))[0] ok &= check("an UNVERIFIED reference price is not read", plain.original_price is None @@ -3824,7 +4206,7 @@ def test_parse_is_gated_on_the_policy(): # engine parsed whatever reached the parse line, so the `parse` column # of STATE_POLICY decided nothing and an engine could disagree with the # table — and with its twins — without anything noticing. That is the - # defect CLAUDE.md §17 names for constants, with a function instead. + # defect of a policy constant nothing reads, with a function instead. # # Measured across the family on 2026-09-23 by counting definitions # against readers: 7 of 24 repos defined it and none called it. @@ -3957,6 +4339,7 @@ def main() -> int: ok &= test_every_shared_call_binds() ok &= test_x_debug_header_is_redacted() ok &= test_scraper_api_waitfor_is_an_object() + ok &= test_engine_fetch_loop_offline() print() if _failures: