Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ body:
options:
- category
- product
- transfers
- player
- not applicable
validations:
required: true
Expand Down
14 changes: 6 additions & 8 deletions .github/ISSUE_TEMPLATE/site_changed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ body:
options:
- category
- product
- transfers
- player
- not sure / more than one
validations:
required: true
Expand All @@ -37,7 +35,7 @@ body:
label: What stopped matching
options:
- Rows (0 rows on a page that visibly has data)
- A specific field is null or wrong (price, club, position, fee, ...)
- A specific field is null or wrong (price, currency, shade, availability, ...)
- Price coverage dropped below the floor logged in the run output
- Pagination stops early or repeats a page
- The bot-challenge detector finds nothing on a page that has one
Expand All @@ -62,13 +60,13 @@ body:
description: |
Whichever of these you can get. A capture beats a description.

- The relevant table row's HTML (right-click the row on the real site
-> Copy -> Copy outerHTML), or the output of
- The relevant product tile's HTML (right-click the tile on the real
site -> Copy -> Copy outerHTML), or the output of
`--dump-html capture.html` against this URL.
- For a field problem: the value you got, and the value on the page.
- For a player-profile problem: whether the page has a
`itemtype="https://schema.org/Person"` block at all (view source
and search for it).
- For a product-page problem: whether the page has a
`<script type="application/ld+json">` block with `"@type":"Product"`
at all (view source and search for it).
render: text
validations:
required: true
Expand Down
46 changes: 42 additions & 4 deletions .github/ci_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
# committing a sample is that it came from a real run; a placeholder teaches
# readers field names and value shapes that do not exist. Kept generic
# (rather than site-specific) on purpose: a real row here is a real
# player or transfer, and there is no fixed set of "fake-looking" names to
# check for the way an e-commerce sibling checks for "sample product".
# Givenchy product, and the markers below are the shapes a hand-written
# placeholder row tends to take.
FABRICATION_MARKERS = ("sample-product-", "example brand", "sample product",
"product description text", "lorem ipsum",
"your_api_key", "123456789")
Expand Down Expand Up @@ -74,6 +74,16 @@
"a:b@", # env_config.py's own precedence fixtures
"a:b@from-dotenv")

# A key-shaped value under a key-shaped name, as a page's own config or a
# fixture would carry it. Every quote may be backslash-escaped, because the
# fixtures here are JSON strings inside Python strings and arrive as
# `\\"apiKey\\": \\"...\\"` -- a bare-quote pattern matched zero times in
# exactly the files where a front-end key would turn up. Not limited to hex:
# front-end keys are often alphanumeric.
KEY_SHAPED_FIELD = re.compile(
r'\\?"(?:api_?key|apiKey|clientKey|client_key|secret|access_?token)'
r'\\?"\s*:\s*\\?"([A-Za-z0-9_\-]{24,})\\?"', re.IGNORECASE)

# A 2captcha API key is a 32-character hex string.
HEX32 = re.compile(r"\b[0-9a-f]{32}\b")
HEX32_ALLOWED = ("sha", "hash", "nonce", "example", "md5", "digest", "checksum")
Expand Down Expand Up @@ -121,13 +131,31 @@ def git_ignored(paths):
return {p for p in proc.stdout.split("\0") if p}


def _is_virtualenv(directory):
"""A directory holding `pyvenv.cfg` is a virtualenv whatever it is
called. Matching by NAME (`venv`, `.venv`) walked into any environment
named anything else -- `myenv`, `venv-playwright` -- and reported pip's
vendored hex strings as leaked keys."""
return (directory / "pyvenv.cfg").is_file()


def scanned_files():
candidates = []
venvs = set()
for path in sorted(REPO.rglob("*")):
if not path.is_file() or path.suffix not in SCANNED_SUFFIXES:
continue
if any(part in {".git", "__pycache__", ".venv", "venv"}
for part in path.parts):
# Judged on the path RELATIVE to the repo. `path.parts` holds every
# ancestor too, so a clone that happened to live under a directory
# called `venv` (or `.git`, or `__pycache__`) skipped every file and
# reported "nothing credential-shaped" after scanning nothing.
rel_parts = path.relative_to(REPO).parts
if any(part in {".git", "__pycache__"} for part in rel_parts):
continue
parents = [REPO.joinpath(*rel_parts[:i])
for i in range(1, len(rel_parts))]
if any(p in venvs or _is_virtualenv(p) for p in parents):
venvs.update(p for p in parents if _is_virtualenv(p))
continue
candidates.append(path)

