101 lines
4.1 KiB
Python
101 lines
4.1 KiB
Python
#!/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("<!--") or (cur.count("<!--") > cur.count("-->")):
|
||
|
|
continue # already commented
|
||
|
|
lines[idx] = cur[:m.start()] + "<!-- " + link_txt + " -->" + 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()
|