#!/usr/bin/env python3
"""
Link-Checker fuer das "build broken" Blog-Archiv.
Extrahiert alle externen Links aus content/posts/*.md und prueft sie per HTTP.
Ergebnis wird als data/links.json gespeichert, die von layouts/_default/baseof.html
eingebunden wird (Broken-Links -> Modal mit web.archive.org, Replacement-Links
werden automatisch umgeschrieben).
Nutzung:
python check_links.py # pruefen + data/links.json aktualisieren
python check_links.py --check-only # nur pruefen, data/links.json NICHT schreiben
python check_links.py --dry-run # wie --check-only, aber zeigt diff der Aenderungen
Optionen:
--check-only data/links.json nicht schreiben (nur Ausgabe)
--dry-run wie --check-only, zusaetzlich geplante Aenderungen anzeigen
--include-coded Auch Platzhalter-URLs (localhost:port, IP:port, w3.org,
tempuri.org aus Code-Beispielen) pruefen. Standard: ausgeschlossen.
Achtung: URLs, die nur Bot-Schutz liefern (HTTP 403/429) werden NICHT als broken
markiert. Die erkannten Status werden als Tabelle ausgegeben; manuelle
Nachpflege (replacement-Eintraege) bleibt Aufgabe des Menschen.
"""
import argparse
import glob
import json
import re
import sys
import time
from pathlib import Path
import requests
ROOT = Path(__file__).resolve().parent.parent
CONTENT_GLOB = ROOT / "content" / "posts" / "*.md"
DATA_FILE = ROOT / "data" / "links.json"
# URLs, die nie geprueft/angefasst werden:
# - aztec-project.org (Original-Quelle, bewusst tot, Zweck des Archivs)
# - web.archive.org (Archive-Links, "leben" per Definition)
# - moonweb.org (eigene Domain)
SKIP_HOSTS = ("aztec-project.org", "web.archive.org", "moonweb.org")
# Platzhalter/Code-Beispiel-URLs, die keine echten Links sind
CODED_PLACEHOLDERS = {
"http://IP:port",
"http://localhost:port",
"http://www.w3.org/2001/XMLSchema",
"http://www.w3.org/2001/XMLSchema-instance",
"http://www.w3.org/2003/05/soap-envelope",
"http://tempuri.org/",
"http://tempuri.org/AendernEtwas",
"http://schemas.xmlsoap.org/soap/envelope/",
}
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
),
"Accept-Language": "de,en;q=0.8",
}
def extract_urls():
"""Liefert {url: set(dateinamen)}.
Bevorzugt werden die tatsaechlich gerenderten -Links aus
public/posts/*/index.html verwendet (nur die erscheinen dem Besucher und
nur die werden von data/links.json/JS verarbeitet). Ist public/ nicht
gebaut, wird auf Markdown-Extraktion zurueckgegriffen (Code-Bloecke und
Inline-Code werden ausgeschlossen).
"""
html_files = sorted(glob.glob(str(ROOT / "public" / "posts" / "*" / "index.html")))
if html_files:
hrefpat = re.compile(r']*href="?([^" >]+)"?')
found = {}
for f in html_files:
html = Path(f).read_text(encoding="utf-8")
name = Path(f).resolve().parent.parent.name
for m in hrefpat.finditer(html):
h = m.group(1).replace("&", "&")
if h.startswith("http"):
found.setdefault(h, set()).add(name)
if found:
return found
# Fallback: Markdown-Extraktion
urlpat = re.compile(r"https?://[^\s<>\")\]]+")
def strip_code(text):
text = re.sub(r"```.*?```", " ", text, flags=re.S)
text = re.sub(r"`[^`]*`", " ", text)
return text
found = {}
for f in sorted(glob.glob(str(CONTENT_GLOB))):
text = Path(f).read_text(encoding="utf-8")
text = strip_code(text)
name = Path(f).name
for m in urlpat.finditer(text):
u = m.group(0).rstrip(".,;:!?\")>]")
u = u.rstrip("\u201c\u201d\u201e\u2019\u2033")
if u.startswith("http"):
found.setdefault(u, set()).add(name)
return found
def is_skipped(url):
for host in SKIP_HOSTS:
if host in url:
return True
return url in CODED_PLACEHOLDERS
def check_url(sess, url, timeout=25):
"""Prueft eine URL, liefert (status, final_url, error)."""
try:
r = sess.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True, verify=True)
return r.status_code, r.url, None
except Exception as e:
return "ERR", url, str(e)[:120]
def classify(status):
"""Status -> (broken?, label)."""
if status == 200:
return False, "OK"
if status in (403, 429):
# Bot-Schutz: Seite lebt, Crawler wird nur geblockt -> NICHT broken
return False, "BLOCKED"
if status in ("ERR", 404, 410, 503):
return True, "BROKEN"
return True, "BROKEN" # andere 4xx/5xx
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--check-only", action="store_true", help="data/links.json nicht schreiben")
ap.add_argument("--dry-run", action="store_true", help="wie --check-only, plus diff anzeigen")
ap.add_argument("--include-coded", action="store_true", help="auch Code-Platzhalter-URLs pruefen")
args = ap.parse_args()
found = extract_urls()
if not found:
print("Keine URLs gefunden.")
sys.exit(1)
# Dedupe: gleiche URL aus mehreren Dateien
urls = sorted(found.keys())
urls_set = set(urls)
to_check = [u for u in urls if not is_skipped(u)]
print(f"Extrahierte URLs: {len(urls)} | zu pruefen: {len(to_check)} | uebersprungen: {len(urls)-len(to_check)}")
print()
sess = requests.Session()
results = {}
for i, u in enumerate(to_check, 1):
status, final, err = check_url(sess, u)
results[u] = (status, final, err)
print(f"[{i}/{len(to_check)}] {status:>4} {u}")
time.sleep(0.25)
# data/links.json laden (falls vorhanden)
old_broken = {}
old_replacement = {}
if DATA_FILE.exists():
try:
old = json.loads(DATA_FILE.read_text(encoding="utf-8"))
old_broken = old.get("broken", {})
old_replacement = old.get("replacement", {})
except Exception:
pass
new_broken = {}
new_replacement = {}
for u in to_check:
status, final, err = results[u]
broken, label = classify(status)
# URLs im replacement-Block sind bewusst umgeschrieben -> nie broken
if u in old_replacement:
new_replacement[u] = old_replacement[u]
continue
if broken:
# Bestehende spezifische Archive-URL beibehalten, sonst Wildcard-Fallback
if u in old_broken:
new_broken[u] = old_broken[u]
else:
new_broken[u] = "https://web.archive.org/web/*/" + u
elif status == 200 and final and final != u:
# Redirect auf lebende Seite -> kein automatischer Replacement,
# da der Redirect eh schon funktioniert. Nur Anzeige.
pass
# Konservativer Merge: manuell kuratierte broken-Eintraege, deren URL noch
# im Content vorkommt, bleiben erhalten, solange der Check sie heute NICHT
# eindeutig als 200 (wieder online) bestaetigt. 403/429 (Bot-Schutz) und
# ERR (DNS/Timeout) schuetzen so vor Flackern.
for u in old_broken:
if u not in new_broken and u in urls_set:
status = results.get(u, (None,))[0]
if status != 200:
new_broken[u] = old_broken[u]
print("\n=== Zusammenfassung ===")
print(f" broken (neu): {len(new_broken)}")
print(f" broken (alt): {len(old_broken)}")
print(f" replacement (alt, bleibt): {len(old_replacement)}")
print()
added = sorted(set(new_broken) - set(old_broken))
removed = sorted(set(old_broken) - set(new_broken))
print(f" + neu als broken: {len(added)}")
for u in added:
print(f" {u}")
print(f" - nicht mehr broken: {len(removed)}")
for u in removed:
print(f" {u}")
if args.check_only or args.dry_run:
print("\n(--check-only/--dry-run: data/links.json wurde NICHT geschrieben)")
return
# data/links.json aktualisieren: broken ersetzen, replacement beibehalten
data = {
"broken": new_broken,
"replacement": old_replacement,
}
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
DATA_FILE.write_text(
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"\ndata/links.json aktualisiert ({len(new_broken)} broken, {len(old_replacement)} replacement)")
if __name__ == "__main__":
main()