443 lines
15 KiB
Python
443 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate external HTTP/HTTPS links in every markdown doc.
|
|
|
|
Requires Python 3.10+ (PEP 604 union types).
|
|
|
|
Walks every `*.md` file under the repo (excluding `.git/`, `node_modules/`,
|
|
`outputs/`), extracts `https?://` URLs from markdown link syntax and bare URLs,
|
|
deduplicates, and validates each unique URL by HEAD request (falling back to
|
|
GET on 405/501). Results are cached for 7 days at `.link-cache.json` (repo
|
|
root, gitignored) so re-runs do not hammer external services.
|
|
|
|
Stdlib only. No `requests`, no `httpx`.
|
|
|
|
Usage:
|
|
python3 scripts/link_check.py # full check, group by file
|
|
python3 scripts/link_check.py --phase 14 # one phase
|
|
python3 scripts/link_check.py --path README.md # one file
|
|
python3 scripts/link_check.py --path phases/14-... # one directory
|
|
python3 scripts/link_check.py --strict # exit 1 on any broken link
|
|
python3 scripts/link_check.py --json # machine-readable
|
|
python3 scripts/link_check.py --cache 0 # bypass cache for this run
|
|
python3 scripts/link_check.py --timeout 15 # per-request timeout (sec)
|
|
python3 scripts/link_check.py --concurrency 16 # worker threads
|
|
|
|
Companion to `scripts/audit_lessons.py` (rule L010 validates *internal* links);
|
|
this script handles the external HTTP/HTTPS surface.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import ssl
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
from urllib import error as urlerror
|
|
from urllib import request as urlrequest
|
|
from urllib.parse import urlparse
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
CACHE_PATH = ROOT / ".link-cache.json"
|
|
CACHE_SCHEMA_VERSION = 1
|
|
USER_AGENT = (
|
|
"ai-engineering-from-scratch link-check/1.0 "
|
|
"(+https://aiengineeringfromscratch.com)"
|
|
)
|
|
DEFAULT_TIMEOUT = 10
|
|
DEFAULT_CONCURRENCY = 8
|
|
DEFAULT_CACHE_DAYS = 7
|
|
DEFAULT_SKIP_DOMAINS = (
|
|
"twitter.com",
|
|
"x.com",
|
|
"linkedin.com",
|
|
"instagram.com",
|
|
"medium.com",
|
|
)
|
|
EXCLUDE_DIRS = {".git", "node_modules", "outputs"}
|
|
|
|
MD_LINK_RE = re.compile(r"\[[^\]]*\]\((<?)(https?://[^\s)>]+)>?\)")
|
|
BARE_URL_RE = re.compile(r"(?<![\w(\[=\"'])(https?://[^\s)\]<>\"'`]+)")
|
|
TRAILING_PUNCT = ".,;:!?)\"'>"
|
|
|
|
|
|
@dataclass
|
|
class UrlOccurrence:
|
|
file: str
|
|
line: int
|
|
|
|
|
|
@dataclass
|
|
class CheckResult:
|
|
url: str
|
|
status: str
|
|
http_status: int | None
|
|
error: str | None
|
|
cached: bool = False
|
|
|
|
@property
|
|
def ok(self) -> bool:
|
|
return self.status == "ok"
|
|
|
|
|
|
@dataclass
|
|
class Report:
|
|
checked_files: int = 0
|
|
unique_urls: int = 0
|
|
requested: int = 0
|
|
cached_hits: int = 0
|
|
skipped: list[str] = field(default_factory=list)
|
|
failed: list[dict[str, object]] = field(default_factory=list)
|
|
by_file: dict[str, list[dict[str, object]]] = field(default_factory=dict)
|
|
|
|
def to_dict(self) -> dict[str, object]:
|
|
return {
|
|
"schema_version": 1,
|
|
"checked_files": self.checked_files,
|
|
"unique_urls": self.unique_urls,
|
|
"requested": self.requested,
|
|
"cached_hits": self.cached_hits,
|
|
"skipped_count": len(self.skipped),
|
|
"failed_count": len(self.failed),
|
|
"skipped": sorted(set(self.skipped)),
|
|
"failed": self.failed,
|
|
"by_file": self.by_file,
|
|
}
|
|
|
|
|
|
def iter_markdown_files(
|
|
root: Path, phase: int | None, path: Path | None
|
|
) -> Iterable[Path]:
|
|
if path is not None:
|
|
path = path.resolve()
|
|
if path.is_file():
|
|
if path.suffix == ".md":
|
|
yield path
|
|
return
|
|
roots = [path]
|
|
elif phase is not None:
|
|
phase_prefix = f"{phase:02d}-"
|
|
phases_dir = root / "phases"
|
|
if not phases_dir.is_dir():
|
|
return
|
|
matches = [p for p in phases_dir.iterdir() if p.is_dir() and p.name.startswith(phase_prefix)]
|
|
if not matches:
|
|
return
|
|
roots = matches
|
|
else:
|
|
roots = [root]
|
|
|
|
for r in roots:
|
|
if r.is_file():
|
|
if r.suffix == ".md":
|
|
yield r
|
|
continue
|
|
for dirpath, dirnames, filenames in os.walk(r):
|
|
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
|
|
for name in filenames:
|
|
if name.endswith(".md"):
|
|
yield Path(dirpath) / name
|
|
|
|
|
|
def strip_trailing_punct(url: str) -> str:
|
|
while url and url[-1] in TRAILING_PUNCT:
|
|
url = url[:-1]
|
|
return url
|
|
|
|
|
|
def extract_urls(text: str) -> list[tuple[str, int]]:
|
|
"""Return list of (url, line_number) tuples preserving order."""
|
|
out: list[tuple[str, int]] = []
|
|
seen_per_line: set[tuple[int, str]] = set()
|
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
for m in MD_LINK_RE.finditer(line):
|
|
url = strip_trailing_punct(m.group(2))
|
|
key = (lineno, url)
|
|
if key in seen_per_line:
|
|
continue
|
|
seen_per_line.add(key)
|
|
out.append((url, lineno))
|
|
masked = MD_LINK_RE.sub(" ", line)
|
|
for m in BARE_URL_RE.finditer(masked):
|
|
url = strip_trailing_punct(m.group(1))
|
|
key = (lineno, url)
|
|
if key in seen_per_line:
|
|
continue
|
|
seen_per_line.add(key)
|
|
out.append((url, lineno))
|
|
return out
|
|
|
|
|
|
def load_cache() -> dict[str, dict[str, object]]:
|
|
if not CACHE_PATH.is_file():
|
|
return {}
|
|
try:
|
|
raw = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
return {}
|
|
if raw.get("schema_version") != CACHE_SCHEMA_VERSION:
|
|
return {}
|
|
entries = raw.get("entries")
|
|
return entries if isinstance(entries, dict) else {}
|
|
|
|
|
|
def save_cache(entries: dict[str, dict[str, object]]) -> None:
|
|
payload = {"schema_version": CACHE_SCHEMA_VERSION, "entries": entries}
|
|
try:
|
|
CACHE_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
except OSError as exc:
|
|
print(f"warning: could not write {CACHE_PATH.name}: {exc}", file=sys.stderr)
|
|
|
|
|
|
def cache_is_fresh(entry: dict[str, object], cache_days: int) -> bool:
|
|
if cache_days <= 0:
|
|
return False
|
|
checked_at = entry.get("checked_at")
|
|
if not isinstance(checked_at, (int, float)):
|
|
return False
|
|
age = time.time() - float(checked_at)
|
|
return age < cache_days * 86400
|
|
|
|
|
|
def domain_of(url: str) -> str:
|
|
try:
|
|
netloc = urlparse(url).netloc.lower()
|
|
except ValueError:
|
|
return ""
|
|
if netloc.startswith("www."):
|
|
netloc = netloc[4:]
|
|
if ":" in netloc:
|
|
netloc = netloc.split(":", 1)[0]
|
|
return netloc
|
|
|
|
|
|
def should_skip(url: str, skip_domains: set[str]) -> bool:
|
|
domain = domain_of(url)
|
|
if not domain:
|
|
return False
|
|
for sd in skip_domains:
|
|
if domain == sd or domain.endswith("." + sd):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _request(url: str, method: str, timeout: int) -> tuple[int | None, str | None]:
|
|
req = urlrequest.Request(
|
|
url,
|
|
method=method,
|
|
headers={
|
|
"User-Agent": USER_AGENT,
|
|
"Accept": "*/*",
|
|
"Accept-Language": "en-US,en;q=0.9",
|
|
},
|
|
)
|
|
ctx = ssl.create_default_context()
|
|
try:
|
|
with urlrequest.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
return resp.status, None
|
|
except urlerror.HTTPError as exc:
|
|
return exc.code, f"http {exc.code}"
|
|
except urlerror.URLError as exc:
|
|
reason = getattr(exc, "reason", exc)
|
|
return None, f"url-error: {reason}"
|
|
except socket.timeout:
|
|
return None, "timeout"
|
|
except (TimeoutError, ConnectionError) as exc:
|
|
return None, f"conn-error: {exc}"
|
|
except ssl.SSLError as exc:
|
|
return None, f"ssl-error: {exc}"
|
|
except Exception as exc:
|
|
return None, f"error: {exc.__class__.__name__}: {exc}"
|
|
|
|
|
|
def check_url(url: str, timeout: int) -> CheckResult:
|
|
status_code, err = _request(url, "HEAD", timeout)
|
|
if status_code in (405, 501) or (status_code is None and err and "http" not in err):
|
|
get_status, get_err = _request(url, "GET", timeout)
|
|
if get_status is not None:
|
|
status_code, err = get_status, get_err
|
|
elif status_code is None:
|
|
status_code, err = get_status, get_err
|
|
|
|
if status_code is None:
|
|
return CheckResult(url=url, status="error", http_status=None, error=err or "unknown")
|
|
if 200 <= status_code < 400:
|
|
return CheckResult(url=url, status="ok", http_status=status_code, error=None)
|
|
return CheckResult(url=url, status="broken", http_status=status_code, error=err or f"http {status_code}")
|
|
|
|
|
|
def run(args: argparse.Namespace) -> int:
|
|
if args.path is not None:
|
|
path_arg: Path | None = Path(args.path)
|
|
if not path_arg.is_absolute():
|
|
path_arg = (Path.cwd() / path_arg).resolve()
|
|
else:
|
|
path_arg = None
|
|
|
|
skip_env = os.environ.get("LINK_CHECK_SKIP", "")
|
|
if skip_env.strip():
|
|
skip_domains = {d.strip().lower() for d in skip_env.split(",") if d.strip()}
|
|
else:
|
|
skip_domains = set(DEFAULT_SKIP_DOMAINS)
|
|
|
|
files = sorted(set(iter_markdown_files(ROOT, args.phase, path_arg)))
|
|
|
|
occurrences: dict[str, list[UrlOccurrence]] = {}
|
|
for f in files:
|
|
try:
|
|
text = f.read_text(encoding="utf-8")
|
|
except (OSError, UnicodeDecodeError):
|
|
continue
|
|
try:
|
|
rel = f.relative_to(ROOT).as_posix()
|
|
except ValueError:
|
|
rel = str(f)
|
|
for url, line in extract_urls(text):
|
|
occurrences.setdefault(url, []).append(UrlOccurrence(file=rel, line=line))
|
|
|
|
report = Report(checked_files=len(files), unique_urls=len(occurrences))
|
|
|
|
cache = load_cache()
|
|
to_check: list[str] = []
|
|
results: dict[str, CheckResult] = {}
|
|
|
|
for url in occurrences:
|
|
if should_skip(url, skip_domains):
|
|
report.skipped.append(url)
|
|
continue
|
|
entry = cache.get(url)
|
|
if entry and cache_is_fresh(entry, args.cache):
|
|
status = str(entry.get("status", "error"))
|
|
http_status = entry.get("http_status")
|
|
http_status_int = int(http_status) if isinstance(http_status, (int, float)) else None
|
|
err = entry.get("last_error")
|
|
results[url] = CheckResult(
|
|
url=url,
|
|
status=status,
|
|
http_status=http_status_int,
|
|
error=str(err) if err else None,
|
|
cached=True,
|
|
)
|
|
report.cached_hits += 1
|
|
continue
|
|
to_check.append(url)
|
|
|
|
report.requested = len(to_check)
|
|
|
|
if to_check:
|
|
with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as executor:
|
|
futures = {executor.submit(check_url, url, args.timeout): url for url in to_check}
|
|
for fut in as_completed(futures):
|
|
url = futures[fut]
|
|
try:
|
|
result = fut.result()
|
|
except Exception as exc:
|
|
result = CheckResult(
|
|
url=url, status="error", http_status=None, error=f"executor: {exc}"
|
|
)
|
|
results[url] = result
|
|
cache[url] = {
|
|
"status": result.status,
|
|
"http_status": result.http_status,
|
|
"checked_at": time.time(),
|
|
"last_error": result.error,
|
|
}
|
|
if not args.json:
|
|
mark = "OK" if result.ok else "FAIL"
|
|
code = result.http_status if result.http_status is not None else "-"
|
|
print(f" [{mark}] {code} {url}", file=sys.stderr)
|
|
|
|
save_cache(cache)
|
|
|
|
by_file: dict[str, list[dict[str, object]]] = {}
|
|
for url, occs in occurrences.items():
|
|
result = results.get(url)
|
|
if result is None:
|
|
continue
|
|
if result.ok:
|
|
continue
|
|
for occ in occs:
|
|
entry = {
|
|
"url": url,
|
|
"line": occ.line,
|
|
"status": result.status,
|
|
"http_status": result.http_status,
|
|
"error": result.error,
|
|
"cached": result.cached,
|
|
}
|
|
by_file.setdefault(occ.file, []).append(entry)
|
|
report.failed.append({"file": occ.file, **entry})
|
|
|
|
for fname in by_file:
|
|
by_file[fname].sort(key=lambda e: (e["line"], e["url"]))
|
|
report.by_file = dict(sorted(by_file.items()))
|
|
|
|
if args.json:
|
|
json.dump(report.to_dict(), sys.stdout, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
else:
|
|
print(f"checked {report.checked_files} markdown files", file=sys.stderr)
|
|
print(f"unique urls: {report.unique_urls}", file=sys.stderr)
|
|
print(f"requested: {report.requested}", file=sys.stderr)
|
|
print(f"cache hits: {report.cached_hits}", file=sys.stderr)
|
|
print(f"skipped: {len(set(report.skipped))} urls in {len(skip_domains)} domains", file=sys.stderr)
|
|
print(f"broken: {len(report.failed)} occurrences across {len(report.by_file)} files", file=sys.stderr)
|
|
if report.by_file:
|
|
print("", file=sys.stderr)
|
|
for fname, entries in report.by_file.items():
|
|
print(fname, file=sys.stderr)
|
|
for e in entries:
|
|
code = e["http_status"] if e["http_status"] is not None else e["error"]
|
|
print(f" line {e['line']}: [{code}] {e['url']}", file=sys.stderr)
|
|
|
|
if args.strict and report.failed:
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Validate external HTTP/HTTPS links across markdown docs.",
|
|
)
|
|
parser.add_argument("--phase", type=int, default=None, help="restrict to phase NN")
|
|
parser.add_argument("--path", default=None, help="restrict to one file or directory")
|
|
parser.add_argument("--strict", action="store_true", help="exit 1 if any link is broken")
|
|
parser.add_argument("--json", action="store_true", help="emit machine-readable report")
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=DEFAULT_TIMEOUT,
|
|
help=f"per-request timeout in seconds (default: {DEFAULT_TIMEOUT})",
|
|
)
|
|
parser.add_argument(
|
|
"--concurrency",
|
|
type=int,
|
|
default=DEFAULT_CONCURRENCY,
|
|
help=f"worker threads (default: {DEFAULT_CONCURRENCY})",
|
|
)
|
|
parser.add_argument(
|
|
"--cache",
|
|
type=int,
|
|
default=DEFAULT_CACHE_DAYS,
|
|
help=f"cache TTL in days; 0 disables (default: {DEFAULT_CACHE_DAYS})",
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
return run(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|