#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Re-sweep every share link and report drift against the committed catalog.
An HTTP status carries one of two very different meanings, and conflating them
is how link checkers earn a reputation for crying wolf:
* a statement about the resource — 404/410: the bot really is not there
* a statement about the requester — 403/429/503 behind a bot wall: we were
turned away, which says nothing at all about whether the bot exists
A checker that reports the second as the first will, the first time x.ai puts a
challenge in front of a datacenter IP, declare all 361 shares dead. So results
land in one of four buckets and only one of them counts as a dead link:
alive answered under 400
gone 404/410, or another 4xx with no wall signature. The bot is not
there. This is the ONLY bucket --write moves out of the catalog
blocked a throttle status carrying a wall signature, or a non-HTTP bot
code. Reported separately, never treated as breakage
flaky 5xx, a timeout, or no answer after retries. A statement about the
host, not about the bot
The flaky bucket is not hypothetical: the 2026-09-01 sweep saw one share return
500, and it answered 200 the next time it was asked. Under a status != 200 rule
that bot was wrongly retired.
A circuit breaker guards the whole run. If more than CIRCUIT_BREAKER of the
sweep comes back blocked or flaky, the problem is the network path, not the
catalog, and the script refuses to write.
python3 scripts/check_links.py # report only
python3 scripts/check_links.py --write # move `gone` rows to retired.json
python3 scripts/check_links.py --limit 20 # quick smoke test
Exit codes: 0 clean · 1 dead links found · 2 circuit breaker tripped
Rate limiting is intentional (6 workers, 0.25s pause each). These are other
people's pages; sweep them politely.
"""
import argparse
import html
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UA = "awesome-grokbot-linkcheck/1.0 (+https://github.com/kydlikebtc/awesome-grokbot)"
WORKERS = 6
PAUSE = 0.25
TIMEOUT = 25
RETRIES = 3
# Fraction of the sweep that may come back blocked/flaky before we conclude the
# network path is the problem rather than the catalog.
CIRCUIT_BREAKER = 0.25
# Statuses that are a throttle or a bot code rather than a claim about the resource.
THROTTLE_STATUS = {403, 429, 503}
# Statuses that genuinely say "not here".
GONE_STATUS = {404, 410}
BODY_MARKERS = (
re.compile(r"cf-browser-verification|cf_chl_opt|__cf_chl", re.I),
re.compile(r"Just a moment\.\.\.", re.I),
re.compile(r"Checking your browser before accessing", re.I),
re.compile(r"Attention Required!\s*\|\s*Cloudflare", re.I),
re.compile(r"
\s*Access denied", re.I),
re.compile(r"captcha", re.I),
)
OG_TITLE = r'property=["\']og:title["\'][^>]*content=["\'](.*?)["\']'
OG_DESC = r'property=["\']og:description["\'][^>]*content=["\'](.*?)["\']'
def grab(pattern, text):
m = re.search(pattern, text, re.S | re.I)
if not m:
return None
return html.unescape(re.sub(r"\s+", " ", m.group(1))).strip() or None
def wall_signature(status, headers, body):
"""Return a reason string if this status is about us, or None if it is about the resource."""
lower = {k.lower(): v for k, v in headers.items()}
mitigated = lower.get("cf-mitigated", "")
if "challenge" in mitigated.lower():
return "cf-mitigated: challenge"
if status >= 900:
return f"non-standard status {status}, a bot code rather than an HTTP status"
# RFC 9110 is explicit that 429 is a temporary condition.
if status == 429:
return "429, rate limited rather than missing"
if status not in THROTTLE_STATUS:
return None
if "retry-after" in lower:
return f"{status} with a Retry-After header, a temporary refusal"
if any(rx.search(body) for rx in BODY_MARKERS):
return f"{status} serving an interstitial challenge page"
# A throttle status from a CDN edge with no real body: the edge answered and
# the origin was never asked. Still not a statement about the bot.
cdn = "cloudflare" in lower.get("server", "").lower() or "cf-ray" in lower
if cdn and len(body.strip()) < 512:
return f"{status} from a CDN edge with no page body"
return None
def probe(entry):
"""Fetch one share page and put it in exactly one bucket."""
url = entry["import"]
out = {
"slug": entry["slug"],
"name": entry["name"],
"url": url,
"bucket": None,
"status": None,
"detail": "",
"og_title": None,
"og_description": None,
}
status = headers = None
body = ""
last_error = None
for attempt in range(RETRIES):
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
status, headers = resp.status, dict(resp.headers)
body = resp.read().decode("utf-8", "replace")
break
except urllib.error.HTTPError as e:
# An HTTP error is an answer; do not retry it.
status, headers = e.code, dict(e.headers)
try:
body = e.read().decode("utf-8", "replace")
except Exception:
body = ""
break
except Exception as e:
last_error = f"{type(e).__name__}: {e}"
time.sleep(1.5 * (attempt + 1))
time.sleep(PAUSE)
if status is None:
out["bucket"] = "flaky"
out["detail"] = last_error or "no answer after retries"
return out
out["status"] = status
if status < 400:
out["bucket"] = "alive"
out["detail"] = str(status)
out["og_title"] = grab(OG_TITLE, body)
out["og_description"] = grab(OG_DESC, body)
return out
sig = wall_signature(status, headers or {}, body)
if sig:
out["bucket"] = "blocked"
out["detail"] = sig
return out
if 500 <= status < 600:
# The host stumbled. That is not a claim that the bot is gone — the
# 2026-09-01 sweep proved it, with a 500 that answered 200 next time.
out["bucket"] = "flaky"
out["detail"] = f"{status} server error, not a statement about the bot"
return out
out["bucket"] = "gone"
out["detail"] = f"{status}" + (
" not found" if status in GONE_STATUS else " with no wall signature"
)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument(
"--write", action="store_true", help="move `gone` rows into retired.json"
)
ap.add_argument(
"--limit", type=int, default=0, help="only check the first N entries"
)
ap.add_argument(
"--date", default=None, help="date to stamp as `checked` (YYYY-MM-DD)"
)
args = ap.parse_args()
catalog = json.load(open(os.path.join(ROOT, "catalog.json"), encoding="utf-8"))
entries = catalog["entries"]
todo = entries[: args.limit] if args.limit else entries
print(
f"sweeping {len(todo)} share links with {WORKERS} workers...", file=sys.stderr
)
results = {}
done = 0
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
for r in pool.map(probe, todo):
results[r["slug"]] = r
done += 1
if done % 25 == 0:
print(f" {done}/{len(todo)}", file=sys.stderr)
buckets = Counter(r["bucket"] for r in results.values())
gone = [r for r in results.values() if r["bucket"] == "gone"]
blocked = [r for r in results.values() if r["bucket"] == "blocked"]
flaky = [r for r in results.values() if r["bucket"] == "flaky"]
print(f"\nLinks checked: {len(results)}")
for b in ("alive", "gone", "blocked", "flaky"):
print(f" {b:<8} {buckets[b]}")
for r in gone:
print(f" GONE {r['slug']:38} {r['detail']} {r['url']}")
for r in blocked:
print(f" blocked {r['slug']:38} {r['detail']}")
for r in flaky:
print(f" flaky {r['slug']:38} {r['detail']}")
# renames, only meaningful for pages that answered
renamed = []
for e in todo:
r = results[e["slug"]]
if r["bucket"] != "alive" or not r["og_title"]:
continue
m = re.match(r"^(.*?)\s+by\s+([^,]+)$", r["og_title"])
live = (m.group(1) if m else r["og_title"]).strip()
if live and live.lower() != e["name"].lower():
renamed.append((e, live))
if renamed:
print(f"\nRENAMED {len(renamed)}")
for e, live in renamed:
print(f" {e['slug']:38} catalog={e['name']!r} live={live!r}")
# circuit breaker
suspect = buckets["blocked"] + buckets["flaky"]
tripped = len(results) > 0 and suspect / len(results) > CIRCUIT_BREAKER
if tripped:
pct = suspect / len(results) * 100
print(
f"\nCIRCUIT BREAKER: {suspect}/{len(results)} ({pct:.0f}%) came back blocked or flaky, "
f"over the {CIRCUIT_BREAKER:.0%} threshold.\n"
"That pattern means the network path is the problem, not the catalog. "
"Nothing was written. Re-run from a different network before touching the data."
)
return 2
if args.write:
stamp = args.date or time.strftime("%Y-%m-%d")
# Sync names and blurbs from the live page. Without this the catalog
# slowly drifts away from what a reader sees when they click, which is
# the one thing it promises not to do. Three rows had been renamed in
# the day after the first build alone.
applied = 0
for e in entries:
r = results.get(e["slug"])
if not r or r["bucket"] != "alive":
continue
if r.get("og_title"):
m = re.match(r"^(.*?)\s+by\s+([^,]+)$", r["og_title"])
live = (m.group(1) if m else r["og_title"]).strip()
if live and live.lower() != e["name"].lower():
# aka keeps the *earliest* known name, so a row stays
# findable by whatever people first called it.
e.setdefault("aka", e["name"])
e["name"] = live
applied += 1
# renamed back to what aka records -> the alias is noise now
if e.get("aka") and e["aka"].lower() == e["name"].lower():
e.pop("aka")
if r.get("og_description") and r["og_description"] != e.get(
"official_summary"
):
e["official_summary"] = r["og_description"]
if applied:
print(f"\napplied {applied} rename(s) from the live pages")
# Partition once, whether or not anything is gone. An earlier version
# nested this under `if not gone`, which meant a real dead link was
# never actually moved — the one case the branch exists for.
gone_slugs = {r["slug"] for r in gone}
retired = json.load(open(os.path.join(ROOT, "retired.json"), encoding="utf-8"))
keep, moved = [], []
for e in entries:
r = results.get(e["slug"])
if e["slug"] in gone_slugs:
e["link_status"] = r["status"]
e["checked"] = stamp
moved.append(e)
continue
if r and r["bucket"] == "alive":
e["checked"] = stamp
e["link_status"] = 200
keep.append(e)
catalog["entries"] = keep
catalog["checked"] = stamp
catalog["generated"] = stamp
counts = Counter(e["category"] for e in keep)
catalog["counts"]["live"] = len(keep)
catalog["counts"]["by_category"] = {
k: counts.get(k, 0) for k in catalog["counts"]["by_category"]
}
if moved:
retired["entries"].extend(moved)
retired["checked"] = stamp
catalog["counts"]["retired"] = len(retired["entries"])
json.dump(
retired,
open(os.path.join(ROOT, "retired.json"), "w", encoding="utf-8"),
ensure_ascii=False,
indent=2,
)
json.dump(
catalog,
open(os.path.join(ROOT, "catalog.json"), "w", encoding="utf-8"),
ensure_ascii=False,
indent=2,
)
if moved:
print(f"\nmoved {len(moved)} row(s) to retired.json; {len(keep)} live")
else:
print(f"wrote catalog.json ({len(keep)} live, checked {stamp})")
print("now run: python3 scripts/build_readme.py")
if blocked or flaky:
print(
f"left {len(blocked) + len(flaky)} blocked/flaky row(s) in the catalog on purpose — "
"those statuses are not evidence the bot is gone"
)
return 1 if gone else 0
if __name__ == "__main__":
sys.exit(main())