rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
f9437bb
·
1 Parent(s): 1cc997b

fix(link-rot): validate PDF candidates + untrack llm_health.json

Browse files

THE INCIDENT
Overnight 2026-05-27/28 the nightly link-rot LaunchAgent
(com.rohit.insurancebot.linkrot → tools/check_link_rot.py) applied
seven bad auto-patches to 40-data/corpus_urls.md and one to
tools/.pdf_etag_state.json:

- HDFC ERGO ×3 + Bajaj Allianz ×2 + New India ×1: rewrote https→http
via try_canonicalise — both work but the downgrade is gratuitous.
- Niva Bupa Health Companion (a core indemnity product): rewrote the
live URL to a 2-year-old Wayback wrapper that serves HTML, not PDF.
Would have silently broken corpus rebuild for that policy.

ROOT CAUSE
head_check() did HEAD only; the affected origins (HDFC ERGO, Bajaj,
New India, Niva Bupa) return text/html interstitials or transient
errors for HEAD while serving the real PDF on GET. When HEAD failed
transiently, the tool fell into auto_fix and both strategies (try_
canonicalise + try_wayback) accepted any candidate whose HEAD returned
status 2xx — without verifying it ACTUALLY served a PDF. The Wayback
'available' API returns the HTML-wrapped /web/<ts>/ URL by default,
which is 200 OK but text/html.

THE FIX
1. New _validate_pdf_candidate(url): GET Range bytes=0-31, accept only
if status ∈ {200,206} AND (body starts with %PDF OR content-type
contains 'pdf'). Some CDNs send octet-stream for PDFs, so the magic
check is the strict gate; content-type is the loose one.
2. New _looks_like_pdf_url(url): detects .pdf paths and Wayback-wrapped
.pdf originals — only those URLs require PDF validation (HTML
review URLs etc. still flow through unchanged).
3. New _wayback_raw_variant(url): rewrites
https://web.archive.org/web/<ts>/<orig> →
https://web.archive.org/web/<ts>id_/<orig> so the archive serves
the original PDF bytes (no HTML wrapper).
4. try_canonicalise now requires expected-PDF candidates to pass
_validate_pdf_candidate before being returned as a replacement.
5. try_wayback now rewrites to id_ form AND validates before returning.

VERIFIED on the exact problem URLs (live probes):
Niva Bupa LIVE True ← no fallback needed
Niva Bupa Wayback wrap False ← the bad fallback the old code took
Niva Bupa Wayback id_ True ← the new fallback works
HDFC ERGO https True
HDFC ERGO http True (both work; canonicalise no longer
chosen unless https first fails)
NewIndia https True
NewIndia http False ← would be rejected as bad fallback

ALSO: untrack 40-data/llm_health.json
The file is process-local probe state, written on every probe tick.
Local-dev probebacks (e.g. SSL CERTIFICATE_VERIFY_FAILED rows from
running outside the HF Space) would otherwise stage 100s of stale
'down' lines that overwrite the live snapshot on next push. Backend
tolerates missing (llm_health.py:389) and rebuilds chains/candidates
on first probe after startup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (3) hide show
  1. .gitignore +6 -0
  2. 40-data/llm_health.json +0 -381
  3. tools/check_link_rot.py +78 -7
.gitignore CHANGED
@@ -91,6 +91,12 @@ README.pdf
91
  # Runtime + heavy binary: never commit
92
  40-data/policies/
93
  40-data/sessions/
 
 
 
 
 
 
94
  80-audit/full_*/
95
  80-audit/postfix_*/
96
  MUST_FIX.md
 
91
  # Runtime + heavy binary: never commit
92
  40-data/policies/
93
  40-data/sessions/
94
+ # Process-local LLM probe snapshot — written by backend/llm_health.py on
95
+ # every probe tick. Local-dev probebacks would otherwise stage hundreds
96
+ # of stale "down" rows that overwrite the HF Space's live snapshot. The
97
+ # backend tolerates missing (llm_health.py:389) and rebuilds
98
+ # chains/candidates/recent_turns on first probe after startup.
99
+ 40-data/llm_health.json
100
  80-audit/full_*/