Expand Down Expand Up @@ -233,12 +261,22 @@ def secret_check():
failed.append(f"{rel}:{lineno} looks like a URL with real "
f"credentials in it")

if KEY_SHAPED_FIELD.search(line) and not any(
token in line for token in CREDENTIAL_ALLOWED):
failed.append(f"{rel}:{lineno} carries a key-shaped value "
f"under a key-shaped field name")

for match in HEX32.findall(line):
if any(token in line.lower() for token in HEX32_ALLOWED):
continue
failed.append(f"{rel}:{lineno} contains {match[:6]}… — a "
f"32-char hex string, the shape of a 2captcha key")

if scanned == 0:
# "Nothing credential-shaped" after scanning nothing is a pass for
# the wrong reason.
failed.append("0 files scanned -- the scan excluded everything, so "
"it proves nothing")
if not failed:
print(f"ok {scanned} files scanned, nothing credential-shaped")
return failed
Expand Down
70 changes: 18 additions & 52 deletions .github/workflows/canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,17 @@ name: canary
# badge stays green throughout. This workflow is the other half — a real run,
# on a real page, on a schedule — so a site-side break is caught here first.
#
# THIS CANARY RUNS WITH OR WITHOUT A SECRET, AND THE DIFFERENCE MATTERS.
# ------------------------------------------------------------------------
# A sibling in this family (mediamarkt-scraper) gates its canary on a
# residential proxy secret, because that site answers every datacentre
# address — including a GitHub runner — with HTTP 403. Givenchy Beauty is not
# reliably either way, and this workflow's first version got that wrong: it
# asserted the site challenges nobody, citing research from 2026-09-10/12.
# Then 8 of 10 consecutive requests from a bare datacentre address were
# answered with an AWS WAF captcha, and later the same day six distinct
# network positions drew none at all. Whether a GitHub runner meets the WAF
# is a property of that runner's address on that morning.
# THIS CANARY NEEDS NO SECRET, AND A BLOCK IS A FAILURE.
# -----------------------------------------------------------
# The listing path is served to a bare GitHub runner with a local Playwright
# browser: the last ten scheduled runs before 2026-10-15 all reached the
# data checks below. So the run is ungated, and every outcome other than
# real rows -- blocked (3), zero rows (4), never obtained (5), partial (6)
# -- turns this check red. An earlier version skipped green on exit 3 from a
# bare runner, which meant a canary that could pass without testing the site.
#
# So the rule here is NOT "this needs no secret". It is:
#
# * If GIVENCHY_CDP_ENDPOINT or GIVENCHY_PROXY is configured,
# the run goes through it — that is the repo's documented default path —
# and being blocked anyway is a REAL failure worth a red check.
# * If neither is configured, the run uses a bare runner and a local
# Playwright browser, which is what catches a markup change, and that is
# the common case. But a block from a bare runner is a fact about the
# runner's address, not about the site, so it SKIPS with a ::notice::
# rather than failing. A check that goes red for a reason nobody in this
# repo can fix is a check everyone learns to ignore.
#
# Every other exit code is still interpreted and still turns this red, so a
# markup change cannot hide behind the skip.
# GIVENCHY_CDP_ENDPOINT / GIVENCHY_PROXY are optional: if set, the run goes
# through them instead of the runner's own address. Neither is required.
#
# Secrets reach the scraper through the ENVIRONMENT, never a command line —
# `ps` reads argv. env_config.py reads exactly these variable names, and an
Expand Down Expand Up @@ -73,16 +58,13 @@ jobs:
elif [ "$HAVE_PROXY" = "true" ]; then
echo "::notice::Running through GIVENCHY_PROXY. A block here is a real failure."
else
echo "::notice::Running from a bare runner with a local Playwright browser and no secret. This catches a markup change; if the runner's address meets the WAF, this job SKIPS rather than failing."
echo "::notice::Running from a bare runner with a local Playwright browser and no secret -- the path this repo documents as working without one. A block here fails the job."
fi

