Repository navigation
Expand file tree
/
Copy pathrun.py
More file actions
1715 lines (1591 loc) · 90.8 KB
/
Copy pathrun.py
File metadata and controls
1715 lines (1591 loc) · 90.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""checker — a manually run check script. Usage and verdict semantics live in SKILL.md at the skill root.
Item definitions live in references/checklist/checklist.md (**the single source of truth**);
this script implements its machine items C1-C20, C23-C30; C21 is a human-review item,
listed in the report but never judged; C22's target-URL status part is machine-run, the
reciprocity judgment stays human.
The two are aligned by verify_checklist_sync() on every start — check logic can't be
auto-generated (each one is hand-written), but "which items exist, what priority, which
section" must match; on mismatch it shouts on stdout.
python3 scripts/run.py # config.TARGET empty → run every site in sites.yaml
python3 scripts/run.py --site example-com # run just one site from sites.yaml
python3 scripts/run.py --target http://localhost:3000 # single-site override (local mode auto-detected)
Writes nothing inside the package (this checker gets copied into the skill; a skill
update = whole-package overwrite). Two external directories:
<config-dir> default ~/.config/seo-ops — config (what you feed the tool)
├── sites.yaml — site roster (multi-site only)
└── .env — API keys (already-exported env vars win)
<state-dir> default ~/Documents/seo-ops — outputs (what the tool emits), for humans
└── out/report-<site>-<date>.md — a form isomorphic to the checklist (three sections; result + evidence)
out/checks.db — checks snapshots (SQLite, schema in SKILL.md)
The boundary is config vs output; also, Documents is often cloud-synced, so keys must not live there.
Three invariants:
1. **Three-state verdicts** pass / fail / N.A.(reason) — "not tested" and "nothing wrong"
must never blur into one green.
2. **Purely deterministic, zero LLM** — fetch → regex/json parsing → threshold comparison →
markdown assembly. Two runs on the same site and config must be byte-identical; that is
what lets it serve as acceptance evidence and be argued over with contractors.
3. **Crawler's viewpoint** — no cookies stored, no Accept-Language sent. The checker must
see what Googlebot sees, not what "a user who already browsed around" sees (see references/C26.md).
Reads only the live HTTP/HTML output, stack-agnostic; real browser UA (Cloudflare blocks
fake crawler UAs). Sequential by default (config.FETCH_CONCURRENCY = 1); concurrency is a
reserved seam — redo the concurrency output-diff before enabling it.
"""
import argparse, collections, json, re, sqlite3, sys, threading, time, traceback, unicodedata
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from http.cookiejar import DefaultCookiePolicy
from pathlib import Path
from urllib.parse import urlparse, urljoin
import requests, yaml
# Once installed as a skill this directory may be read-only, and "zero writes" is a promise
# we made to users — yet import writes __pycache__ next door as a side effect. Verified:
# installing into ~/.claude/skills/ did produce it. Bytecode caching has no perceptible
# benefit for this network-I/O-bound script; turn it off and the promise holds.
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parent))
import config as CFG
def _resolve_paths():
"""Locate the data root — i.e. **the directory that contains checklist/checklist.md**;
`ai-crawlers.yaml` and `sites.example.yaml` live under it too.
Why probe instead of hard-coding: this script has lived through three layouts
(tools/check inside the monorepo, a standalone repo root, and now the skill spec's
scripts/ + references/), and the relative position of script vs data changed every
time. Hard-code one path and every move means editing code — miss one spot and it is
a runtime crash.
skill spec : scripts/run.py + references/checklist/checklist.md
standalone repo: checker/run.py + checklist/checklist.md
monorepo : tools/check/run.py + docs/checklist/checklist.md
"""
here = Path(__file__).resolve()
cands = (here.parents[1] / "references", # skill spec (current)
here.parents[1], # standalone repo root
here.parents[2] / "docs", # monorepo
here.parents[2])
for root in cands:
md = root / "checklist" / "checklist.md"
if md.exists():
return root, md
return cands[0], cands[0] / "checklist" / "checklist.md"
ROOT, CHECKLIST_MD = _resolve_paths()
NOW = datetime.now(timezone.utc)
# ───────────────────────── fetch layer ─────────────────────────
class Fetcher:
"""Concurrent fetcher.
**Sequential by default (config.FETCH_CONCURRENCY = 1)** — `map()` then runs a plain
list comprehension, no threads. Concurrency is only a reserved seam for future
thousand-page sites; before enabling it, redo the concurrency output-diff.
Three guarantees that keep output byte-reproducible once the seam opens (the checker
is an acceptance tool — two runs must yield the same report):
1. `map()` returns in input order — concurrency changes fetch timing, never result order;
2. only one thread fetches a given URL, the rest wait for its result (per-key lock) —
never "two different snapshots of the same page";
3. one Session per thread (requests.Session is not thread-safe).
Throttle semantics: `sleep` is the interval between two requests of **a single worker**;
overall QPS ≈ workers / sleep.
"""
def __init__(self, sleep, workers):
self.local = threading.local()
self.sleep = sleep
self.workers = max(1, workers)
self.cache = {}
self.throttled = 0 # requests rejected with 429/503; >0 = this run is discounted, the report header must say so
self._extra = 0.0 # adaptive extra interval: multiplicative backoff on throttle, additive recovery on streaks (AIMD)
self._ok = 0 # recovery counter
self._lock = threading.Lock()
self._keylocks = {}
def _session(self):
s = getattr(self.local, "s", None)
if s is None:
s = requests.Session()
s.headers["User-Agent"] = CFG.UA
# Never store cookies: a crawler is a **stateless first visit** every time; it
# never carries the previous page's state. Reusing cookies makes the checker
# behave like a user — 2026-08-25, measured on the pilot site:
# cookieless first visit to /home hops twice to /cn (Chinese, Set-Cookie records
# the language); with cookies it hops once to / (English). Session reuse means
# "whichever page got fetched first" decides the whole report, and the result
# drifts with concurrency scheduling.
s.cookies.set_policy(DefaultCookiePolicy(allowed_domains=[]))
# Explicitly do not send Accept-Language (None in requests = drop the header).
# The default already omits it, but that is "right by accident" — pin it down,
# so that someone adding a default header later can't silently switch the
# viewpoint: Googlebot crawls with "no language preference"; adding this header
# turns it into a user's viewpoint (C26 measures exactly that difference).
s.headers["Accept-Language"] = None
self.local.s = s
return s
def _fetch(self, url, redirects, headers=None, attempt=0, method="GET"):
with self._lock:
extra = self._extra
time.sleep(self.sleep + extra)
try:
r = self._session().request(method, url, timeout=CFG.REQUEST_TIMEOUT,
allow_redirects=redirects, headers=headers)
out = {"status": r.status_code, "text": r.text, "headers": dict(r.headers),
"final_url": r.url, "hops": len(r.history), "err": None}
except Exception as e:
return {"status": None, "text": "", "headers": {}, "final_url": url,
"hops": 0, "err": str(e)[:120]}
if out["status"] not in CFG.THROTTLE_STATUSES:
with self._lock: # the "additive recovery" half of AIMD
if self._extra:
self._ok += 1
if self._ok >= CFG.THROTTLE_RECOVER_AFTER:
self._ok = 0
self._extra = max(0.0, self._extra - CFG.THROTTLE_BACKOFF / 2)
else:
with self._lock:
self.throttled += 1
self._ok = 0
self._extra = min(self._extra * 2 + 0.5, CFG.THROTTLE_MAX_SLEEP) # multiplicative backoff
if attempt < CFG.THROTTLE_RETRIES:
ra = out["headers"].get("Retry-After") or out["headers"].get("retry-after")
try:
wait = float(ra)
except (TypeError, ValueError):
wait = CFG.THROTTLE_BACKOFF * (2 ** attempt)
time.sleep(min(wait, 30))
return self._fetch(url, redirects, headers, attempt + 1, method)
return out
def get(self, url, redirects=True, force=False, headers=None, method="GET"):
"""Returns dict(status, text, headers, final_url, hops, err); cached.
force=True (C10's double fetch) bypasses the cache and does not write back —
that call's whole point is "fetch again and see if it changed".
method="HEAD" (C18's image weight probe) asks only for headers — never download
megabytes of image bytes just to read one Content-Length."""
if force:
return self._fetch(url, redirects, headers, method=method)
key = (url, redirects, tuple(sorted((headers or {}).items())), method)
with self._lock:
if key in self.cache:
return self.cache[key]
kl = self._keylocks.setdefault(key, threading.Lock())
with kl:
with self._lock:
if key in self.cache: # someone finished it while we waited for the lock
return self.cache[key]
out = self._fetch(url, redirects, headers, method=method)
with self._lock:
self.cache[key] = out
return out
def map(self, fn, items):
"""Run fn(item) concurrently, **returning in the original order of items**;
degrades to sequential when workers<=1 (for debugging)."""
items = list(items)
if self.workers <= 1 or len(items) <= 1:
return [fn(i) for i in items]
with ThreadPoolExecutor(max_workers=self.workers) as ex:
return list(ex.map(fn, items))
# ───────────────────────── HTML parsing helpers ─────────────────────────
def strip_text(html):
t = re.sub(r"<script.*?</script>|<style.*?</style>|<noscript.*?</noscript>",
"", html, flags=re.S | re.I)
t = re.sub(r"<[^>]+>", " ", t)
return re.sub(r"\s+", " ", t).strip()
def word_count(text):
"""Words for the C13 thin-content floor. Latin/digit runs count as one word each; CJK
has no spaces to tokenize on, so one character = one word (the same convention word
processors use). Mixed pages just sum the two."""
latin = len(re.findall(r"[A-Za-z0-9]+", text))
cjk = len(re.findall(r"[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7a3]", text))
return latin + cjk
def metas(html):
"""meta name/property → content (lower-cased keys; first wins on duplicates)."""
out = {}
for m in re.finditer(r'<meta\s+[^>]*>', html, flags=re.I):
tag = m.group(0)
k = re.search(r'(?:name|property)\s*=\s*["\']([^"\']+)["\']', tag, flags=re.I)
v = re.search(r'content\s*=\s*["\']([^"\']*)["\']', tag, flags=re.I)
if k and v:
out.setdefault(k.group(1).strip().lower(), v.group(1).strip())
return out
def ld_blocks(html):
"""[(parsed_or_None, raw)]; parsed is the json.loads result."""
out = []
for b in re.findall(r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
html, flags=re.S | re.I):
try:
out.append((json.loads(b.strip()), b))
except Exception:
out.append((None, b))
return out
def ld_types(parsed):
types = []
def walk(n):
if isinstance(n, dict):
t = n.get("@type")
if isinstance(t, str): types.append(t)
elif isinstance(t, list): types.extend(x for x in t if isinstance(x, str))
for v in n.values(): walk(v)
elif isinstance(n, list):
for v in n: walk(v)
walk(parsed)
return types
def canonical_href(html):
m = re.search(r'<link\s+[^>]*rel=["\']canonical["\'][^>]*>', html, flags=re.I)
if not m:
m2 = re.search(r'<link\s+[^>]*href=["\']([^"\']+)["\'][^>]*rel=["\']canonical["\']',
html, flags=re.I)
return m2.group(1) if m2 else None
h = re.search(r'href=["\']([^"\']+)["\']', m.group(0), flags=re.I)
return h.group(1) if h else None
def header_canonical(headers):
"""The HTTP-channel canonical: `Link: <url>; rel="canonical"`.
When it coexists with HTML's <link rel=canonical> and disagrees, the engine picks
one on its own — and control is lost (C8)."""
v = headers.get("Link") or headers.get("link") or ""
for part in v.split(","):
if re.search(r'rel\s*=\s*"?canonical"?', part, flags=re.I):
m = re.search(r"<([^>]+)>", part)
if m:
return m.group(1).strip()
return None
def norm_url(u):
"""Normalization for comparison: drop the fragment, strip the path's trailing slash (except root)."""
p = urlparse(u)
path = p.path or "/"
if path != "/" and path.endswith("/"):
path = path.rstrip("/")
return f"{p.scheme}://{p.netloc}{path}" + (f"?{p.query}" if p.query else "")
def headings(html):
return [int(m.group(1)) for m in re.finditer(r"<h([1-6])[\s>]", html, flags=re.I)]
def throttled(r):
"""429/503 = we fetched too fast, not a broken page — no verdict may ever count it as fail."""
return r["status"] in CFG.THROTTLE_STATUSES
def is_local(host):
if host in ("localhost",) or host.endswith(".local"):
return True
return bool(re.match(r"^(\d{1,3}\.){3}\d{1,3}$|^127\.|^\[?::1\]?$", host))
# ───────────────────────── result collection ─────────────────────────
PASS, FAIL, NA, HUMAN = "pass", "fail", "N.A.", "human review"
def as_items(ev):
"""Evidence is always list[str], one item per finding, **never split within an item**.
The old version flattened evidence internally with `";".join(...)` and then guessed
the structure back with `split(";")` when rendering the appendix — but evidence text
itself contains semicolons ("15/15 pages pass (heuristic; the 90% ratio verdict awaits
headless)", "/ (81/81 imgs missing dimensions; 7/81 imgs missing the alt attribute)"),
so lines got split mid-parenthesis, leaving brackets unclosed.
That is an in-band delimiter: the separator shares a channel with the data, and the
moment the data contains the same character they are indistinguishable — picking a
rarer separator only shrinks the collision odds, it doesn't fix anything.
The only real fix is to skip that round-trip: carry structure the whole way and
**join only at the final render step**.
"""
if ev is None or ev == "":
return []
return [ev] if isinstance(ev, str) else [x for x in ev if x]
class Result:
def __init__(self):
self.rows = {} # cid → (status, list[str])
self.page_rows = [] # (url, cid, status, list[str])
def set(self, cid, status, evidence=""):
self.rows[cid] = (status, as_items(evidence))
def page(self, url, cid, status, evidence=""):
self.page_rows.append((url, cid, status, as_items(evidence)))
def agg_pages(result, cid, pages_status):
"""Aggregate per-page results into an item result: any fail → fail (evidence lists
up to 5 violating pages); an empty set = N.A., never pass."""
if not pages_status:
result.set(cid, NA, "no-pages (page sample empty or every fetch failed)")
return
fails = [(u, as_items(ev)) for u, st, ev in pages_status if st == FAIL]
if fails:
# **One page = one item.** A page's multiple problems get joined inside the same
# item (in parentheses), never a new item — in the appendix one line is one page,
# so a reader sees at a glance which pages are broken.
items = [f"{u}({'; '.join(evs)})" if evs else u for u, evs in fails[:5]]
if len(fails) > 5:
items.append(f"…{len(fails)} pages total")
result.set(cid, FAIL, items)
else:
oks = [1 for _, st, _ in pages_status if st == PASS]
result.set(cid, PASS, f"{len(oks)}/{len(pages_status)} pages pass")
for u, st, ev in pages_status:
result.page(u, cid, st, ev)
# ───────────────────────── the checks ─────────────────────────
def check_site(site, f, args):
"""site: dict(id, origin, declared_host, rendering, samples[{url,ymyl,locale,pair}], sitemap)"""
R = Result()
origin = site["origin"].rstrip("/")
host = urlparse(origin).netloc
local = is_local(host.split(":")[0])
mode = "local mode" if local else "domain mode"
# ---------- precondition: is the site root reachable ----------
# No connection, no report. A mistyped domain would otherwise degrade into "a few
# reds + a pile of N.A." — which looks like a health check but tested nothing.
# **A half-true report is worse than no report.** Only connection-layer failures
# count here (status is None: DNS/timeout/refused); a 500 means "alive but broken"
# and should be tested as usual.
root = f.get(origin + "/")
if root["status"] is None:
raise RuntimeError(f"site root unreachable ({origin}/): {root['err']}")
# ---------- page sample set: sites.yaml samples + sitemap sampling ----------
sitemap_entries, sitemap_err, sitemap_shards = collect_sitemap(site, f, local, origin)
sitemap_urls = [u for u, _ in sitemap_entries]
limit = args.page_sample if args.page_sample > 0 else None # 0 = full (every page the sitemap registers)
sample_pages = [{**s, "url": origin + s["url"]} for s in site.get("samples", [])]
seen = {norm_url(p["url"]) for p in sample_pages}
for u in sitemap_urls:
if limit is not None and len(sample_pages) >= limit: break
u2 = map_host(u, site, local, origin)
if norm_url(u2) not in seen:
sample_pages.append({"url": u2})
seen.add(norm_url(u2))
def fetch_page(p): # the biggest fetch volume: in full mode = every page the sitemap registers
r = f.get(p["url"])
if r["err"] or not r["status"]:
return {**p, "fetch": r, "html": "", "text": ""}
return {**p, "fetch": r, "html": r["text"], "text": strip_text(r["text"])}
pages = f.map(fetch_page, sample_pages[:limit] if limit else sample_pages)
ok_pages = [p for p in pages if p["fetch"]["status"] == 200 and p["html"]]
thr_pages = [p for p in pages if throttled(p["fetch"])]
R.fetch_fails = [f"{p['url']} → {p['fetch']['status'] or p['fetch']['err']}"
for p in pages if p["fetch"]["status"] != 200 and not throttled(p["fetch"])]
R.thr_pages = len(thr_pages)
# ---------- C1 robots.txt × ai-crawlers.yaml ----------
crawlers = yaml.safe_load((ROOT / "ai-crawlers.yaml").read_text())["crawlers"]
all_uas = [ua for group in crawlers.values() for ua in group]
r = f.get(origin + "/robots.txt")
if r["status"] is None:
# A connection-layer failure ≠ 404. Couldn't fetch it = didn't test it; never
# report green on the "no file = allow all by default" rule.
R.set("C1", NA, f"unreachable (robots.txt fetch failed: {r['err']})")
elif r["status"] != 200:
R.set("C1", PASS, f"robots.txt {r['status']} (no file = allow all by default; "
f"but the spec expects a template-generated robots.txt — add one)")
else:
blocked = [ua for ua in all_uas if robots_blocks(r["text"], ua)]
if blocked:
R.set("C1", FAIL, f"blocked UAs: {', '.join(blocked)}")
else:
R.set("C1", PASS, f"all {len(all_uas)} UAs allowed")
# ---------- C2 sitemap ----------
if sitemap_err:
R.set("C2", FAIL, sitemap_err)
elif not sitemap_urls:
R.set("C2", FAIL, "sitemap has no entries")
else:
problems = []
problems += lastmod_problems(sitemap_entries)
over = [(s, n) for s, n in sitemap_shards if n > CFG.SITEMAP_MAX_URLS_PER_FILE]
if over:
problems.append("shard over the protocol cap (must move to index shards): " +
"; ".join(f"{s} {n} entries > {CFG.SITEMAP_MAX_URLS_PER_FILE}"
for s, n in over[:3]))
def probe(u):
# Do not follow redirects: the sitemap's semantics is "these URLs ARE the
# canonical addresses" — the **starting point** must be 200. Following would
# mean judging only the destination — an entry that 302s elsewhere would
# count as reachable (the old implementation's hole).
rr = f.get(map_host(u, site, local, origin), redirects=False)
if throttled(rr):
return None # throttling never counts as a broken entry
if rr["status"] == 200:
return None
if rr["status"] and 300 <= rr["status"] < 400:
return f"{u} → {rr['status']} redirects to {rr['headers'].get('Location', '?')} (entries should themselves be canonical)"
return f"{u} → {rr['status'] or rr['err']}"
bad = [x for x in f.map(probe, sitemap_urls[:args.sitemap_sample]) if x]
if bad:
problems += [f"sample problem: {b}" for b in bad[:5]]
R.set("C2", FAIL if problems else PASS,
problems or
f"{len(sitemap_urls)} entries; {min(len(sitemap_urls), args.sitemap_sample)} sampled reachable")
# ---------- C3 canonicalization 301 ----------
if local:
R.set("C3", NA, "need-domain (local mode)")
else:
bare = host[4:] if host.startswith("www.") else host
variants = [f"http://{bare}/", f"http://www.{bare}/",
f"https://{bare}/", f"https://www.{bare}/"]
finals, errs = set(), []
for v, rr in zip(variants, f.map(f.get, variants)):
if rr["err"]:
errs.append(f"{v} → {rr['err']}")
else:
finals.add(norm_url(rr["final_url"]))
if errs:
R.set("C3", FAIL, errs[:3])
elif len(finals) == 1:
R.set("C3", PASS, f"all four variants converge → {finals.pop()}")
else:
R.set("C3", FAIL, f"divergent destinations: {sorted(finals)}")
# ---------- C4 CWV ----------
if local:
R.set("C4", NA, "need-domain (local mode)")
elif not CFG.CRUX_API_KEY:
R.set("C4", NA, "need-crux-key (no CrUX API key in config)")
else:
R.set("C4", *crux_check(site, f))
# ---------- C5 IndexNow key ----------
key = CFG.INDEXNOW_KEYS.get(site["id"])
if not key:
R.set("C5", NA, "need-key-declaration (not registered in config.INDEXNOW_KEYS)")
else:
rr = f.get(f"{origin}/{key}.txt")
ok = rr["status"] == 200 and key in rr["text"]
R.set("C5", PASS if ok else FAIL, f"/{key}.txt → {rr['status']}")
# ---------- C6 internal outlinks free of 4xx/5xx (the part outside the sitemap) ----------
# Division of labor with C2: **C2 judges "are the URLs declared for indexing reachable"
# (inside the sitemap); C6 judges "are the links actually present on pages reachable"
# (the link graph).** They overlap on in-sitemap URLs, so here targets that hit the
# sitemap get dropped, keeping only what C2 structurally cannot see —
# stale links left in hubs, mistyped hrefs, inbound links never cleaned after retirement.
#
# 2026-08-25 also removed the orphan verdict: it concludes from "not seen", while
# incomplete crawls are the norm (throttling / caps / login state / language clusters)
# — measured on the pilot site it reported 138 orphans, root cause being C26 locking
# the crawler into the /cn cluster; adding internal links cures nothing. Dead links
# are the opposite: only what was **seen** gets reported.
crawled, _disc, capped, dead_links, crawl_thr, ext_links = crawl_links(f, origin, args.max_pages)
smset = {norm_url(map_host(u, site, local, origin)) for u in sitemap_urls} if sitemap_urls else set()
outside = [d for d in dead_links if norm_url(d[0]) not in smset]
dead_ev = bad_link_evidence(outside)
# External sample: conclusive-only. Third-party sites 429/403/timeout the checker
# routinely (bot walls, geo blocks) and none of that says the link is broken for a
# user — only a hard 404/410 may count red; the rest is listed, never judged.
ext_sample = list(ext_links.items())[:CFG.EXTERNAL_LINK_SAMPLE]
ext_dead, ext_unver = [], []
for (u, src), rr in zip(ext_sample, f.map(lambda kv: f.get(kv[0]), ext_sample)):
if rr["status"] in (404, 410):
ext_dead.append(f"external {u} → {rr['status']} (source {src})")
elif rr["status"] != 200:
ext_unver.append(u)
if ext_unver:
ext_dead_note = [f"{len(ext_unver)} external links unverifiable (non-200 but inconclusive "
f"— bot wall / geo block / transient; verify in a browser): "
+ "; ".join(ext_unver[:3])]
else:
ext_dead_note = []
partial = []
if crawl_thr:
partial.append(f"throttled ({crawl_thr} crawled pages throttled, their outlinks unparsed; "
f"rerun with fewer --workers or a larger --sleep)")
if capped:
partial.append(f"crawl-capped (crawled {crawled} pages, hit the {args.max_pages} cap)")
if outside or ext_dead:
# Found dead links are conclusive; incomplete coverage doesn't weaken that —
# there may just be more not yet crawled
R.set("C6", FAIL, dead_ev + ext_dead[:5] + ext_dead_note + partial)
elif partial:
R.set("C6", NA, partial + ["coverage incomplete, not enough to declare internal outlinks clean"])
else:
n = len(dead_links) - len(outside)
R.set("C6", PASS, [f"crawled {crawled} pages, internal outlinks outside the sitemap free of 4xx/5xx"
+ (f" (plus {n} bad links whose targets are in the sitemap — C2's business)" if n else "")]
+ ([f"{len(ext_sample)} external links sampled, no conclusive 404/410"] if ext_sample else [])
+ ext_dead_note)
# ---------- C7 llms.txt ----------
rr = f.get(origin + "/llms.txt")
if rr["status"] != 200:
R.set("C7", FAIL, f"/llms.txt → {rr['status'] or rr['err']}")
elif rr["text"].strip().startswith("<") or len(rr["text"].strip()) < 50:
R.set("C7", FAIL, "exists but not valid markdown, or near-empty")
else:
R.set("C7", PASS, f"{len(rr['text'])} chars; key-page coverage and summaries → human review (against T2)")
# ---------- C28 security response headers (site-level: these are set once at the
# server/CDN layer, so the root's headers speak for the site) ----------
if not origin.lower().startswith("https://"):
R.set("C28", NA, "need-https (security headers are judged on the https origin only)")
elif throttled(root):
R.set("C28", NA, "throttled (root fetch got 429/503)")
else:
hdrs = {k.lower(): v for k, v in root["headers"].items()}
csp = hdrs.get("content-security-policy", "")
missing = []
if "strict-transport-security" not in hdrs:
missing.append("Strict-Transport-Security")
if not csp:
missing.append("Content-Security-Policy (roll out via Content-Security-Policy-Report-Only first)")
if "nosniff" not in hdrs.get("x-content-type-options", "").lower():
missing.append("X-Content-Type-Options: nosniff")
if "x-frame-options" not in hdrs and "frame-ancestors" not in csp.lower():
missing.append("X-Frame-Options (or CSP frame-ancestors)")
if "referrer-policy" not in hdrs:
missing.append("Referrer-Policy")
R.set("C28", FAIL if missing else PASS,
[f"missing: {m}" for m in missing] or
"all five security headers present (one server/CDN config covers the whole site)")
# ---------- C29 URL hygiene (sitemap URL list, zero extra fetches) ----------
if not sitemap_urls:
R.set("C29", NA, "no sitemap URL list to scan")
else:
upper = [u for u in sitemap_urls if re.search(r"[A-Z]", urlparse(u).path)]
unders = [u for u in sitemap_urls if "_" in urlparse(u).path]
query = [u for u in sitemap_urls if urlparse(u).query]
nonascii = [u for u in sitemap_urls if any(ord(c) > 127 for c in u)]
probs = []
if upper:
probs.append(f"{len(upper)} URLs with uppercase in the path (URLs are case-sensitive — "
f"duplicate-content risk) e.g. {upper[0]}")
if unders:
probs.append(f"{len(unders)} URLs with underscores (use hyphens as word separators) e.g. {unders[0]}")
if query:
probs.append(f"{len(query)} URLs with query params (canonical addresses should be parameter-free) e.g. {query[0]}")
if nonascii:
probs.append(f"{len(nonascii)} URLs with raw non-ASCII characters (invalid per URL spec — "
f"percent-encode them; existing URLs need a 301 plan, don't rename casually) e.g. {nonascii[0]}")
R.set("C29", FAIL if probs else PASS,
probs or f"{len(sitemap_urls)} sitemap URLs all lowercase, hyphenated, ASCII, parameter-free")
# ---------- C26 no auto language redirects (site-level, sampled) ----------
probes = [{"Accept-Language": lang} for lang in CFG.LANG_REDIRECT_PROBES]
targets = [p["url"] for p in ok_pages[:CFG.LANG_REDIRECT_SAMPLE_SIZE]]
if not targets:
R.set("C26", NA, "no-pages (no testable pages)")
else:
div = []
for u in targets:
rs = [f.get(u, headers=h) for h in probes]
if len({norm_url(r["final_url"]) for r in rs}) > 1:
# no language header = the crawler's viewpoint (already fetched during page
# sampling, so this hits the cache — zero extra requests)
bot = f.get(u)
langs = [l.split(",")[0] for l in CFG.LANG_REDIRECT_PROBES]
div.append(
f"**{u}** final URL varies with Accept-Language (one URL, two behaviors — not 'two languages, two URLs'): "
+ " / ".join(f"{l}→{r['final_url']} (hop {r['hops']})" for l, r in zip(langs, rs))
+ f"; crawlers send no Accept-Language → they get {bot['final_url']} (hop {bot['hops']})"
f", the other version has no crawler-reachable URL")
R.set("C26", FAIL if div else PASS,
div[:3] or
f"sampled {len(targets)} page(s), same final URL under every Accept-Language"
f" (language differences live entirely in URLs — compliant)")
# ---------- page-level: C8-C20 ----------
declared_hosts = [urlparse(canonical_href(p["html"]) or "").netloc
for p in ok_pages if canonical_href(p["html"])]
# sorted() is not optional: on a tie, max(set(...)) picks a winner by set iteration
# order → string hashing → randomized per Python process, so the same input could
# give a different majority_host on two runs. Nothing to do with concurrency —
# single-threaded drifts too.
majority_host = max(sorted(set(declared_hosts)), key=declared_hosts.count) if declared_hosts else None
# C8 canonical (both channels: HTML + HTTP header)
st = []
for p in ok_pages:
href = canonical_href(p["html"])
hdr = header_canonical(p["fetch"]["headers"])
fu = norm_url(p["fetch"]["final_url"])
probs = []
if href and hdr and norm_url(urljoin(fu, href)) != norm_url(urljoin(fu, hdr)):
probs.append(f"channels conflict: HTML={href} / header={hdr}")
src = href or hdr
if not src:
st.append((p["url"], FAIL, "no canonical (neither the HTML nor the HTTP header channel)")); continue
cu = norm_url(urljoin(fu, src))
if local:
okc = (majority_host and urlparse(cu).netloc == majority_host
and urlparse(cu).path == urlparse(fu).path)
if not okc:
probs.append(f"canonical={cu} (self-consistency check, declared host={majority_host})")
elif cu != fu:
probs.append(f"canonical={cu} ≠ {fu}")
st.append((p["url"], FAIL if probs else PASS, probs))
agg_pages(R, "C8", st)
# C9 server-side rendering (v1 heuristic)
st = [(p["url"],
PASS if len(p["text"]) >= CFG.SSR_MIN_TEXT_CHARS else FAIL,
f"{len(p['text'])} chars" + ("" if len(p["text"]) >= CFG.SSR_MIN_TEXT_CHARS
else f" < {CFG.SSR_MIN_TEXT_CHARS}, likely CSR shell / thin content"))
for p in ok_pages]
agg_pages(R, "C9", st)
if R.rows.get("C9") and R.rows["C9"][0] == PASS:
R.set("C9", PASS, R.rows["C9"][1] + ["heuristic; the 90% ratio verdict awaits headless"])
# C10 cached public version (double-fetch diff, sampled)
st = []
dbl = ok_pages[:CFG.CACHE_DIFF_SAMPLE_SIZE]
for p, r2 in zip(dbl, f.map(lambda q: f.get(q["url"], force=True), dbl)):
if r2["status"] != 200:
st.append((p["url"], NA, f"second fetch {r2['status']}")); continue
same = r2["text"] == p["html"]
st.append((p["url"], PASS if same else FAIL,
"" if same else f"two fetches diff non-empty (len {len(p['html'])} vs {len(r2['text'])})"))
agg_pages(R, "C10", st)
# C23 indexed pages carry no noindex (self-evidencing: being in the sitemap = declared
# for indexing, so also carrying noindex is a self-contradiction)
st = []
for p in ok_pages:
mm = metas(p["html"])
hits = []
for name in CFG.NOINDEX_META_NAMES:
val = mm.get(name, "").lower()
hits += [f'meta[{name}]="{val}"' for tok in CFG.NOINDEX_TOKENS
if re.search(rf"\b{tok}\b", val)][:1]
xr = (p["fetch"]["headers"].get("X-Robots-Tag")
or p["fetch"]["headers"].get("x-robots-tag") or "").lower()
hits += [f'X-Robots-Tag="{xr}"' for tok in CFG.NOINDEX_TOKENS
if re.search(rf"\b{tok}\b", xr)][:1]
st.append((p["url"], FAIL if hits else PASS, hits))
agg_pages(R, "C23", st)
# C27 valid head: an invalid element (img/iframe/div…) makes the parser deem the head
# closed early — every meta/link/canonical after it lands in the body and is ignored.
# One misplaced tag silently voids C8/C16/C19/C22/C23's tags on that page, which is
# why this is P0 while most per-page items are not.
st = []
for p in ok_pages:
m = re.search(r"<head[^>]*>(.*?)</head>", p["html"], flags=re.S | re.I)
if not m:
st.append((p["url"], NA, "no explicit head element found")); continue
inner = re.sub(r"<!--.*?-->", "", m.group(1), flags=re.S)
# script/style/noscript/template CONTENT may legally contain markup-looking text
# (JSON, CSS hacks, inline SVG in a template) — strip whole blocks before scanning
inner = re.sub(r"<(script|style|noscript|template)\b.*?</\1\s*>", "",
inner, flags=re.S | re.I)
bad = collections.Counter(t.lower() for t in re.findall(r"<([a-zA-Z][a-zA-Z0-9-]*)", inner)
if t.lower() not in CFG.HEAD_VALID_TAGS)
st.append((p["url"], FAIL if bad else PASS,
[f"invalid element in head: <{t}> ×{n} (head parsing ends there; "
f"every tag after it is ignored)" for t, n in bad.most_common(3)]))
agg_pages(R, "C27", st)
# C11 title/description
st, titles, descs = [], {}, {}
for p in ok_pages:
m = re.search(r"<title[^>]*>(.*?)</title>", p["html"], flags=re.S | re.I)
t = re.sub(r"\s+", " ", m.group(1)).strip() if m else ""
d = metas(p["html"]).get("description", "")
probs = []
if not t: probs.append("no title")
elif len(t) > CFG.TITLE_MAX_CHARS: probs.append(f"title {len(t)} chars > {CFG.TITLE_MAX_CHARS}")
if not d: probs.append("no description")
elif len(d) > CFG.DESC_MAX_CHARS: probs.append(f"desc {len(d)} chars > {CFG.DESC_MAX_CHARS}")
if t: titles.setdefault(t, []).append(p["url"])
if d: descs.setdefault(d, []).append(p["url"])
st.append((p["url"], FAIL if probs else PASS, probs))
def dup_ev(kind, groups):
"""Duplicate evidence. The old version only wrote "title duplicated x3" — without
saying which text is duplicated a reader has nothing to act on, and it packed a
whole group into one item, so a 149-page site produced 3000+ characters and blew
up the report table.
Now: give the duplicated text itself + one example URL, truncated by the report-wide
[:N] + "…N total" convention."""
items = sorted(((txt, urls) for txt, urls in groups.items() if len(urls) > 1),
key=lambda x: -len(x[1]))
out = [f'{kind} duplicated ×{len(urls)} "{txt[:40]}{"…" if len(txt) > 40 else ""}" e.g. {urls[0]}'
for txt, urls in items[:3]]
return out + ([f"…{len(items)} duplicated {kind} groups total"] if len(items) > 3 else [])
dup = dup_ev("title", titles) + dup_ev("desc", descs)
agg_pages(R, "C11", st)
if dup:
prev = R.rows["C11"]
R.set("C11", FAIL, prev[1] + dup)
# C12 JSON-LD basics (types + base-group required fields) + negative scan
def typed_nodes(blocks):
nodes = []
def walk(n):
if isinstance(n, dict):
if n.get("@type"): nodes.append(n)
for v in n.values(): walk(v)
elif isinstance(n, list):
for v in n: walk(v)
for parsed, _ in blocks:
if parsed is not None: walk(parsed)
return nodes
def base_group_probs(nodes):
"""Base-group required fields: any one Organization node with name/url/logo/sameAs
complete, and any one WebSite node with name/url complete, satisfies it
(accommodates the @id split-block reference style)."""
orgs = [n for n in nodes if str(n.get("@type")) in CFG.ORG_TYPES
or (isinstance(n.get("@type"), list) and set(n["@type"]) & CFG.ORG_TYPES)]
wss = [n for n in nodes if n.get("@type") == "WebSite"]
probs = []
if orgs:
best = min((tuple(f for f in ("name", "url", "logo", "sameAs") if not n.get(f))
for n in orgs), key=len)
if best: probs.append("Organization missing fields: " + ",".join(best))
if wss:
best = min((tuple(f for f in ("name", "url") if not n.get(f))
for n in wss), key=len)
if best: probs.append("WebSite missing fields: " + ",".join(best))
return probs
st, rejected_hit = [], {} # rejected type → pages hit (negative scan, red on sight)
for p in ok_pages:
blocks = ld_blocks(p["html"])
if not blocks:
st.append((p["url"], FAIL, "no ld+json")); continue
parse_err = any(b[0] is None for b in blocks)
types = [t for b in blocks if b[0] for t in ld_types(b[0])]
has_org = any(t in CFG.ORG_TYPES for t in types)
has_ws = "WebSite" in types
for t in set(types) & set(CFG.LD_REJECTED_TYPES):
rejected_hit.setdefault(t, []).append(p["url"])
probs = []
if parse_err: probs.append("unparseable block")
if not has_org: probs.append("missing Organization (subtypes count)")
if not has_ws: probs.append("missing WebSite")
if not probs:
nodes = typed_nodes(blocks)
probs += base_group_probs(nodes)
for n in nodes: # checked on sight: a declared type must have its fields complete
ts = n["@type"] if isinstance(n["@type"], list) else [n["@type"]]
for t in ts:
req = CFG.TYPE_REQUIRED.get(t)
if req:
missing = [fld for fld in req if not n.get(fld)]
if missing:
probs.append(f"{t} missing fields: {','.join(missing)}")
st.append((p["url"], FAIL if probs else PASS, sorted(set(probs))))
agg_pages(R, "C12", st)
if rejected_hit:
prev = R.rows["C12"]
ev = [f"rejected type {t} ({CFG.LD_REJECTED_TYPES[t]}) on {len(us)} pages: {us[0]}"
for t, us in sorted(rejected_hit.items())]
R.set("C12", FAIL, prev[1] + ev)
# C13 empty-shell 200 + thin content (char floor catches empty shells, word floor
# catches "syntactically full, semantically empty" — 200 latin words ≈ 1200 chars,
# so the char threshold alone can't see it)
st = []
for p in ok_pages:
chars, words = len(p["text"]), word_count(p["text"])
probs = []
if chars < CFG.MIN_CONTENT_CHARS:
probs.append(f"{chars} chars < {CFG.MIN_CONTENT_CHARS} (likely empty shell)")
elif words < CFG.MIN_CONTENT_WORDS:
probs.append(f"{words} words < {CFG.MIN_CONTENT_WORDS} (thin content; add substance or noindex)")
st.append((p["url"], FAIL if probs else PASS, probs or f"{chars} chars / {words} words"))
agg_pages(R, "C13", st)
prev = R.rows["C13"]
R.set("C13", prev[0], prev[1] + ["retired sampling not wired (need-topic-queue)"])
# C14 body-hide scripts
st = []
for p in ok_pages:
hits = [pat for pat in CFG.BODY_HIDE_PATTERNS if re.search(pat, p["html"], flags=re.I)]
st.append((p["url"], FAIL if hits else PASS, hits))
agg_pages(R, "C14", st)
# C15 render cost × declaration
rendering = site.get("rendering")
if not rendering:
R.set("C15", NA, "need-declaration (no rendering strategy declared in sites.yaml)")
elif rendering in ("ssg", "isr"):
R.set("C15", PASS, f"declared {rendering}, exempt from the cache-header requirement")
else:
st = []
for p in ok_pages:
cc = p["fetch"]["headers"].get("Cache-Control", "")
ok = "s-maxage" in cc
st.append((p["url"], PASS if ok else FAIL,
f"Cache-Control: {cc or '(none)'}"))
agg_pages(R, "C15", st)
# C16 snippet meta
st = []
for p in ok_pages:
robots = metas(p["html"]).get("robots", "")
ok = "max-snippet:-1" in robots.replace(" ", "") and "max-image-preview:large" in robots.replace(" ", "")
st.append((p["url"], PASS if ok else FAIL, f"robots meta: {robots or '(none)'}"))
agg_pages(R, "C16", st)
# C24 viewport meta
st = []
for p in ok_pages:
vp = metas(p["html"]).get("viewport", "")
ok = CFG.VIEWPORT_REQUIRED_TOKEN in vp.replace(" ", "")
st.append((p["url"], PASS if ok else FAIL,
"" if ok else f"viewport: {vp or '(none)'}"))
agg_pages(R, "C24", st)
# C17 heading hierarchy
st = []
for p in ok_pages:
hs = headings(p["html"])
h1n = hs.count(1)
probs = []
if h1n != 1: probs.append(f"h1×{h1n}")
prev_l = None
for l in hs:
if prev_l is not None and l > prev_l + 1:
probs.append(f"skips h{prev_l}→h{l}"); break
prev_l = l
st.append((p["url"], FAIL if probs else PASS, probs))
agg_pages(R, "C17", st)
# C18 img attributes complete (width/height + alt)
st = []
for p in ok_pages:
imgs = re.findall(r"<img\s[^>]*>", p["html"], flags=re.I)
no_size = [i for i in imgs
if not (re.search(r"\bwidth\s*=", i, flags=re.I) and re.search(r"\bheight\s*=", i, flags=re.I))]
# Judge "is the attribute present", not "is the value empty": alt="" is the correct
# form for decorative images; a missing attribute is what leaves machines guessing
no_alt = [i for i in imgs if not re.search(r"\balt\s*=", i, flags=re.I)]
probs = []
if no_size: probs.append(f"{len(no_size)}/{len(imgs)} imgs missing dimensions")
if no_alt: probs.append(f"{len(no_alt)}/{len(imgs)} imgs missing the alt attribute")
st.append((p["url"], FAIL if probs else PASS,
probs or f"{len(imgs)} imgs have dimensions+alt"))
agg_pages(R, "C18", st)
# Weight budget (site-wide sample, HEAD only): oversized images are LCP's main lever
# (C4 measures the outcome in field data; this names the culprit files). Judged via
# Content-Length — no header = unverifiable, listed but never failed on.
img_srcs = []
for p in ok_pages:
for tag in re.findall(r"<img\s[^>]*>", p["html"], flags=re.I):
m = re.search(r'\bsrc\s*=\s*["\']([^"\']+)["\']', tag, flags=re.I)
if not m or m.group(1).startswith("data:"):
continue
u = urljoin(p["fetch"]["final_url"], m.group(1))
if u.startswith(("http://", "https://")) and u not in img_srcs:
img_srcs.append(u)
sample = img_srcs[:CFG.IMG_WEIGHT_SAMPLE]
heavy, no_len = [], 0
for u, rr in zip(sample, f.map(lambda q: f.get(q, method="HEAD"), sample)):
cl = rr["headers"].get("Content-Length") or rr["headers"].get("content-length")
if rr["status"] != 200 or not cl or not cl.isdigit():
no_len += 1
continue
kb = int(cl) // 1024
if kb > CFG.IMG_MAX_KB:
heavy.append(f"{u} ({kb}KB > {CFG.IMG_MAX_KB}KB)")
if heavy:
prev = R.rows["C18"]
R.set("C18", FAIL, prev[1] + [f"{len(heavy)}/{len(sample)} sampled imgs over the weight budget "
f"(compress / WebP-AVIF / responsive srcset)"] + heavy[:5])
elif sample:
prev = R.rows["C18"]
note = f"{len(sample) - no_len}/{len(sample)} sampled imgs verified within {CFG.IMG_MAX_KB}KB"
if no_len:
note += f" ({no_len} unverifiable: no Content-Length or non-200 on HEAD)"
R.set("C18", prev[0], prev[1] + [note])
# C19 full OG set
need = ["og:title", "og:description", "og:type", "og:url", "og:image"]
st = []
for p in ok_pages:
mm = metas(p["html"])
missing = [k for k in need if k not in mm]
if "twitter:card" not in mm: missing.append("twitter:card")
probs = ["missing " + ",".join(missing)] if missing else []
w, h = mm.get("og:image:width"), mm.get("og:image:height")
if w and h and (w, h) != (str(CFG.OG_IMAGE_WIDTH), str(CFG.OG_IMAGE_HEIGHT)):
probs.append(f"og:image declares {w}×{h} ≠ {CFG.OG_IMAGE_WIDTH}×{CFG.OG_IMAGE_HEIGHT}")
img = mm.get("og:image", "")
if img and not img.lower().startswith(("http://", "https://")):
probs.append(f"og:image not an absolute URL: {img}")
# og:type value: valid vocabulary + self-evidencing trigger (the page's JSON-LD
# calls itself an article → must be article, not website)
ogt = mm.get("og:type", "").lower()
if ogt and ogt not in CFG.OG_VALID_TYPES:
probs.append(f"og:type invalid value: {ogt}")
page_ld = {t for b in ld_blocks(p["html"]) if b[0] for t in ld_types(b[0])}
if ogt and (page_ld & CFG.ARTICLE_LD_TYPES) and ogt != "article":
probs.append(f"og:type={ogt}, but the page's JSON-LD calls itself "
f"{','.join(sorted(page_ld & CFG.ARTICLE_LD_TYPES))} → should be article")
st.append((p["url"], FAIL if probs else PASS, probs))
agg_pages(R, "C19", st)
# C20 redirect chains (only pages fetched successfully)
st = []
for p in pages:
if p["fetch"]["status"] is None or throttled(p["fetch"]):
continue
hops = p["fetch"]["hops"]
st.append((p["url"], PASS if hops <= CFG.MAX_REDIRECT_HOPS else FAIL,
f"{hops} hop"))
agg_pages(R, "C20", st)
# C25 mixed content (subresources only; <a> is a navigation outlink, never blocked, not judged)
sub_re = re.compile(r"<(?:%s)\s[^>]*\bsrc\s*=\s*[\"']http://([^\"']+)"
% "|".join(CFG.SUBRESOURCE_TAGS), flags=re.I)
link_re = re.compile(r"<link\s[^>]*\bhref\s*=\s*[\"']http://([^\"']+)", flags=re.I)
st = []
for p in ok_pages:
if not p["fetch"]["final_url"].lower().startswith("https://"):
st.append((p["url"], NA, "http page, no mixed content to speak of")); continue
hits = sub_re.findall(p["html"]) + link_re.findall(p["html"])
st.append((p["url"], FAIL if hits else PASS,
[f"{len(hits)} http:// subresources"] + ["http://" + h for h in hits[:3]]
if hits else ""))
agg_pages(R, "C25", st)
# C30 link hygiene: anchors need a machine-readable label (text / img alt / aria-label);
# external target=_blank without rel=noopener leaves window.opener open (modern browsers
# imply noopener, the explicit rel covers the older ones — defense in depth, hence P2)
a_re = re.compile(r"<a\s([^>]*)>(.*?)</a\s*>", flags=re.S | re.I)
st = []
for p in ok_pages:
page_host = urlparse(p["fetch"]["final_url"]).netloc
no_text, unsafe = [], []
for attrs, inner in a_re.findall(p["html"]):
hm = re.search(r'\bhref\s*=\s*["\']([^"\']+)["\']', attrs, flags=re.I)
if not hm or hm.group(1).startswith(("javascript:", "mailto:", "tel:", "#")):
continue
href = hm.group(1)
if not strip_text(inner):
has_alt = re.search(r'<img\s[^>]*\balt\s*=\s*["\'][^"\']+["\']', inner, flags=re.I)
has_aria = re.search(r'\baria-label\s*=\s*["\'][^"\']+["\']', attrs + " " + inner, flags=re.I)
if not has_alt and not has_aria:
no_text.append(href)
if re.search(r'\btarget\s*=\s*["\']?_blank', attrs, flags=re.I):
tgt = urlparse(urljoin(p["fetch"]["final_url"], href))
rel = re.search(r'\brel\s*=\s*["\']([^"\']*)["\']', attrs, flags=re.I)
if tgt.netloc and tgt.netloc != page_host and not (
rel and re.search(r"noopener|noreferrer", rel.group(1), flags=re.I)):
unsafe.append(href)
probs = []
if no_text:
probs.append(f"{len(no_text)} links with no anchor text / img alt / aria-label, e.g. {no_text[0]}")
if unsafe:
probs.append(f"{len(unsafe)} external target=_blank links without rel=noopener, e.g. {unsafe[0]}")
st.append((p["url"], FAIL if probs else PASS, probs))
agg_pages(R, "C30", st)
# C21 human review; C22 machine part (target status) + human reciprocity
R.set("C21", HUMAN, "before launch, walk C21's YMYL trust-block checklist (see references/checklist/references/C21.md) (trigger: ymyl=true)")
# C22: the machine can verify what the annotations point at; whether the pairs close
# (A lists B and B lists A, plus x-default) needs the language-config list — human.