101
  80-audit/postfix_*/
102
  MUST_FIX.md
40-data/llm_health.json DELETED
@@ -1,381 +0,0 @@
1
- {
2
- "updated_at": "2026-05-16T20:23:07Z",
3
- "models": {
4
- "qwen/qwen3-next-80b-a3b-instruct": {
5
- "model": "qwen/qwen3-next-80b-a3b-instruct",
6
- "status": "healthy",
7
- "last_success_at": "2026-05-16T20:23:07Z",
8
- "last_failure_at": "2026-05-16T17:55:07Z",
9
- "last_error": null,
10
- "last_status_code": null,
11
- "latency_ms": 907,
12
- "consecutive_failures": 0,
13
- "tested_at": "2026-05-16T20:23:07Z",
14
- "probe_history": [
15
- {
16
- "ok": true,
17
- "latency_ms": 1145,
18
- "ts": "2026-05-16T20:00:14Z",
19
- "src": "probe"
20
- },
21
- {
22
- "ok": true,
23
- "latency_ms": 658,
24
- "ts": "2026-05-16T20:06:08Z",
25
- "src": "probe"
26
- },
27
- {
28
- "ok": true,
29
- "latency_ms": 1416,
30
- "ts": "2026-05-16T20:11:26Z",
31
- "src": "probe"
32
- },
33
- {
34
- "ok": true,
35
- "latency_ms": 499,
36
- "ts": "2026-05-16T20:17:37Z",
37
- "src": "probe"
38
- },
39
- {
40
- "ok": true,
41
- "latency_ms": 907,
42
- "ts": "2026-05-16T20:23:07Z",
43
- "src": "probe"
44
- }
45
- ],
46
- "degraded_until_monotonic": 0.0,
47
- "credits_remaining": null,
48
- "credits_unit": null,
49
- "credits_reset_at": null,
50
- "credits_observed_at": null,
51
- "credits_low_water": 0.0
52
- },
53
- "qwen/qwen3.5-122b-a10b": {
54
- "model": "qwen/qwen3.5-122b-a10b",
55
- "status": "down",
56
- "last_success_at": null,
57
- "last_failure_at": "2026-05-14T16:35:39Z",
58
- "last_error": "timeout",
59
- "last_status_code": null,
60
- "latency_ms": null,
61
- "consecutive_failures": 5,
62
- "tested_at": "2026-05-14T16:35:39Z",
63
- "probe_history": [],
64
- "degraded_until_monotonic": 0.0,
65
- "credits_remaining": null,
66
- "credits_unit": null,
67
- "credits_reset_at": null,
68
- "credits_observed_at": null,
69
- "credits_low_water": 0.0
70
- },
71
- "openai/gpt-oss-120b": {
72
- "model": "openai/gpt-oss-120b",
73
- "status": "down",
74
- "last_success_at": null,
75
- "last_failure_at": "2026-05-14T16:35:39Z",
76
- "last_error": "empty_content",
77
- "last_status_code": null,
78
- "latency_ms": null,
79
- "consecutive_failures": 5,
80
- "tested_at": "2026-05-14T16:35:39Z",
81
- "probe_history": [],
82
- "degraded_until_monotonic": 0.0,
83
- "credits_remaining": null,
84
- "credits_unit": null,
85
- "credits_reset_at": null,
86
- "credits_observed_at": null,
87
- "credits_low_water": 0.0
88
- },
89
- "mistralai/mistral-large-3-675b-instruct-2512": {
90
- "model": "mistralai/mistral-large-3-675b-instruct-2512",
91
- "status": "down",
92
- "last_success_at": "2026-05-16T19:28:36Z",
93
- "last_failure_at": "2026-05-16T20:23:07Z",
94
- "last_error": "timeout",
95
- "last_status_code": null,
96
- "latency_ms": 5119,
97
- "consecutive_failures": 9,
98
- "tested_at": "2026-05-16T20:23:07Z",
99
- "probe_history": [
100
- {
101
- "ok": false,
102
- "latency_ms": 8004,
103
- "ts": "2026-05-16T20:00:14Z",
104
- "src": "probe"
105
- },
106
- {
107
- "ok": false,
108
- "latency_ms": 42670,
109
- "ts": "2026-05-16T20:06:08Z",
110
- "src": "probe"
111
- },
112
- {
113
- "ok": false,
114
- "latency_ms": 8009,
115
- "ts": "2026-05-16T20:11:26Z",
116
- "src": "probe"
117
- },
118
- {
119
- "ok": false,
120
- "latency_ms": 8003,
121
- "ts": "2026-05-16T20:17:37Z",
122
- "src": "probe"
123
- },
124
- {
125
- "ok": false,
126
- "latency_ms": 7997,
127
- "ts": "2026-05-16T20:23:07Z",
128
- "src": "probe"
129
- }
130
- ],
131
- "degraded_until_monotonic": 0.0,
132
- "credits_remaining": null,
133
- "credits_unit": null,
134
- "credits_reset_at": null,
135
- "credits_observed_at": null,
136
- "credits_low_water": 0.0
137
- },
138
- "nvidia/llama-3.3-nemotron-super-49b-v1.5": {
139
- "model": "nvidia/llama-3.3-nemotron-super-49b-v1.5",
140
- "status": "healthy",
141
- "last_success_at": "2026-05-16T20:23:07Z",
142
- "last_failure_at": "2026-05-16T20:17:37Z",
143
- "last_error": null,
144
- "last_status_code": null,
145
- "latency_ms": 296,
146
- "consecutive_failures": 0,
147
- "tested_at": "2026-05-16T20:23:07Z",
148
- "probe_history": [
149
- {
150
- "ok": true,
151
- "latency_ms": 280,
152
- "ts": "2026-05-16T20:00:14Z",
153
- "src": "probe"
154
- },
155
- {
156
- "ok": true,
157
- "latency_ms": 388,
158
- "ts": "2026-05-16T20:06:08Z",
159
- "src": "probe"
160
- },
161
- {
162
- "ok": true,
163
- "latency_ms": 306,
164
- "ts": "2026-05-16T20:11:26Z",
165
- "src": "probe"
166
- },
167
- {
168
- "ok": false,
169
- "latency_ms": 62582,
170
- "ts": "2026-05-16T20:17:37Z",
171
- "src": "probe"
172
- },
173
- {
174
- "ok": true,
175
- "latency_ms": 296,
176
- "ts": "2026-05-16T20:23:07Z",
177
- "src": "probe"
178
- }
179
- ],
180
- "degraded_until_monotonic": 0.0,
181
- "credits_remaining": null,
182
- "credits_unit": null,
183
- "credits_reset_at": null,
184
- "credits_observed_at": null,
185
- "credits_low_water": 0.0
186
- },
187
- "meta/llama-3.3-70b-instruct": {
188
- "model": "meta/llama-3.3-70b-instruct",
189
- "status": "healthy",
190
- "last_success_at": "2026-05-14T16:35:39Z",
191
- "last_failure_at": "2026-05-14T15:58:40Z",
192
- "last_error": null,
193
- "last_status_code": null,
194
- "latency_ms": 479,
195
- "consecutive_failures": 0,
196
- "tested_at": "2026-05-14T16:35:39Z",
197
- "probe_history": [],
198
- "degraded_until_monotonic": 0.0,
199
- "credits_remaining": null,
200
- "credits_unit": null,
201
- "credits_reset_at": null,
202
- "credits_observed_at": null,
203
- "credits_low_water": 0.0
204
- },
205
- "deepseek-ai/deepseek-v4-pro": {
206
- "model": "deepseek-ai/deepseek-v4-pro",
207
- "status": "down",
208
- "last_success_at": null,
209
- "last_failure_at": "2026-05-14T16:35:39Z",
210
- "last_error": "timeout",
211
- "last_status_code": null,
212
- "latency_ms": null,
213
- "consecutive_failures": 5,
214
- "tested_at": "2026-05-14T16:35:39Z",
215
- "probe_history": [],
216
- "degraded_until_monotonic": 0.0,
217
- "credits_remaining": null,
218
- "credits_unit": null,
219
- "credits_reset_at": null,
220
- "credits_observed_at": null,
221
- "credits_low_water": 0.0
222
- },
223
- "nvidia/nemotron-3-nano-30b-a3b": {
224
- "model": "nvidia/nemotron-3-nano-30b-a3b",
225
- "status": "down",
226
- "last_success_at": null,
227
- "last_failure_at": "2026-05-14T16:35:39Z",
228
- "last_error": "empty_content",
229
- "last_status_code": null,
230
- "latency_ms": null,
231
- "consecutive_failures": 5,
232
- "tested_at": "2026-05-14T16:35:39Z",
233
- "probe_history": [],
234
- "degraded_until_monotonic": 0.0,
235
- "credits_remaining": null,
236
- "credits_unit": null,
237
- "credits_reset_at": null,
238
- "credits_observed_at": null,
239
- "credits_low_water": 0.0
240
- },
241
- "deepseek-ai/deepseek-v4-flash": {
242
- "model": "deepseek-ai/deepseek-v4-flash",
243
- "status": "down",
244
- "last_success_at": null,
245
- "last_failure_at": "2026-05-14T16:35:39Z",
246
- "last_error": "timeout",
247
- "last_status_code": null,
248
- "latency_ms": null,
249
- "consecutive_failures": 5,
250
- "tested_at": "2026-05-14T16:35:39Z",
251
- "probe_history": [],
252
- "degraded_until_monotonic": 0.0,
253
- "credits_remaining": null,
254
- "credits_unit": null,
255
- "credits_reset_at": null,
256
- "credits_observed_at": null,
257
- "credits_low_water": 0.0
258
- },
259
- "moonshotai/kimi-k2-instruct-0905": {
260
- "model": "moonshotai/kimi-k2-instruct-0905",
261
- "status": "down",
262
- "last_success_at": null,
263
- "last_failure_at": "2026-05-14T16:35:39Z",
264
- "last_error": "http_404",
265
- "last_status_code": null,
266
- "latency_ms": null,
267
- "consecutive_failures": 5,
268
- "tested_at": "2026-05-14T16:35:39Z",
269
- "probe_history": [],
270
- "degraded_until_monotonic": 0.0,
271
- "credits_remaining": null,
272
- "credits_unit": null,
273
- "credits_reset_at": null,
274
- "credits_observed_at": null,
275
- "credits_low_water": 0.0
276
- },
277
- "minimaxai/minimax-m2.5": {
278
- "model": "minimaxai/minimax-m2.5",
279
- "status": "down",
280
- "last_success_at": null,
281
- "last_failure_at": "2026-05-14T16:35:39Z",
282
- "last_error": "http_410",
283
- "last_status_code": null,
284
- "latency_ms": null,
285
- "consecutive_failures": 5,
286
- "tested_at": "2026-05-14T16:35:39Z",
287
- "probe_history": [],
288
- "degraded_until_monotonic": 0.0,
289
- "credits_remaining": null,
290
- "credits_unit": null,
291
- "credits_reset_at": null,
292
- "credits_observed_at": null,
293
- "credits_low_water": 0.0
294
- },
295
- "meta/llama-4-maverick-17b-128e-instruct": {
296
- "model": "meta/llama-4-maverick-17b-128e-instruct",
297
- "status": "healthy",
298
- "last_success_at": "2026-05-16T20:23:07Z",
299
- "last_failure_at": "2026-05-16T14:04:03Z",
300
- "last_error": null,
301
- "last_status_code": null,
302
- "latency_ms": 1302,
303
- "consecutive_failures": 0,
304
- "tested_at": "2026-05-16T20:23:07Z",
305
- "probe_history": [
306
- {
307
- "ok": true,
308
- "latency_ms": 1555,
309
- "ts": "2026-05-16T20:00:14Z",
310
- "src": "probe"
311
- },
312
- {
313
- "ok": true,
314
- "latency_ms": 969,
315
- "ts": "2026-05-16T20:06:08Z",
316
- "src": "probe"
317
- },
318
- {
319
- "ok": true,
320
- "latency_ms": 900,
321
- "ts": "2026-05-16T20:11:26Z",
322
- "src": "probe"
323
- },
324
- {
325
- "ok": true,
326
- "latency_ms": 564,
327
- "ts": "2026-05-16T20:17:37Z",
328
- "src": "probe"
329
- },
330
- {
331
- "ok": true,
332
- "latency_ms": 1302,
333
- "ts": "2026-05-16T20:23:07Z",
334
- "src": "probe"
335
- }
336
- ],
337
- "degraded_until_monotonic": 0.0,
338
- "credits_remaining": null,
339
- "credits_unit": null,
340
- "credits_reset_at": null,
341
- "credits_observed_at": null,
342
- "credits_low_water": 0.0
343
- },
344
- "openrouter:openai/gpt-oss-120b": {
345
- "model": "openrouter:openai/gpt-oss-120b",
346
- "status": "down",
347
- "last_success_at": null,
348
- "last_failure_at": "2026-05-14T16:35:39Z",
349
- "last_error": "empty_content",
350
- "last_status_code": null,
351
- "latency_ms": null,
352
- "consecutive_failures": 4,
353
- "tested_at": "2026-05-14T16:35:39Z",
354
- "probe_history": [],
355
- "degraded_until_monotonic": 0.0,
356
- "credits_remaining": 32.802310819,
357
- "credits_unit": "usd_balance",
358
- "credits_reset_at": null,
359
- "credits_observed_at": 97811.656528458,
360
- "credits_low_water": 0.05
361
- },
362
- "groq:llama-3.3-70b-versatile": {
363
- "model": "groq:llama-3.3-70b-versatile",
364
- "status": "healthy",
365
- "last_success_at": "2026-05-14T16:35:39Z",
366
- "last_failure_at": null,
367
- "last_error": null,
368
- "last_status_code": null,
369
- "latency_ms": 200,
370
- "consecutive_failures": 0,
371
- "tested_at": "2026-05-14T16:35:39Z",
372
- "probe_history": [],
373
- "degraded_until_monotonic": 0.0,
374
- "credits_remaining": null,
375
- "credits_unit": null,
376
- "credits_reset_at": null,
377
- "credits_observed_at": null,
378
- "credits_low_water": 0.0
379
- }
380
- }
381
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/check_link_rot.py CHANGED
@@ -163,8 +163,63 @@ def head_check(url: str, client: httpx.Client) -> tuple[int, str]:
163
  # ---------- auto-fix strategies ----------
164
 
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  def try_wayback(url: str, client: httpx.Client) -> str | None:
167
- """Return a working Wayback Machine snapshot URL, or None."""
 
 
 
 
 
168
  try:
169
  r = client.get(
170
  "https://archive.org/wayback/available",
@@ -175,15 +230,27 @@ def try_wayback(url: str, client: httpx.Client) -> str | None:
175
  if r.status_code != 200:
176
  return None
177
  snap = r.json().get("archived_snapshots", {}).get("closest", {})
178
- if snap.get("available") and snap.get("status", "").startswith("2"):
179
- return snap.get("url")
 
 
 
 
 
 
 
 
180
  except (httpx.HTTPError, ValueError):
181
  return None
182
- return None
183
 
184
 
185
  def try_canonicalise(url: str, client: httpx.Client) -> str | None:
186
- """Strip query strings, flip http<->https."""
 
 
 
 
 
187
  candidates = []
188
  if "?" in url:
189
  candidates.append(url.split("?", 1)[0])
@@ -191,10 +258,14 @@ def try_canonicalise(url: str, client: httpx.Client) -> str | None:
191
  candidates.append("https://" + url[len("http://") :])
192
  elif url.startswith("https://"):
193
  candidates.append("http://" + url[len("https://") :])
 
194
  for c in candidates:
195
  s, _ = head_check(c, client)
196
- if 200 <= s < 400:
197
- return c
 
 
 
198
  return None
199
 
200
 
 
163
  # ---------- auto-fix strategies ----------
164
 
165
 
166
+ def _looks_like_pdf_url(url: str) -> bool:
167
+ """Heuristic: corpus expects a PDF at this URL."""
168
+ # Direct .pdf paths
169
+ if ".pdf" in url.lower().split("?", 1)[0]:
170
+ return True
171
+ # Wayback-wrapped originals: https://web.archive.org/web/<ts>/<original>
172
+ if "web.archive.org/web/" in url and ".pdf" in url.lower():
173
+ return True
174
+ return False
175
+
176
+
177
+ def _validate_pdf_candidate(url: str, client: httpx.Client) -> bool:
178
+ """Confirm a candidate URL ACTUALLY serves a PDF (Bug #X 2026-05-28).
179
+
180
+ Background: HEAD on the same path can return 200 with text/html (server
181
+ interstitial) while GET returns 200 with %PDF — and vice versa on
182
+ transient failures. The previous auto-fix accepted any candidate that
183
+ returned 200 to a HEAD, which silently downgraded https→http and
184
+ swapped live URLs for HTML-only Wayback wrappers. Validate by
185
+ GET-Range and check the file magic.
186
+ """
187
+ try:
188
+ r = client.get(
189
+ url,
190
+ headers={**HEADERS, "Range": "bytes=0-31"},
191
+ follow_redirects=True,
192
+ timeout=15,
193
+ )
194
+ if r.status_code not in (200, 206):
195
+ return False
196
+ ct = (r.headers.get("Content-Type") or "").lower()
197
+ body = r.content[:4]
198
+ # Accept either explicit PDF content-type OR %PDF magic — some
199
+ # CDNs send octet-stream / generic types for PDF assets.
200
+ return body == b"%PDF" or "pdf" in ct
201
+ except (httpx.HTTPError, ValueError):
202
+ return False
203
+
204
+
205
+ def _wayback_raw_variant(url: str) -> str:
206
+ """Rewrite a Wayback snapshot URL to its `id_` raw variant so the
207
+ archive serves the original PDF bytes instead of the HTML wrapper.
208
+ https://web.archive.org/web/<ts>/<orig> -->
209
+ https://web.archive.org/web/<ts>id_/<orig>"""
210
+ m = re.match(r"^(https?://web\.archive\.org/web/\d+)(/.+)$", url)
211
+ if not m:
212
+ return url
213
+ return f"{m.group(1)}id_{m.group(2)}"
214
+
215
+
216
  def try_wayback(url: str, client: httpx.Client) -> str | None:
217
+ """Return a working Wayback Machine snapshot URL, or None.
218
+
219
+ For PDF URLs, rewrite to the `id_` raw variant and verify the
220
+ snapshot actually serves the PDF bytes (some snapshots are HTML
221
+ error pages with status 200).
222
+ """
223
  try:
224
  r = client.get(
225
  "https://archive.org/wayback/available",
 
230
  if r.status_code != 200:
231
  return None
232
  snap = r.json().get("archived_snapshots", {}).get("closest", {})
233
+ if not (snap.get("available") and snap.get("status", "").startswith("2")):
234
+ return None
235
+ candidate = snap.get("url")
236
+ if not candidate:
237
+ return None
238
+ if _looks_like_pdf_url(url):
239
+ candidate = _wayback_raw_variant(candidate)
240
+ if not _validate_pdf_candidate(candidate, client):
241
+ return None
242
+ return candidate
243
  except (httpx.HTTPError, ValueError):
244
  return None
 
245
 
246
 
247
  def try_canonicalise(url: str, client: httpx.Client) -> str | None:
248
+ """Strip query strings, flip http<->https.
249
+
250
+ For PDF URLs, the candidate MUST serve actual PDF bytes (not just
251
+ return HTTP 200) — HEAD alone is too credulous on servers that
252
+ answer interstitial HTML for HEAD but real PDF for GET.
253
+ """
254
  candidates = []
255
  if "?" in url:
256
  candidates.append(url.split("?", 1)[0])
 
258
  candidates.append("https://" + url[len("http://") :])
259
  elif url.startswith("https://"):
260
  candidates.append("http://" + url[len("https://") :])
261
+ expect_pdf = _looks_like_pdf_url(url)
262
  for c in candidates:
263
  s, _ = head_check(c, client)
264
+ if not (200 <= s < 400):
265
+ continue
266
+ if expect_pdf and not _validate_pdf_candidate(c, client):
267
+ continue
268
+ return c
269
  return None
270
 
271