#!/usr/bin/env python3 """Comment out confirmed dead INTERNAL page links, report external/bare-domain failures. Reads lychee's JSON report (linkcheck/internal.json). A link is auto-commented ONLY if lychee flagged its file:// path as a missing file under the built site (public/). External https:// failures and bare-domain failures are written to a manual-review report and left untouched. Idempotent: already-commented links are not detected by lychee, so re-runs are safe. IMPORTANT: run lychee with the SOURCE markdown as input (e.g. `lychee --root-dir public --default-extension html content/*.md`) so the error_map keys are the markdown files we actually edit. They are relative to the repo root. Usage: python3 scripts/fix_dead_links.py --report linkcheck/internal.json # dry-run python3 scripts/fix_dead_links.py --report linkcheck/internal.json --apply # edit """ import argparse import json import re from pathlib import Path def main(): ap = argparse.ArgumentParser() ap.add_argument("--report", required=True) ap.add_argument("--apply", action="store_true") ap.add_argument("--public-dir", default="public", help="built site dir that lychee's --root-dir pointed at") args = ap.parse_args() data = json.loads(Path(args.report).read_text()) err_map = data.get("error_map", {}) to_comment = [] # (md_file_relative, line, dest) manual = [] # (md_file_relative, line, url, status_text) for md_file, items in err_map.items(): for it in items: url = it.get("url", "") status = it.get("status", {}).get("text", "") span = it.get("span", {}) if url.startswith("file://"): after = url[len("file://"):] m = re.search(r"/" + re.escape(args.public_dir) + r"/(.*)$", after) if m: to_comment.append((md_file, span.get("line"), "/" + m.group(1))) continue manual.append((md_file, span.get("line"), url, status)) # Group by source file (error_map keys are relative to repo root). by_file = {} for md_file, line, dest in to_comment: by_file.setdefault(md_file, []).append((line, dest)) for md_file in by_file: by_file[md_file].sort(reverse=True) # edit bottom-up; keep line count stable plan = 0 for md_file, edits in sorted(by_file.items()): p = Path(md_file) if not p.exists(): print(f" ! missing source, skipping: {p}") continue lines = p.read_text().splitlines(keepends=True) changed = False for line_no, dest in edits: if not line_no: continue idx = line_no - 1 if idx < 0 or idx >= len(lines): continue cur = lines[idx] # lychee reports the resolved file path (trailing slash stripped); the # markdown link may keep a trailing slash, so allow an optional "/". m = re.search(r"\[([^\]]*)\]\(" + re.escape(dest) + r"/?\s*\)", cur) if not m: print(f" ? could not locate link on {md_file}:{line_no} for {dest}") continue link_txt = m.group(0) if link_txt.count("")): continue # already commented lines[idx] = cur[:m.start()] + "" + cur[m.end():] changed = True plan += 1 print(f" {'APPLY ' if args.apply else 'DRY '} {md_file}:{line_no} {link_txt}") if changed and args.apply: p.write_text("".join(lines)) outdir = Path("linkcheck") outdir.mkdir(parents=True, exist_ok=True) with (outdir / "manual_review.txt").open("w") as f: f.write("Manual review (NOT auto-commented). External/bare-domain failures.\n\n") for md_file, line, url, status in sorted(manual): f.write(f"{md_file}:{line}\t{url}\t[{status}]\n") print(f"\nAuto-comment planned: {plan} (apply={args.apply})") print(f"Manual review entries: {len(manual)} -> {outdir/'manual_review.txt'}") if __name__ == "__main__": main()