- name: Scrape a real, known-good category page
id: run
# 3 pages, not 1: with one page pagination is never exercised, so a
# broken ?page= convention or the "gallery pagination trap" this
# repo's page_flow.py works around (see pagination_is_addressable's
# docstring) would stay invisible here — which is precisely the
# silent decay this workflow exists to catch.
# One page: this site's listing paging is robots-disallowed, so a
# listing is a single fetch by design (see the README).
#
# 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
Expand All @@ -108,35 +90,18 @@ jobs:
CODE: ${{ steps.run.outputs.exit_code }}
run: |
code="$CODE"
skipped=false
case "$code" in
0) echo "OK — rows parsed from a live run." ;;
3) if [ "$HAVE_CDP" = "true" ] || [ "$HAVE_PROXY" = "true" ]; then
echo "::error::Blocked before parsing (exit 3) while running through a configured exit (cdp=$HAVE_CDP proxy=$HAVE_PROXY). That is the documented default path failing, not the runner's address. Check the zone or proxy credentials first — a Scraping Browser zone that refuses CONNECT by host reports ERR_TUNNEL_CONNECTION_FAILED and is an account matter, not a scraper bug — then see detect_bot_challenge()/BOT_CHALLENGE_MARKERS in product_parser.py."
else
# No secret configured, so this ran from a bare runner. A
# block here is a fact about that runner's address on the
# day: 8 of 10 requests from one datacentre address were
# challenged on 2026-09-16, and none at all from six other
# network positions later the same day. Nothing in this
# repo can fix it, so it must not paint the check red.
echo "::notice::SKIPPED: this runner's address was challenged before the page was parsed (exit 3), and no GIVENCHY_CDP_ENDPOINT or GIVENCHY_PROXY secret is configured to route around it. That is a property of the runner's address, not of the site or of this scraper, so the canary is skipping rather than failing. Set either secret to have this run go through an exit that is not challenged -- and to make a block here a real failure."
skipped=true
code=0
fi ;;
3) echo "::error::Blocked before parsing (exit 3) (cdp=$HAVE_CDP proxy=$HAVE_PROXY). The canary does not skip on a block: a run that never saw the listing has tested nothing. From a bare runner this may be the runner's address rather than the site -- the HTML dump in the artifacts shows which challenge was served; setting GIVENCHY_PROXY or GIVENCHY_CDP_ENDPOINT routes the run around it." ;;
4) echo "::error::Ran fine but parsed 0 rows (exit 4) on a listing known to carry products. The site's markup has probably changed. The HTML dump in the artifacts tells you what the parser actually saw." ;;
5) echo "::error::Remote API error (exit 5) — a 2captcha product path failed. With a secret configured (cdp=$HAVE_CDP proxy=$HAVE_PROXY) that is the endpoint or the credentials; with neither, nothing should have called a remote API at all and the run log in the artifacts is where to start." ;;
5) echo "::error::The page was never obtained (exit 5): a load timeout, a dead exit, or a 2captcha product path failing (cdp=$HAVE_CDP proxy=$HAVE_PROXY). canary_run.attempt.json names the stop_reason; not_found means the canary's own category path has been retired." ;;
6) echo "::error::Partial run (exit 6) — the run stopped early. canary_run.meta.json names which pages failed. A listing here is a SINGLE page, so a partial run means that one page failed after some rows were already merged, not that pagination ran out." ;;
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
echo "skipped=$skipped" >> "$GITHUB_OUTPUT"
test "$code" = "0"

- name: Sanity-check the scraped data
# Nothing was fetched on the skip path, so there is nothing to check
# and canary_run.json does not exist.
if: steps.verdict.outputs.skipped != 'true'
# 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
Expand Down Expand Up @@ -194,7 +159,7 @@ jobs:
fail.append(f"{field} present on only {got}/{len(rows)} rows "
f"(floor {floor:.0%})")

