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.
3838TRANSIENT_STATUSES = frozenset ({429 , 503 })
39+ AUTOMATION_CHALLENGE_REASON = "automation-challenge"
3940RETRY_ATTEMPTS = 3
4041RETRY_BACKOFF_S = (2.0 , 6.0 )
4142MAX_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
148186def 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+
250296def 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
294342def 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
0 commit comments