Skip to content

Commit c7703eb

Browse files
committed
fix(verify): distinguish bot challenges from dead sources
1 parent fc0ad8f commit c7703eb

5 files changed

Lines changed: 173 additions & 23 deletions

File tree

‎app/verify/cli.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -453,11 +453,22 @@ def cmd_check_urls(args: argparse.Namespace) -> int:
453453
def _summarize_cache(cache: dict[str, dict[str, Any]], targets: list[str]) -> None:
454454
from collections import Counter
455455
alive = sum(1 for u in targets if cache.get(u, {}).get("alive"))
456-
dead = sum(1 for u in targets if u in cache and not cache[u].get("alive"))
457-
print(f"\nliveness over {len(targets)} targeted URL(s): {alive} alive, {dead} dead")
456+
indeterminate = sum(
457+
1 for u in targets if http_check.is_automation_challenge(cache.get(u, {}))
458+
)
459+
dead = sum(
460+
1 for u in targets
461+
if u in cache and not cache[u].get("alive")
462+
and not http_check.is_automation_challenge(cache[u])
463+
)
464+
print(
465+
f"\nliveness over {len(targets)} targeted URL(s): {alive} alive, {dead} dead, "
466+
f"{indeterminate} automation-challenged"
467+
)
458468
reasons = Counter(
459469
cache[u].get("reason") for u in targets
460470
if u in cache and not cache[u].get("alive")
471+
and not http_check.is_automation_challenge(cache[u])
461472
)
462473
if reasons:
463474
print("dead reasons:")
@@ -619,10 +630,17 @@ def cmd_pr(args: argparse.Namespace) -> int:
619630
except Exception as exc: # network hiccup must not sink the report
620631
print(f"_Tier 1 skipped: {exc}_\n")
621632
alive = sum(1 for e in url_cache.values() if e.get("alive"))
622-
dead = len(url_cache) - alive
633+
indeterminate = sum(1 for e in url_cache.values() if http_check.is_automation_challenge(e))
634+
dead = len(url_cache) - alive - indeterminate
623635
print("### Tier 1 — source-URL liveness (changed)\n")
624-
print(f"Checked **{len(url_cache)}** unique URL(s): **{alive} alive**, **{dead} dead**.\n")
625-
dead_reasons = Counter(e["reason"] for e in url_cache.values() if not e.get("alive"))
636+
print(
637+
f"Checked **{len(url_cache)}** unique URL(s): **{alive} alive**, "
638+
f"**{dead} dead**, **{indeterminate} automation-challenged**.\n"
639+
)
640+
dead_reasons = Counter(
641+
e["reason"] for e in url_cache.values()
642+
if not e.get("alive") and not http_check.is_automation_challenge(e)
643+
)
626644
if dead_reasons:
627645
print("| Dead reason | Count |")
628646
print("| --- | ---: |")

‎app/verify/http_check.py‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
# nothing about whether the page exists, so these answers must never be cached as
3737
# a verdict — otherwise one impatient run marks a whole host dead for a TTL.
3838
TRANSIENT_STATUSES = frozenset({429, 503})
39+
AUTOMATION_CHALLENGE_REASON = "automation-challenge"
3940
RETRY_ATTEMPTS = 3
4041
RETRY_BACKOFF_S = (2.0, 6.0)
4142
MAX_RETRY_AFTER_S = 15.0
@@ -58,6 +59,11 @@ class CheckResult(NamedTuple):
5859
def transient(self) -> bool:
5960
return self.status in TRANSIENT_STATUSES
6061

62+
@property
63+
def indeterminate(self) -> bool:
64+
"""True when the endpoint reached an anti-bot challenge, not a page verdict."""
65+
return self.reason == AUTOMATION_CHALLENGE_REASON
66+
6167

6268
# --- opener abstraction (injectable for tests) -----------------------------------
6369

@@ -102,7 +108,15 @@ def _is_homepage_redirect(original: str, final: str) -> bool:
102108
return _path_depth(original) >= 1 and _path_depth(final) == 0
103109

104110

