Repository navigation
canary #19
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
| name: canary | |
| # tests.yml (deliberately) never touches amazon.com — see its own header for | |
| # why. That leaves a real gap: a markup change on the site itself only shows up | |
| # when a user hits it and files an issue, while the offline badge stays green | |
| # throughout. This workflow is the other half — a real run, on a real search | |
| # URL, on a schedule — so a site-side break is caught here first. | |
| # | |
| # Uses no 2Captcha product and no secret by default, because the README's | |
| # headline claim is that an ordinary browser on a clean IP returns three pages | |
| # of products with no key, no proxy and no account, and this is where that | |
| # claim gets exercised continuously. | |
| # | |
| # EXPECT MORE NOISE HERE THAN ON A RESIDENTIAL IP. GitHub Actions runners are | |
| # datacenter addresses, and Amazon treats those less generously than the | |
| # residential exit the README's numbers were measured from: a red run can mean | |
| # the site changed (a real break) or that this address was throttled or shown a | |
| # captcha. The exit-code interpretation step below spells out which is which. | |
| # | |
| # (First manual dispatch, 2026-09-08, from a runner with no proxy at all: 72 | |
| # rows, 66 priced, status=complete, 3/3 pages. So a datacenter address is not | |
| # automatically refused — but one green run is not a promise.) | |
| # | |
| # If it does prove noisy, set the AMAZON_PROXY repo secret and nothing else: | |
| # | |
| # gh secret set AMAZON_PROXY --repo 2scraper/amazon-scraper | |
| # | |
| # The run below passes it through the ENVIRONMENT, which env_config.py reads | |
| # for exactly this variable, rather than through a --proxy flag. Two reasons: | |
| # a credential on a command line is readable by anything that can run `ps`, | |
| # which is the rule this repo states and should therefore follow in its own | |
| # CI; and an unset secret arrives as an empty string, which the loader already | |
| # treats as "not configured", so the same workflow works with and without it | |
| # and there is no commented-out line for someone to forget to uncomment. | |
| on: | |
| schedule: | |
| # Once a day, off the hour, so it is not competing with everyone else's | |
| # on-the-hour cron jobs for runner availability. | |
| - cron: "23 6 * * *" | |
| workflow_dispatch: | |
| jobs: | |
| live-run: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install Playwright engine | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt -r requirements-playwright.txt | |
| playwright install --with-deps chromium | |
| - name: Scrape a real, known-good search listing | |
| id: run | |
| # The same filtered search URL the README uses as its worked example, | |
| # so a 0-result run means either the site changed or this address was | |
| # refused — not "this URL never had products". Filtered (`&i=`) rather | |
| # than a bare query, because a department-scoped search is the shape a | |
| # real monitoring job uses. | |
| # | |
| # --pages 3, not 1: with one page pagination is never exercised at | |
| # all, so a renamed next-page class (or the ?page= convention | |
| # breaking) would stay invisible here — which is precisely the silent | |
| # decay this workflow exists to catch. Amazon keeps serving a valid | |
| # page for any page number, so the run's own "this page added nothing | |
| # new" terminator is what has to be trusted, and three pages is enough | |
| # to exercise it. | |
| # | |
| # continue-on-error: the exit code is read explicitly below so the | |
| # interpretation (parser break vs. probable address block) can be | |
| # printed before the job goes red. | |
| # | |
| # set +e: GitHub Actions runs `run:` steps as `bash -e`, so without | |
| # this a non-zero scraper exit aborts the script on THAT line and the | |
| # `echo "exit_code=$?"` below never runs. continue-on-error only stops | |
| # the step from failing the job; it does not touch the shell's errexit. | |
| continue-on-error: true | |
| env: | |
| # Empty when the secret is not set, which env_config.py treats as | |
| # unset — so this line is correct in both cases and needs no edit | |
| # when the secret arrives. See the header. | |
| AMAZON_PROXY: ${{ secrets.AMAZON_PROXY }} | |
| run: | | |
| set +e | |
| python3 playwright_scraper.py \ | |
| --url "https://www.amazon.com/s?k=bluetooth+headphones&i=electronics" \ | |
| --pages 3 --out canary_run --dump-html canary_run.html | |
| echo "exit_code=$?" >> "$GITHUB_OUTPUT" | |
| - name: Interpret the exit code | |
| run: | | |
| code="${{ steps.run.outputs.exit_code }}" | |
| case "$code" in | |
| 0) echo "OK — products parsed from a live run." ;; | |
| 3) echo "::error::Blocked before parsing (exit 3): Amazon's image captcha, an unresolved 503 throttle, or a sign-in wall. Check canary_run.meta.json's stop_reason in the artifacts, and note this may be this runner's datacenter address rather than a site change — see AMAZON_PROXY in this workflow's header." ;; | |
| 4) echo "::error::Ran fine but parsed 0 rows (exit 4) on a URL known to have plenty — this is the shape a real site-side break takes." ;; | |
| 5) echo "::error::The page was never fetched (exit 5) — a navigation timeout or a transport failure, NOT an empty result. This is the one exit code here that is more likely to be this runner's network than a site change; canary_run.last_attempt.json names the stop_reason." ;; | |
| 6) echo "::error::Partial run (exit 6) — some pages were gathered and then the run stopped early. canary_run.meta.json names which pages failed." ;; | |
| 2) echo "::error::Bad usage (exit 2) — the workflow's own arguments are wrong, not the site." ;; | |
| *) echo "::error::Unexpected exit code $code (see the step above for the traceback)." ;; | |
| esac | |
| test "$code" = "0" | |
| - name: Sanity-check the scraped data | |
| # Exit 0 only means "some rows were written". It says nothing about | |
| # whether the page actually rendered: a snapshot taken too early still | |
| # writes rows, just with fields empty. Every floor below is set from a | |
| # measured figure and deliberately well under it, so this fails on a | |
| # collapse rather than on normal variation. | |
| run: | | |
| python3 - <<'EOF' | |
| import json, sys | |
| from pathlib import Path | |
| PAGES_REQUESTED = 3 | |
| # Measured 2026-09-08 from a residential European exit: 3 pages of | |
| # this URL gave 90 rows, 77 after duplicates were dropped, at 24-30 | |
| # organic tiles per page. The floor is set at half of that. | |
| MIN_ROWS = 40 | |
| # Measured price coverage on this URL: 71-83%. Amazon genuinely | |
| # withholds the price on roughly a quarter of tiles, so the floor is | |
| # 50% and not the 90% a site that always prints a price would take. | |
| MIN_PRICED_SHARE = 0.5 | |
| rows = json.loads(Path("canary_run.json").read_text()) | |
| meta = json.loads(Path("canary_run.meta.json").read_text()) | |
| fail = [] | |
| if len(rows) < MIN_ROWS: | |
| fail.append(f"only {len(rows)} rows, expected at least {MIN_ROWS} " | |
| f"across {PAGES_REQUESTED} pages") | |
| priced = sum(1 for r in rows if r.get("price") is not None) | |
| if rows and priced / len(rows) < MIN_PRICED_SHARE: | |
| fail.append(f"only {priced}/{len(rows)} rows have a price " | |
| f"(floor {MIN_PRICED_SHARE:.0%}) — either Amazon is " | |
| f"withholding far more than usual, or the price node " | |
| f"moved") | |
| # Where the price came from. Every priced row on every measured run | |
| # read `.a-price .a-offscreen`; a run that fell back to reassembling | |
| # the visually split price for most rows means that node changed, and | |
| # the numbers are worth re-checking even though they are not wrong. | |
| if priced: | |
| offscreen = sum(1 for r in rows if r.get("price_source") == "offscreen") | |
| if offscreen / priced < 0.9: | |
| fail.append(f"only {offscreen}/{priced} priced rows came from " | |
| f"the .a-offscreen node — the price markup has " | |
| f"probably changed") | |
| # Fields that were 100% populated on every page measured. Any of them | |
| # collapsing means an anchor moved. | |
| for field, floor in (("title", 0.95), ("rating", 0.8), | |
| ("review_count", 0.8), ("image_url", 0.95), | |
| ("sku", 1.0), ("category", 1.0)): | |
| got = sum(1 for r in rows if r.get(field) not in (None, "")) | |
| if rows and got / len(rows) < floor: | |
| fail.append(f"{field} present on only {got}/{len(rows)} rows " | |
| f"(floor {floor:.0%})") | |
| # A review_count is a count, not a rating with its digits glued on. | |
| # This exact shape shipped once: "4.4 out of 5 stars, 279,961 | |
| # ratings" read as 445279961. | |
| absurd = [r["sku"] for r in rows | |
| if isinstance(r.get("review_count"), int) | |
| and r["review_count"] > 50_000_000] | |
| if absurd: | |
| fail.append(f"{len(absurd)} row(s) have an implausible " | |
| f"review_count (>50M): {absurd[:3]} — the count is " | |
| f"being read from a node that also holds the rating") | |
| # The point of running more than one page. | |
| if meta.get("pages_completed", 0) < PAGES_REQUESTED: | |
| fail.append(f"pagination stopped after {meta.get('pages_completed')} " | |
| f"of {PAGES_REQUESTED} pages (stop_reason=" | |
| f"{meta.get('stop_reason')!r}) — the next-page class or " | |
| f"the ?page= convention has probably broken") | |
| if meta.get("status") != "complete": | |
| fail.append(f"run status is {meta.get('status')!r}, not 'complete'") | |
| if meta.get("mode") != "listing": | |
| fail.append(f"run mode is {meta.get('mode')!r}, not 'listing'") | |
| if meta.get("source") != "amazon.com": | |
| fail.append(f"run source is {meta.get('source')!r} — the URL was " | |
| f"redirected to another marketplace") | |
| for line in fail: | |
| print(f"::error::{line}") | |
| print(f"[canary] {len(rows)} rows, {priced} priced, " | |
| f"status={meta.get('status')}, " | |
| f"pages={meta.get('pages_completed')}/{PAGES_REQUESTED}") | |
| sys.exit(1 if fail else 0) | |
| EOF | |
| - name: Upload run artifacts (output, HTML snapshot, debug dumps) | |
| # Always, not just on failure: a run that returns the WRONG numbers | |
| # with no error is exactly the regression that needs the actual bytes | |
| # the parser saw, not a pass/fail badge. | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: canary-run-${{ github.run_id }} | |
| path: | | |
| canary_run.json | |
| canary_run.csv | |
| canary_run.meta.json | |
| canary_run.html* | |
| canary_run_page*_debug.* | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # --------------------------------------------------------------------- | |
| # The variation columns, on a real detail page. | |
| # | |
| # Why this job exists: `variations` read the rendered twister and was null | |
| # on every row of every run for as long as the column existed. Nothing | |
| # noticed, because a detail page was never fetched by CI and there was no | |
| # twister fixture — a column silently returning null is invisible to a | |
| # coverage check and to a green offline suite alike. The offline fixture | |
| # (smoke_test.FIX_VARIATION_STATE) pins the parse; this pins that the | |
| # SITE still publishes what that fixture was carved from. | |
| # | |
| # Ran dispatch-only first, because a job nobody has watched go green does | |
| # not belong on a schedule — an unverified live job going red every | |
| # morning is how everyone learns to ignore a badge. Promoted on evidence: | |
| # two manual dispatches from GitHub runners with no proxy and no secret, | |
| # 2026-09-21, both green. | |
| # | |
| # Those two runs are also why the assertion is a FLOOR and not the | |
| # measured figure. The same product reported 776 variants from a | |
| # workstation, then 744, then 745 from the runners ten minutes apart — one | |
| # product, three numbers, two hours. A canary pinned to any of them would | |
| # go red for the catalogue behaving normally. | |
| # | |
| # If it does start being refused from runners, gate its steps on | |
| # AMAZON_PROXY the way this file's header describes and let it SKIP with a | |
| # ::notice:: when the secret is absent, rather than leaving it red. | |
| variation-canary: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install Playwright engine | |
| run: | | |
| python -m pip install --upgrade pip | |
| pip install -r requirements.txt -r requirements-playwright.txt | |
| playwright install --with-deps chromium | |
| - name: Read a real multi-variant product page | |
| id: run | |
| # Crocs Classic Clog, pinned because it is about as durable as an | |
| # Amazon listing gets (a staple sold for over a decade) and because | |
| # it carried the largest variant count measured while this was | |
| # written: 776 over Size and Color on 2026-09-21. A pinned ASIN can | |
| # still be delisted, which would show up here as exit 4 — see the | |
| # interpretation step. | |
| continue-on-error: true | |
| env: | |
| AMAZON_PROXY: ${{ secrets.AMAZON_PROXY }} | |
| run: | | |
| set +e | |
| python3 playwright_scraper.py --mode product \ | |
| --url "https://www.amazon.com/dp/B0014C2NBC" \ | |
| --out variation_run --dump-html variation_run.html | |
| echo "exit_code=$?" >> "$GITHUB_OUTPUT" | |
| - name: Interpret the exit code | |
| run: | | |
| code="${{ steps.run.outputs.exit_code }}" | |
| case "$code" in | |
| 0) echo "OK — the detail page was read." ;; | |
| 3) echo "::error::Blocked before parsing (exit 3) — may be this runner's address rather than a site change." ;; | |
| 4) echo "::error::The page was served but no product parsed (exit 4). Either the pinned ASIN is gone, or the detail parser broke." ;; | |
| 5) echo "::error::The page was never fetched (exit 5) — transport, more likely this runner's network than the site." ;; | |
| *) echo "::error::Unexpected exit code $code (see the step above)." ;; | |
| esac | |
| [ "$code" = "0" ] | |
| - name: Assert the variation columns are actually populated | |
| run: | | |
| python3 - <<'EOF' | |
| import json | |
| import sys | |
| rows = json.load(open("variation_run.json")) | |
| assert len(rows) == 1, "expected one product row, got %d" % len(rows) | |
| r = rows[0] | |
| fail = [] | |
| for column in ("parent_asin", "variation_dimensions", | |
| "variation_count", "selected_variation"): | |
| if r.get(column) in (None, [], ""): | |
| fail.append("%s is empty" % column) | |
| # A floor, not the measured figure. 776 was true on 2026-09-21 and | |
| # a catalogue legitimately changes size, so asserting it would make | |
| # this job fail for the site doing something normal. What is NOT | |
| # normal is a multi-variant product reporting one variant: that is | |
| # the shape the old column's failure took. | |
| count = r.get("variation_count") | |
| if isinstance(count, int) and count < 2: | |
| fail.append("variation_count is %d on a product picked for " | |
| "having many variants — this is what the extraction " | |
| "silently returning nothing looks like" % count) | |
| # One label per dimension, or the two halves of the state have | |
| # stopped lining up and the pairing is meaningless. | |
| dims, selected = r.get("variation_dimensions"), r.get("selected_variation") | |
| if isinstance(dims, list) and isinstance(selected, list) \ | |
| and len(dims) != len(selected): | |
| fail.append("%d dimensions but %d selected values" | |
| % (len(dims), len(selected))) | |
| if fail: | |
| print("::error::variation columns: " + "; ".join(fail)) | |
| sys.exit(1) | |
| print("canary OK: %s, %s variants over %s" | |
| % (r["sku"], count, ", ".join(dims))) | |
| EOF | |
| - uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: variation-canary | |
| path: | | |
| variation_run.json | |
| variation_run.meta.json | |
| variation_run.last_attempt.json | |
| variation_run.html | |
| if-no-files-found: warn |