# CLAUDE.md §4's guard, one line and it catches the regression
# One line, and it catches the regression
# forever: a struck price read from the wrong node (an EU 30-day-low
# disclosure rather than a list price) gives an original_price at or
# BELOW the price, which is a negative discount on a product that is
Expand All @@ -215,7 +180,7 @@ jobs:
fail.append("sku is not unique across the run -- rows are being "
"keyed on something that is not the variant id")

# CLAUDE.md §18's tokopedia lesson: a page number never threaded
# A page number never threaded
# into the parser produces (page, row_index) collisions that look,
# from the row count alone, like a healthy run.
pairs = [(r.get("page"), r.get("row_index")) for r in rows]
Expand Down Expand Up @@ -259,6 +224,7 @@ jobs:
canary_run.json
canary_run.csv
canary_run.meta.json
canary_run.attempt.json
canary_run.html*
canary_run_page*_debug.*
if-no-files-found: ignore
Expand Down
16 changes: 8 additions & 8 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ jobs:
you can find is a number or a claim that no longer matches the
code, or a comment that explains a reason the code no longer has.

A few things specific to this repo worth checking: every price is
in EUR and every row is a DOM reading (there is no JSON-LD on
this site to cross-check against — see output_writer.py and
diff_runs.py); there are two row schemas, Player and Transfer
(output_writer.py), and a change to one should not silently affect
the other; `page` and `row_index` must stay unique across a
paginated run (see product_parser.py and the tokopedia lesson in
CLAUDE.md); and this site's listing pagination is robots-DISALLOWED,
A few things specific to this repo worth checking: listing rows
are read from the tiles' own microdata (listings publish no
JSON-LD) while product pages read JSON-LD, and a missing currency
must stay null rather than default to USD (the showcase locales
int/en and ru print no price at all); there is one row schema,
Product (output_writer.py), and `sku` is the VARIANT id, not the
master id the URL carries; `page` and `row_index` must stay unique across a
paginated run (see product_parser.py); and this site's listing pagination is robots-DISALLOWED,
so product_parser.page_url() returning its input unchanged is a
deliberate decision, not a stub — a change that makes it
construct a URL deserves extra scrutiny.
Expand Down
16 changes: 6 additions & 10 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*.json
*.csv
!package.json
# Committed on purpose: a real excerpt of a real run (--mode market-values),
# Committed on purpose: a real excerpt of a real run (--mode category),
# so a reader can see the exact output shape without installing anything.
!sample_output.json
!sample_output.csv
Expand All @@ -29,12 +29,6 @@ artifacts/
*_debug.png
*.html.page*

# --- unlike other members of this family, index.html and landing.md are ----
# --- committed here on purpose, not generated -- do NOT gitignore them -----
# (kept as an explicit note rather than a silent omission, since every sibling
# scraper in this family DOES gitignore an index.html as "published
# elsewhere", and that is deliberately not true of this repo)

# --- a downloaded 2captcha proxy list is a credential file ------------------
# Every line of one carries a login and a password. The glob covers the
# variants people actually make (proxylist-us.txt, proxylist.2.txt) and
Expand Down Expand Up @@ -75,7 +69,10 @@ env/
# The scrapers mask user:pass in their own logs, but raw HTML dumps are NOT
# masked, and a key pasted into a shell history file is not masked either.
*.log
.env
# Every working copy of .env, however it was renamed (.env.bak, .env.local,
# .env.save): one `git add -A` from a commit otherwise. The template stays.
.env*
!.env.example
*.key
secrets*

Expand All @@ -84,7 +81,6 @@ secrets*
# HTML and sitemaps from givenchybeauty.com, fetched 2026-09-17/18. It is
# EVIDENCE, kept on disk so a claim in the README can be re-checked, but it
# is not repo content -- the fixtures the test suite actually runs against
# are trimmed, scrubbed excerpts embedded in smoke_test.py, per CLAUDE.md
# §10. A committed capture would also carry whatever per-visitor state the
# are trimmed, scrubbed excerpts embedded in smoke_test.py. A committed capture would also carry whatever per-visitor state the
# page happened to hold.
captures/
Loading
Loading