105-
def classify(original_url: str, status: int | None, final_url: str | None) -> tuple[bool, str]:
111+
def classify(
112+
original_url: str,
113+
status: int | None,
114+
final_url: str | None,
115+
*,
116+
automation_challenge: bool = False,
117+
) -> tuple[bool, str]:
118+
if automation_challenge:
119+
return False, AUTOMATION_CHALLENGE_REASON
106120
if status is None:
107121
return False, "error"
108122
if status >= 400:
@@ -122,11 +136,34 @@ def _retry_after_seconds(exc: Exception) -> float | None:
122136
return None # HTTP-date form; fall back to our own backoff
123137

124138

125-
def _attempt(url: str, opener: Any) -> tuple[int | None, str | None, float | None]:
126-
"""One HEAD-then-GET pass. Returns (status, final_url, retry_after)."""
139+
def _is_automation_challenge(exc: Exception, code: int) -> bool:
140+
"""Recognize an explicit anti-bot challenge without weakening generic 403s.
141+
142+
A September 2026 probe with this module's User-Agent found
143+
``browser.geekbench.com`` returning ``403`` with
144+
``CF-Mitigated: challenge`` and Cloudflare's challenge page. That proves the
145+
request reached the cited host but not whether an unauthenticated client may
146+
inspect the page. Keep ordinary 401/403 responses as dead: an HTTP status
147+
alone cannot distinguish a missing or forbidden page from bot protection.
148+
"""
149+
if code != 403:
150+
return False
151+
headers = getattr(exc, "headers", None)
152+
mitigated = headers.get("CF-Mitigated", "") if headers is not None else ""
153+
return isinstance(mitigated, str) and mitigated.lower() == "challenge"
154+
155+
156+
def _attempt(url: str, opener: Any) -> tuple[int | None, str | None, float | None, bool]:
157+
"""One HEAD-then-GET pass.
158+
159+
Returns ``(status, final_url, retry_after, automation_challenge)``. The last
160+
value is intentionally based on an explicit gateway signal, never on a bare
161+
403, so unavailable pages cannot become promotable merely by returning 403.
162+
"""
127163
status: int | None = None
128164
final: str | None = None
129165
retry_after: float | None = None
166+
automation_challenge = False
130167
for method in ("HEAD", "GET"):
131168
try:
132169
status, final = opener.open(url, method)
@@ -138,11 +175,12 @@ def _attempt(url: str, opener: Any) -> tuple[int | None, str | None, float | Non
138175
if isinstance(code, int):
139176
status, final = code, getattr(exc, "url", None) or url
140177
retry_after = _retry_after_seconds(exc)
178+
automation_challenge = _is_automation_challenge(exc, code)
141179
if method == "HEAD" and code in (400, 403, 405, 501):
142180
continue
143181
break
144-
status, final = None, None
145-
return status, final, retry_after
182+
status, final, automation_challenge = None, None, False
183+
return status, final, retry_after, automation_challenge
146184

147185

148186
def check_one(
@@ -154,15 +192,18 @@ def check_one(
154192
to wait, not that the page is gone.
155193
"""
156194
status = final = retry_after = None
195+
automation_challenge = False
157196
for attempt in range(RETRY_ATTEMPTS):
158-
status, final, retry_after = _attempt(url, opener)
197+
status, final, retry_after, automation_challenge = _attempt(url, opener)
159198
if status not in TRANSIENT_STATUSES:
160199
break
161200
if on_rate_limit is not None:
162201
on_rate_limit(host_of(url))
163202
if attempt < RETRY_ATTEMPTS - 1:
164203
time.sleep(retry_after if retry_after is not None else RETRY_BACKOFF_S[attempt])
165-
alive, reason = classify(url, status, final)
204+
alive, reason = classify(
205+
url, status, final, automation_challenge=automation_challenge
206+
)
166207
return CheckResult(url, status, final, alive, reason)
167208

168209

@@ -247,13 +288,20 @@ def _task(url: str) -> CheckResult:
247288
# --- cache -----------------------------------------------------------------------
248289

249290

291+
def is_automation_challenge(entry: dict[str, Any]) -> bool:
292+
"""Whether a cached result reached an explicit anti-bot challenge."""
293+
return entry.get("reason") == AUTOMATION_CHALLENGE_REASON
294+
295+
250296
def load_cache(path: Path = URL_CACHE_PATH) -> dict[str, dict[str, Any]]:
251297
"""Load the cache, dropping rate-limit answers written by older runs.
252298
253299
A 429/503 is not a verdict, so an entry holding one is not a cache hit —
254300
it is a URL we still have to check. Filtering on load heals a cache that a
255301
previous run poisoned (3,998 GSMArena pages were parked as dead this way,
256-
all of which answer 200 when asked at a civil pace).
302+
all of which answer 200 when asked at a civil pace). Explicit anti-bot
303+
challenges stay cached as *indeterminate*: the promotion layer can use that
304+
reachability signal only for an already-authoritative cited host.
257305
"""
258306
return {
259307
e["url"]: e
@@ -292,14 +340,14 @@ def result_to_entry(r: CheckResult, ts: str) -> dict[str, Any]:
292340

293341

294342
def record_liveness(source_urls: list[str], cache: dict[str, dict[str, Any]]) -> tuple[int, int]:
295-
"""(#live, #dead) for a record's URLs that are present in the cache."""
343+
"""(#live, #dead) for cached URLs, excluding anti-bot indeterminate results."""
296344
live = dead = 0
297345
for u in source_urls:
298346
e = cache.get(u)
299347
if e is None:
300348
continue
301349
if e.get("alive"):
302350
live += 1
303-
else:
351+
elif not is_automation_challenge(e):
304352
dead += 1
305353
return live, dead

‎app/verify/promote.py‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pathlib import Path
2020
from typing import Any, NamedTuple
2121

22-
from . import hosts
22+
from . import hosts, http_check
2323
from .common import STATE_DIR
2424

2525
CROSSREF_CACHE_PATH = STATE_DIR / "crossref_cache.jsonl"
@@ -36,16 +36,27 @@ class PromotionDecision(NamedTuple):
3636
def has_live_authoritative_source(
3737
source_urls: list[str], url_cache: dict[str, dict[str, Any]]
3838
) -> bool:
39-
"""True if some cited URL is an authoritative host (Tier 1 *or* Tier 2) AND
40-
confirmed alive. The green band already requires a T1/T2 source + completeness
41-
+ consistency; this just adds "and that source actually resolves". Requiring a
42-
*manufacturer/encyclopaedia* (T1 only) was too strict — it never promoted the
43-
many green records sourced from reputable spec/benchmark DBs (gsmarena,
44-
cpubenchmark, ...), so verified never moved off its floor.
39+
"""True if an authoritative cited URL has a usable liveness signal.
40+
41+
A normal alive response always qualifies. An explicit anti-bot challenge also
42+
qualifies, but only for an already classified Tier-1/Tier-2 host: it proves
43+
the request reached that host while the gateway withheld page inspection.
44+
This preserves the live-source gate without treating every 403 as a live
45+
record. Generic errors, ordinary 401/403, redirects to a homepage, and
46+
unclassified hosts remain insufficient for promotion.
47+
48+
This distinction is deliberate. The September 2026 probe found Geekbench's
49+
Cloudflare challenge under the verifier User-Agent, while several other
50+
Tier-2 databases returned normal 200 responses. Treating challenge responses
51+
as dead made a host-wide automation policy look like thousands of dead
52+
citations; dropping the liveness gate altogether made unchecked citations
53+
promotable. This narrow fallback avoids both failure modes.
4554
"""
4655
for u in source_urls:
4756
entry = url_cache.get(u)
48-
if entry and entry.get("alive") and hosts.tier_of_host(hosts.host_of(u)) in (1, 2):
57+
if not entry or hosts.tier_of_host(hosts.host_of(u)) not in (1, 2):
58+
continue
59+
if entry.get("alive") or http_check.is_automation_challenge(entry):
4960
return True
5061
return False
5162

‎tests/verify/test_http_check.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ def test_record_liveness():
9292
assert http_check.record_liveness(["a", "b", "c", "missing"], cache) == (2, 1)
9393

9494

95+
def test_record_liveness_excludes_automation_challenge():
96+
cache = {
97+
"blocked": {"alive": False, "reason": http_check.AUTOMATION_CHALLENGE_REASON},
98+
"dead": {"alive": False, "reason": "http-404"},
99+
}
100+
assert http_check.record_liveness(["blocked", "dead"], cache) == (0, 1)
101+
102+
95103
def test_cache_roundtrip():
96104
# tmp_path fixture is unreliable on this Windows runner; use a local scratch file.
97105
from pathlib import Path
@@ -115,6 +123,16 @@ def __init__(self, url, retry_after=None):
115123
self.headers = {"Retry-After": retry_after} if retry_after else {}
116124

117125

126+
class _Http403Challenge(Exception):
127+
"""urllib-shaped Cloudflare bot challenge observed from Geekbench."""
128+
129+
def __init__(self, url):
130+
super().__init__("Forbidden")
131+
self.code = 403
132+
self.url = url
133+
self.headers = {"CF-Mitigated": "challenge"}
134+
135+
118136
class FlakyOpener(FakeOpener):
119137
"""Rate-limits the first ``fail_times`` calls, then answers normally."""
120138

@@ -146,6 +164,27 @@ def test_persistent_rate_limit_is_transient_not_dead(monkeypatch):
146164
assert res.status == 429 and res.transient # caller must not cache this as a verdict
147165

148166

167+
def test_cloudflare_bot_challenge_is_indeterminate_not_dead():
168+
url = "https://browser.geekbench.com/v6/cpu/1"
169+
op = FakeOpener({url: _Http403Challenge(url)})
170+
res = http_check.check_one(url, op)
171+
assert res.status == 403
172+
assert not res.alive
173+
assert res.indeterminate
174+
assert not res.transient
175+
assert res.reason == http_check.AUTOMATION_CHALLENGE_REASON
176+
assert [method for _url, method in op.calls] == ["HEAD", "GET"]
177+
178+
179+
def test_generic_403_stays_dead():
180+
err = type("E", (Exception,), {"code": 403, "url": None, "headers": {}})()
181+
url = "https://example.com/private"
182+
res = http_check.check_one(url, FakeOpener({url: err}))
183+
assert not res.alive
184+
assert not res.indeterminate
185+
assert res.reason == "http-403"
186+
187+
149188
def test_rate_limit_slows_the_host_down(monkeypatch):
150189
monkeypatch.setattr(http_check.time, "sleep", lambda _s: None)
151190
url = "https://www.gsmarena.com/z-3.php"

‎tests/verify/test_promote_crossref.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,40 @@ def test_green_does_not_promote_with_dead_authoritative_source():
141141
assert not d.promote
142142

143143

144+
def test_green_promotes_with_authoritative_automation_challenge():
145+
url = "https://browser.geekbench.com/v6/cpu/1"
146+
d = promote.decide(
147+
band="green",
148+
source_urls=[url],
149+
url_cache={
150+
url: {
151+
"alive": False,
152+
"status": 403,
153+
"reason": "automation-challenge",
154+
}
155+
},
156+
crossref_decision=None,
157+
)
158+
assert d.promote and d.reason == "green-live-source"
159+
160+
161+
def test_green_does_not_promote_with_unclassified_automation_challenge():
162+
url = "https://example.com/specs/1"
163+
d = promote.decide(
164+
band="green",
165+
source_urls=[url],
166+
url_cache={
167+
url: {
168+
"alive": False,
169+
"status": 403,
170+
"reason": "automation-challenge",
171+
}
172+
},
173+
crossref_decision=None,
174+
)
175+
assert not d.promote
176+
177+
144178
def test_yellow_without_confirm_holds():
145179
d = promote.decide(band="yellow", source_urls=[], url_cache={}, crossref_decision=None)
146180
assert not d.promote

0 commit comments

Comments
 (0)