Skip to content

fix(reports): refresh retired Yandex docs URLs + add docs-drift protection (#426)#427

Merged
axisrow merged 4 commits into
mainfrom
fix/426-reports-drift-urls-and-protection
May 28, 2026
Merged

fix(reports): refresh retired Yandex docs URLs + add docs-drift protection (#426)#427
axisrow merged 4 commits into
mainfrom
fix/426-reports-drift-urls-and-protection

Conversation

@axisrow

@axisrow axisrow commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

  • Refresh four retired Yandex Reports docs URLs (/ru/type, /ru/period, /ru/fields-list, /ru/spec) after Yandex moved them off the legacy /ru/reports/<page> layout and renamed spec.htmlspec. Three of the four had silently been captcha-poisoned (~14.6 KB SmartCaptcha HTML) in tests/reports_cache/raw/.
  • Update five RESOURCE_MAPPING_V5[*]["docs"] URLs that Yandex moved from …/ru/<group>/<group> to single-segment …/ru/<group> (dynamictextadtargets, dynamicfeedadtargets, reports, smartadtargets, vcards). Discovered by the new health checker.
  • Refetch the Reports cache; spec.json is byte-equivalent to the pre-migration snapshot save for one updated AttributionModels description string.

Protection against recurrence

  1. Captcha guard in fetchersfetch_reports_spec and fetch_wsdl/fetch_live_wsdl raise a clear RuntimeError when a response contains showcaptcha/smartcaptcha/<title>Captcha, or is suspiciously short. No more silent cache poisoning.
  2. Cache-content teststest_reports_cache_files_are_real_content and test_wsdl_cache_files_are_real_content enforce the same invariants on committed cache files.
  3. New scripts/check_all_docs_urls.py — paces requests (1.5s gap + 30s captcha retry), enumerates every URL in RESOURCE_MAPPING_V5 and REPORTS_SPEC_URLS, hard-fails on 3xx-to-different-path, 4xx, or captcha body; soft-warns on 5xx.
  4. Pre-release gate in release_pypi.sh — runs check_all_docs_urls.py + refresh_reports_cache.py + focused pytest before building the wheel. Hard fail = no release. No --skip-checks flag.
  5. CLAUDE.md codifies "No URL literals outside the registry" and documents the pre-release gate.

Test plan

  • pytest tests/test_api_coverage.py::TestReportsCoverage tests/test_api_coverage.py::TestWsdlCacheFreshness -v — 10/10 green.
  • pytest tests/ -q --timeout=60 — 1945 passed, 45 skipped, 0 failed.
  • python scripts/check_all_docs_urls.py — all 37 URLs OK.
  • Incident simulation: injected the retired /ru/reports/type URL back into RESOURCE_MAPPING_V5["reports"]["docs_pages"]["type"], ran the health checker — caught it as GONE HTTP 404 and exited with code 1. Restored.
  • grep -l "showcaptcha\|smartcaptcha" tests/reports_cache/raw/*.html — empty.

Out of scope

Closes #426.

🤖 Generated with Claude Code

…s-drift protection (#426)

Yandex retired three Reports docs paths (`/ru/reports/<page>`) in favor of
`/ru/<page>` and renamed `spec.html` to `spec`. The drift checker silently
fell into a Yandex SmartCaptcha rate-limit gateway for three of those pages
(~14.6 KB captcha HTML instead of real docs); the existing `spec.json`
snapshot survived only because it was committed pre-migration.

The fix also catches five more URLs that Yandex moved from
`…/ru/<group>/<group>` to `…/ru/<group>` (dynamictextadtargets,
dynamicfeedadtargets, reports, smartadtargets, vcards).

Protection against silent recurrence:

- `fetch_reports_spec` and `fetch_wsdl`/`fetch_live_wsdl` reject responses
  matching SmartCaptcha markers or below a size threshold.
- New tests `test_reports_cache_files_are_real_content` and
  `test_wsdl_cache_files_are_real_content` guard the committed caches.
- New `scripts/check_all_docs_urls.py` paces requests, follows redirects,
  and fails hard on canonical path moves / captcha / 4xx.
- `scripts/release_pypi.sh` runs the health check + cache refresh +
  focused pytest as a mandatory pre-release gate (no opt-out).
- `CLAUDE.md` codifies "no URL literals outside the registry" and the
  pre-release gate.

Closes #426.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copilot AI review requested due to automatic review settings May 28, 2026 07:08
@axisrow axisrow added this to the 0.4.1 milestone May 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@axisrow axisrow modified the milestones: 0.4.1, 0.3.14 May 28, 2026
@axisrow

axisrow commented May 28, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 3m 49s —— View job


PR Review — Critical Issues Only

  • Read PR diff against origin/main
  • Review captcha guards in fetchers
  • Review check_all_docs_urls.py script
  • Review release_pypi.sh pre-release gate changes
  • Review cache-content tests
  • Post review findings

Solid PR overall — the captcha guards, cache-content tests, and URL drift checker are the right shape. Below are the issues I'd want addressed before this becomes the gating release path. Everything else (style, naming, thresholds) I'm intentionally skipping per the review brief.


1. HEAD-only probing will produce false GONE failures and block releases

File: scripts/check_all_docs_urls.py:53-72

The probe issues requests.head(...) first and only falls through to GET on a 2xx. If Yandex's CDN ever responds 405 (Method Not Allowed) or 403 to HEAD for any of the 37 URLs, the script classifies it as GONE (4xx → hard fail) and release_pypi.sh aborts the release. CDNs frequently treat HEAD differently from GET — and Yandex's own Cloudflare-style fronting is known to do this for some doc routes.

This is the same class of false-positive the PR is trying to prevent (a working page that looks broken), just moved one layer up. Recommend: on HEAD 4xx, retry with GET before declaring GONE. Or use GET with stream=True from the start and just read headers.

Fix this →

2. Pre-release gate has no escape hatch and runs network checks on every release

File: scripts/release_pypi.sh:72-83, 119

prerelease_docs_health_check is mandatory and runs before build_artifacts. The PR description proudly notes "No --skip-checks flag" — which is the right default, but combined with:

  • 37 URLs × 1.5s pacing = ~55s minimum
  • Single 30s captcha retry per URL
  • Any single CAPTCHA / SMALL / GONE / ERROR / MOVED hard-fails
  • HEAD-405 false positives (see Add Claude Code GitHub Workflow #1)

…this means a captcha sweep from Yandex during the release window blocks PyPI publishing with no override. For a hotfix release that doesn't touch any docs URLs, this is a real risk. Worth at least an explicit env-var escape (e.g. SKIP_DOCS_HEALTH_CHECK=1) gated behind a clear warning, even if it's not a CLI flag.

3. refresh_reports_cache.py mutates committed files during release

File: scripts/release_pypi.sh:79

python3 scripts/refresh_reports_cache.py writes to tests/reports_cache/raw/*.html and tests/reports_cache/spec.json. If Yandex updated any doc page since the last commit, the release proceeds with a dirty working tree, and the maintainer ends up either (a) committing post-release fixture drift, or (b) silently dropping the new content. The release script doesn't git diff --exit-code or fail if the cache was modified.

Suggest either:

  • Run with use_cache=True and fail if the live content differs from the committed cache (force a separate commit before release), or
  • Move the refresh out of the release path entirely and rely on the existing scheduled drift monitor.

4. Captcha protection does not cover imported XSDs

File: direct_cli/wsdl_coverage.py:377-396 and tests/test_api_coverage.py:2280-2308

_assert_real_wsdl is only called from fetch_wsdl and fetch_live_wsdl. fetch_imported_xsd (line 377) does the same requests.get(...).text → cache_file.write_text(...) dance with no validation, so an imported XSD can still be silently captcha-poisoned. This is the exact failure mode the PR is closing for WSDLs; the imports path has it open.

The new test_wsdl_cache_files_are_real_content test also uses cache.glob("*.xml") which is non-recursive, so files in tests/wsdl_cache/imports/ are not checked.

Recommend: add a sibling _assert_real_xsd (same captcha markers, lower min-size threshold, require xsd:schema instead of wsdl:definitions) and call it from fetch_imported_xsd; extend the cache-content test to cache.rglob("*.xml") or add a second loop over imports/.

Fix this →

5. _assert_real_wsdl substring match is namespace-prefix-dependent

File: direct_cli/wsdl_coverage.py:164-184

_WSDL_REQUIRED_MARKERS = ("<?xml", "wsdl:definitions") is a raw substring check on the XML text. If Yandex ever changes the namespace prefix (e.g., to <definitions xmlns:wsdl="..."> with definitions as the root element rather than wsdl:definitions, which is perfectly valid WSDL), this will reject real responses and brick the cache refresh.

Today this is fine — all current Yandex WSDLs use wsdl: prefix — but it's a known-fragile guard. Minor, but worth a comment noting the assumption or a regex that allows any namespace prefix on definitions.


Not flagged (intentionally, per the brief)

  • URL fixes in RESOURCE_MAPPING_V5 and refreshed HTML fixtures look correct and match the canonical paths.
  • The strict body-size / captcha-marker thresholds are arbitrary but defensible.
  • CHANGELOG, CLAUDE.md, test additions all look consistent.

Summary

Items 1 and 2 together are the most likely to cause real production pain (a transient Yandex anti-bot event blocking a hotfix). Item 4 is a genuine coverage gap in the captcha protection. Items 3 and 5 are smaller but worth addressing.
· fix/426-reports-drift-urls-and-protection

Three FIX items from the @claude review:

1. HEAD-only probing in check_all_docs_urls.py falsely declared CDN-issued
   405/403 as GONE. Now: only 5xx and 3xx-with-different-path are decided
   from HEAD; everything else falls through to GET, and GONE is only
   declared when GET also returns 4xx. Prevents Yandex CDN false positives
   from blocking a release.
2. release_pypi.sh no longer runs refresh_reports_cache.py, which mutated
   committed files mid-release. Instead it runs the read-only freshness
   tests and then refuses to release if tests/reports_cache or
   tests/wsdl_cache has uncommitted changes — the maintainer must commit
   the cache refresh in a separate, reviewable diff.
3. fetch_imported_xsd now validates responses against a new _assert_real_xsd
   guard (captcha markers + min size + xml/schema markers), closing the
   silent-poisoning hole for tests/wsdl_cache/imports/*.xsd. New test
   test_imported_xsd_cache_files_are_real_content extends coverage to those
   three XSDs that the original glob('*.xml') had missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@axisrow

axisrow commented May 28, 2026

Copy link
Copy Markdown
Owner Author

Triage applied in 1f1b8d3:

@axisrow

axisrow commented May 28, 2026

Copy link
Copy Markdown
Owner Author

@claude review. Focus on critical issues: bugs, security vulnerabilities, logical errors, data loss risks, performance problems. Do NOT nitpick style, naming conventions, minor formatting, or subjective preferences — only flag issues that could break functionality or cause real harm in production.

@claude

claude Bot commented May 28, 2026

Copy link
Copy Markdown

Claude finished @axisrow's task in 2m 5s —— View job


Re-review — follow-up commit 1f1b8d3


All three FIX items look correctly implemented. Notes below are not blockers, just observations from re-reading the patch.

#1 — HEAD→GET fallback (FIXED, correctly)

scripts/check_all_docs_urls.py:65-97 — HEAD now only short-circuits to MOVED on a 3xx-with-different-path or SERVER on 5xx; everything else (including 403/405 from a CDN that doesn't like HEAD) falls through to GET, and GONE is decided from the GET status. The error string preserves both codes for diagnosis ("HTTP 404 (HEAD was 405)"). The captcha-redirect retry on HEAD is preserved.

One minor edge case worth knowing about (not a blocker): the GET on line 79 doesn't set allow_redirects=False, so if the CDN rejects HEAD with 4xx and the GET would 301 to a different canonical path, requests follows the redirect silently and we mark it OK. This means the MOVED-detection only fires when HEAD returns a redirect — for the rare URL that 403s HEAD but redirects on GET, a canonical-path migration would be missed until a subsequent HEAD probe sees the redirect directly. Won't cause a bad release; just a small gap in drift detection on that specific path.

#3 — refresh out of release path (FIXED, with one subtlety)

scripts/release_pypi.sh:80-94 no longer calls refresh_reports_cache.py, runs only the read-only freshness pytest, and then git diff --quiet -- tests/reports_cache tests/wsdl_cache blocks release on a dirty tree. This is the right shape.

One subtle behavior worth flagging (borderline minor): git diff --quiet -- <paths> compares working tree against the index, not against HEAD. So this sequence:

python scripts/refresh_reports_cache.py
git add tests/reports_cache tests/wsdl_cache
./scripts/release_pypi.sh all

…would pass the gate even though the cache has uncommitted-to-HEAD changes. The intent of the gate ("maintainer must commit the refresh explicitly") is better expressed as:

if ! git diff --quiet HEAD -- tests/reports_cache tests/wsdl_cache; then

That covers both unstaged and staged-but-uncommitted changes. As-is, the practical risk is small (released wheels don't include tests/), but it's a one-word change that fully matches the documented intent.

#4 — imported XSD captcha guard + recursive test (FIXED, correctly)

  • direct_cli/wsdl_coverage.py:237-258_assert_real_xsd checks both captcha markers, min-size (1 KB, calibrated for the smallest current import), the XML declaration, and a schema substring. Called from fetch_imported_xsd on line 420 right after raise_for_status() and before the cache write — that's the correct ordering (validate before persist).
  • tests/test_api_coverage.py:2318-2333 — new test_imported_xsd_cache_files_are_real_content globs imports/*.xsd (correct extension this time, matching the actual on-disk files) and applies the same four invariants. Together with the existing WSDL test, both halves of the cache are now guarded.

Implementation matches the production guard's checks one-for-one.


Verdict

The triage applied in 1f1b8d3 addresses the three FIX items I flagged. The two SKIP items (#2 escape hatch, #5 namespace prefix) are defensible per the author's reasoning — #2's underlying risk dissolves now that #1 no longer cascades HEAD failures into release blocks, and #5 is a documented-as-fragile guard with no observed drift.

The two minor notes above (GET redirect handling in _probe, git diff vs git diff HEAD) are not critical-tier — neither can cause a bad release or data loss in normal maintainer flow. Mentioning them only because the user asked for a focused critical review and these are the only loose threads I can find in the follow-up; entirely OK to defer or close as won't-fix.

LGTM to merge as-is.
· fix/426-reports-drift-urls-and-protection

- release_pypi.sh: use `git diff --quiet HEAD --` so the gate trips on
  staged-but-uncommitted cache changes too (was checking against index only).
- check_all_docs_urls.py: pass allow_redirects=False to the GET fallback,
  so a canonical move that only surfaces on GET (HEAD 4xx → GET 3xx-to-
  different-path) is still detected as MOVED instead of silently followed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@axisrow axisrow merged commit e9478f1 into main May 28, 2026
6 checks passed
@axisrow axisrow deleted the fix/426-reports-drift-urls-and-protection branch May 28, 2026 08:02
axisrow added a commit that referenced this pull request May 28, 2026
Pre #427 the release script ran scripts/check_all_docs_urls.py inline,
so a SmartCaptcha rate-limit on our IP (302 → showcaptcha, or HEAD
302/GET 404 when Yandex throttles the docs CDN) would hard-fail an
otherwise valid release.

Changes:

- New scripts/preflight_check.sh runs the three network-dependent
  checks (docs URLs, cache freshness, no uncommitted cache diff) as a
  separate, manual step. Run before bumping the version.
- scripts/release_pypi.sh no longer calls those checks — it only
  rebuilds the wheel, runs twine check, and uploads. Releases become
  deterministic; transient Yandex throttling cannot block them.
- scripts/check_all_docs_urls.py downgrades CAPTCHA from hard-fail to
  soft-warn (same bucket as 5xx), since a persisted captcha is an
  external rate-limit on our IP — not evidence the URL is gone.
  Canonical move (3xx → different path) and confirmed 4xx remain
  hard-fail.
- CLAUDE.md "PyPI Release" section rewritten to document the two-phase
  flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

reports drift checker: hard-coded URLs retired by Yandex (type / fields-list / period / spec.html)

2 participants