|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Scan the repository for URLs and check whether they are reachable. |
| 3 | +
|
| 4 | +The script walks the working tree (skipping VCS data, dependencies and build |
| 5 | +output), extracts http(s) URLs from every text file via a regex, performs |
| 6 | +concurrent HEAD/GET requests, and prints a categorized report. It exits with a |
| 7 | +non-zero status when any URL is broken so it can gate CI. |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import argparse |
| 13 | +import os |
| 14 | +import re |
| 15 | +import socket |
| 16 | +import sys |
| 17 | +from concurrent.futures import ThreadPoolExecutor, as_completed |
| 18 | +from dataclasses import dataclass, field |
| 19 | +from typing import Iterable |
| 20 | +from urllib import error, request |
| 21 | +from urllib.parse import quote, urlsplit, urlunsplit |
| 22 | + |
| 23 | +# --- Configuration ----------------------------------------------------------- |
| 24 | + |
| 25 | +# Directories that should never be scanned. |
| 26 | +DEFAULT_EXCLUDE_DIRS = { |
| 27 | + ".git", |
| 28 | + ".docusaurus", |
| 29 | + ".cache-loader", |
| 30 | + "node_modules", |
| 31 | + "build", |
| 32 | + ".idea", |
| 33 | + ".vscode", |
| 34 | + "__pycache__", |
| 35 | +} |
| 36 | + |
| 37 | +# File extensions we treat as text. Binary files are skipped entirely. |
| 38 | +DEFAULT_INCLUDE_EXT = { |
| 39 | + ".md", ".mdx", ".markdown", |
| 40 | + ".yml", ".yaml", |
| 41 | + ".json", |
| 42 | + ".html", ".htm", |
| 43 | + ".js", ".jsx", ".ts", ".tsx", |
| 44 | + ".py", ".rb", ".go", ".rs", |
| 45 | + ".txt", ".rst", |
| 46 | + ".css", ".scss", |
| 47 | + ".xml", ".svg", |
| 48 | + ".toml", ".ini", ".cfg", |
| 49 | +} |
| 50 | + |
| 51 | +# Regex that matches http(s) URLs. The negated character class stops at common |
| 52 | +# delimiters (whitespace, angle/quote/backtick, square bracket) so we don't |
| 53 | +# capture surrounding markup. Note that ')' is intentionally NOT excluded: |
| 54 | +# parentheses are valid URL characters (Wikipedia, "(3rd).pdf", etc.) and |
| 55 | +# Markdown link syntax wraps URLs as ](url) — the trailing ')' from the wrapper |
| 56 | +# is stripped after matching by balancing parenthesis counts (see below). |
| 57 | +URL_REGEX = re.compile(r"https://[^\s<>\"'`\]]+|http://[^\s<>\"'`\]]+") |
| 58 | + |
| 59 | +# Characters that frequently trail a URL in prose/markdown but are not part of |
| 60 | +# the URL itself. We strip them from the tail of each match. Note: ')' is NOT |
| 61 | +# included here — parentheses are valid URL characters, and Markdown's link |
| 62 | +# wrapper `](url)` is handled separately by balancing paren counts below. |
| 63 | +TRAILING_PUNCT = ".,;:!?]}'\"" |
| 64 | + |
| 65 | +def normalize_url(url: str) -> str: |
| 66 | + """Percent-encode any non-ASCII characters in *url*. |
| 67 | +
|
| 68 | + ``urllib`` only speaks ASCII at the wire level, so a URL containing CJK |
| 69 | + characters (e.g. a Chinese path segment) must be quoted before it is sent. |
| 70 | + Components that are already percent-encoded are preserved (``%`` is in the |
| 71 | + safe set), and URL-structural characters are kept verbatim. |
| 72 | + """ |
| 73 | + parts = urlsplit(url) |
| 74 | + # Each component gets its own safe set so we don't accidentally re-encode |
| 75 | + # characters that have structural meaning in that component. |
| 76 | + safe_path = "/%:@+$,;&=()" |
| 77 | + safe_query = "/%:@+$,;&=()*!?" |
| 78 | + safe_fragment = "/%:@+$,;&=()*!?" |
| 79 | + quoted = parts._replace( |
| 80 | + path=quote(parts.path, safe=safe_path), |
| 81 | + query=quote(parts.query, safe=safe_query), |
| 82 | + fragment=quote(parts.fragment, safe=safe_fragment), |
| 83 | + ) |
| 84 | + return urlunsplit(quoted) |
| 85 | + |
| 86 | + |
| 87 | +# Hosts that are valid placeholders and should not be probed. |
| 88 | +SKIP_HOSTS = { |
| 89 | + "localhost", |
| 90 | + "127.0.0.1", |
| 91 | + "example.com", |
| 92 | + "example.org", |
| 93 | + "example.net", |
| 94 | + "schema.org", |
| 95 | + "fonts.gstatic.com", |
| 96 | +} |
| 97 | + |
| 98 | +DEFAULT_TIMEOUT = 15 |
| 99 | +DEFAULT_WORKERS = 16 |
| 100 | +USER_AGENT = "sharedcourses-url-checker/1.0 (+https://github.com/BetterECNU/SharedCourses)" |
| 101 | + |
| 102 | + |
| 103 | +# --- Data model -------------------------------------------------------------- |
| 104 | + |
| 105 | +@dataclass(frozen=True) |
| 106 | +class UrlResult: |
| 107 | + url: str |
| 108 | + status: int | None |
| 109 | + ok: bool |
| 110 | + note: str = "" |
| 111 | + sources: set[str] = field(default_factory=set) |
| 112 | + |
| 113 | + |
| 114 | +# --- Scanning ---------------------------------------------------------------- |
| 115 | + |
| 116 | +def iter_text_files(root: str, exclude_dirs: set[str], include_ext: set[str]) -> Iterable[str]: |
| 117 | + """Yield paths of text files under *root* that match the filters.""" |
| 118 | + for dirpath, dirnames, filenames in os.walk(root): |
| 119 | + # Prune excluded directories in place so os.walk skips them. |
| 120 | + dirnames[:] = [d for d in dirnames if d not in exclude_dirs] |
| 121 | + for name in filenames: |
| 122 | + ext = os.path.splitext(name)[1].lower() |
| 123 | + if ext in include_ext: |
| 124 | + yield os.path.join(dirpath, name) |
| 125 | + |
| 126 | + |
| 127 | +def find_urls_in_file(path: str, regex: re.Pattern[str]) -> dict[str, set[str]]: |
| 128 | + """Return {url: {relative_paths}} for every URL found in *path*.""" |
| 129 | + try: |
| 130 | + with open(path, "r", encoding="utf-8", errors="ignore") as fh: |
| 131 | + content = fh.read() |
| 132 | + except OSError: |
| 133 | + return {} |
| 134 | + |
| 135 | + url_to_sources: dict[str, set[str]] = {} |
| 136 | + for match in regex.findall(content): |
| 137 | + url = match.rstrip(TRAILING_PUNCT) |
| 138 | + # Drop trailing closing parens that were part of markdown link syntax. |
| 139 | + while url.endswith(")") and url.count("(") < url.count(")"): |
| 140 | + url = url[:-1] |
| 141 | + if not url: |
| 142 | + continue |
| 143 | + url_to_sources.setdefault(url, set()).add(path) |
| 144 | + return url_to_sources |
| 145 | + |
| 146 | + |
| 147 | +def collect_urls(root: str, exclude_dirs: set[str], include_ext: set[str]) -> dict[str, set[str]]: |
| 148 | + """Walk the tree and aggregate every URL with the files it appears in.""" |
| 149 | + aggregated: dict[str, set[str]] = {} |
| 150 | + files_scanned = 0 |
| 151 | + for file_path in iter_text_files(root, exclude_dirs, include_ext): |
| 152 | + files_scanned += 1 |
| 153 | + for url, sources in find_urls_in_file(file_path, URL_REGEX).items(): |
| 154 | + aggregated.setdefault(url, set()).update(sources) |
| 155 | + return aggregated, files_scanned |
| 156 | + |
| 157 | + |
| 158 | +# --- Probing ----------------------------------------------------------------- |
| 159 | + |
| 160 | +def check_url(url: str, timeout: float) -> UrlResult: |
| 161 | + """Probe *url* with a HEAD request, falling back to GET on failure.""" |
| 162 | + # Filter placeholder hosts without importing urllib.parse for every URL. |
| 163 | + host = url.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower() |
| 164 | + if host in SKIP_HOSTS or host.endswith(".local"): |
| 165 | + return UrlResult(url=url, status=None, ok=True, note="skipped (placeholder host)") |
| 166 | + |
| 167 | + headers = {"User-Agent": USER_AGENT} |
| 168 | + # URLs with non-ASCII path segments (e.g. CJK characters) must be quoted |
| 169 | + # before being handed to urllib, otherwise the underlying socket layer |
| 170 | + # raises a UnicodeEncodeError on the host component. |
| 171 | + request_url = normalize_url(url) |
| 172 | + for method in ("HEAD", "GET"): |
| 173 | + try: |
| 174 | + req = request.Request(request_url, headers=headers, method=method) |
| 175 | + with request.urlopen(req, timeout=timeout) as resp: |
| 176 | + status = getattr(resp, "status", resp.getcode()) |
| 177 | + if 200 <= status < 400: |
| 178 | + return UrlResult(url=url, status=status, ok=True, note=f"{method} OK") |
| 179 | + except error.HTTPError as exc: |
| 180 | + # Some servers reject HEAD (405). Try GET before giving up. |
| 181 | + if method == "HEAD" and exc.code in (405, 403, 400): |
| 182 | + continue |
| 183 | + return UrlResult(url=url, status=exc.code, ok=False, |
| 184 | + note=f"{method} HTTPError {exc.code}") |
| 185 | + except error.URLError as exc: |
| 186 | + reason = getattr(exc, "reason", exc) |
| 187 | + return UrlResult(url=url, status=None, ok=False, note=f"{method} URLError: {reason}") |
| 188 | + except (socket.timeout, TimeoutError) as exc: |
| 189 | + return UrlResult(url=url, status=None, ok=False, |
| 190 | + note=f"{method} timeout after {timeout}s") |
| 191 | + except Exception as exc: # noqa: BLE001 — we want every probe to return a result |
| 192 | + return UrlResult(url=url, status=None, ok=False, |
| 193 | + note=f"{method} error: {type(exc).__name__}: {exc}") |
| 194 | + return UrlResult(url=url, status=None, ok=False, note="exhausted HEAD/GET") |
| 195 | + |
| 196 | + |
| 197 | +def probe_all(urls: Iterable[str], timeout: float, workers: int) -> list[UrlResult]: |
| 198 | + """Probe every URL concurrently, preserving the input order in the output.""" |
| 199 | + url_list = list(urls) |
| 200 | + results: dict[str, UrlResult] = {} |
| 201 | + with ThreadPoolExecutor(max_workers=workers) as pool: |
| 202 | + futures = {pool.submit(check_url, url, timeout): url for url in url_list} |
| 203 | + for fut in as_completed(futures): |
| 204 | + url = futures[fut] |
| 205 | + try: |
| 206 | + results[url] = fut.result() |
| 207 | + except Exception as exc: # noqa: BLE001 |
| 208 | + results[url] = UrlResult(url=url, status=None, ok=False, |
| 209 | + note=f"executor error: {type(exc).__name__}: {exc}") |
| 210 | + return [results[url] for url in url_list] |
| 211 | + |
| 212 | + |
| 213 | +# --- Reporting --------------------------------------------------------------- |
| 214 | + |
| 215 | +def format_report(results: list[UrlResult], files_scanned: int, sources: dict[str, set[str]]) -> str: |
| 216 | + total = len(results) |
| 217 | + ok = [r for r in results if r.ok] |
| 218 | + broken = [r for r in results if not r.ok] |
| 219 | + skipped = [r for r in ok if r.note.startswith("skipped")] |
| 220 | + |
| 221 | + lines: list[str] = [] |
| 222 | + lines.append("=" * 72) |
| 223 | + lines.append("URL Check Report") |
| 224 | + lines.append("=" * 72) |
| 225 | + lines.append(f"Files scanned : {files_scanned}") |
| 226 | + lines.append(f"Unique URLs : {total}") |
| 227 | + lines.append(f"OK : {len(ok) - len(skipped)}") |
| 228 | + lines.append(f"Skipped : {len(skipped)}") |
| 229 | + lines.append(f"Broken : {len(broken)}") |
| 230 | + lines.append("") |
| 231 | + |
| 232 | + if broken: |
| 233 | + lines.append("-" * 72) |
| 234 | + lines.append("Broken URLs") |
| 235 | + lines.append("-" * 72) |
| 236 | + for r in broken: |
| 237 | + lines.append(f"[{r.status or '---'}] {r.url}") |
| 238 | + lines.append(f" note : {r.note}") |
| 239 | + sample = sorted(sources.get(r.url, set()))[:3] |
| 240 | + if sample: |
| 241 | + shown = " , ".join(os.path.relpath(p) for p in sample) |
| 242 | + more = len(sources.get(r.url, set())) - len(sample) |
| 243 | + suffix = f" (+{more} more)" if more > 0 else "" |
| 244 | + lines.append(f" in : {shown}{suffix}") |
| 245 | + lines.append("") |
| 246 | + |
| 247 | + if skipped: |
| 248 | + lines.append("-" * 72) |
| 249 | + lines.append("Skipped (placeholder hosts)") |
| 250 | + lines.append("-" * 72) |
| 251 | + for r in skipped: |
| 252 | + lines.append(f" {r.url}") |
| 253 | + lines.append("") |
| 254 | + |
| 255 | + lines.append("=" * 72) |
| 256 | + if broken: |
| 257 | + lines.append(f"Result: FAIL — {len(broken)} broken URL(s) found.") |
| 258 | + else: |
| 259 | + lines.append(f"Result: PASS — all {len(ok) - len(skipped)} reachable URL(s) OK.") |
| 260 | + lines.append("=" * 72) |
| 261 | + return "\n".join(lines) |
| 262 | + |
| 263 | + |
| 264 | +def write_step_summary(results: list[UrlResult], files_scanned: int, |
| 265 | + sources: dict[str, set[str]]) -> None: |
| 266 | + """Append a Markdown summary to GITHUB_STEP_SUMMARY if running in CI.""" |
| 267 | + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") |
| 268 | + if not summary_path: |
| 269 | + return |
| 270 | + total = len(results) |
| 271 | + ok = [r for r in results if r.ok and not r.note.startswith("skipped")] |
| 272 | + skipped = [r for r in results if r.note.startswith("skipped")] |
| 273 | + broken = [r for r in results if not r.ok] |
| 274 | + |
| 275 | + out = ["### 🔗 URL Check", ""] |
| 276 | + out.append(f"- Files scanned: **{files_scanned}**") |
| 277 | + out.append(f"- Unique URLs: **{total}**") |
| 278 | + out.append(f"- OK: **{len(ok)}** | Skipped: {len(skipped)} | Broken: **{len(broken)}**") |
| 279 | + out.append("") |
| 280 | + if broken: |
| 281 | + out.append("#### ❌ Broken URLs") |
| 282 | + out.append("") |
| 283 | + out.append("| Status | URL | Note | File(s) |") |
| 284 | + out.append("| --- | --- | --- | --- |") |
| 285 | + for r in broken: |
| 286 | + srcs = ", ".join( |
| 287 | + f"`{os.path.relpath(p)}`" for p in sorted(sources.get(r.url, set()))[:2] |
| 288 | + ) |
| 289 | + note = r.note.replace("|", "\\|") |
| 290 | + out.append(f"| {r.status or '—'} | {r.url} | {note} | {srcs} |") |
| 291 | + out.append("") |
| 292 | + else: |
| 293 | + out.append("✅ No broken URLs detected.") |
| 294 | + out.append("") |
| 295 | + try: |
| 296 | + with open(summary_path, "a", encoding="utf-8") as fh: |
| 297 | + fh.write("\n".join(out)) |
| 298 | + except OSError: |
| 299 | + pass |
| 300 | + |
| 301 | + |
| 302 | +# --- Entry point ------------------------------------------------------------- |
| 303 | + |
| 304 | +def parse_args() -> argparse.Namespace: |
| 305 | + p = argparse.ArgumentParser(description=__doc__) |
| 306 | + p.add_argument("--root", default=".", help="Repository root to scan (default: .)") |
| 307 | + p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, |
| 308 | + help=f"Per-request timeout in seconds (default: {DEFAULT_TIMEOUT})") |
| 309 | + p.add_argument("--workers", type=int, default=DEFAULT_WORKERS, |
| 310 | + help=f"Concurrent workers (default: {DEFAULT_WORKERS})") |
| 311 | + p.add_argument("--allow-fail", action="store_true", |
| 312 | + help="Exit 0 even when broken URLs are found (still report them).") |
| 313 | + p.add_argument("--only", action="append", default=None, |
| 314 | + help="Limit scan to these comma-separated globs/paths (repeatable).") |
| 315 | + return p.parse_args() |
| 316 | + |
| 317 | + |
| 318 | +def main() -> int: |
| 319 | + args = parse_args() |
| 320 | + root = os.path.abspath(args.root) |
| 321 | + exclude = set(DEFAULT_EXCLUDE_DIRS) |
| 322 | + include = set(DEFAULT_INCLUDE_EXT) |
| 323 | + |
| 324 | + if args.only: |
| 325 | + # When --only is given, scan just those paths but keep the same filters. |
| 326 | + urls: dict[str, set[str]] = {} |
| 327 | + files_scanned = 0 |
| 328 | + for target in args.only: |
| 329 | + for t in target.split(","): |
| 330 | + t = t.strip() |
| 331 | + if not t: |
| 332 | + continue |
| 333 | + full = os.path.join(root, t) if not os.path.isabs(t) else t |
| 334 | + if os.path.isdir(full): |
| 335 | + for fp in iter_text_files(full, exclude, include): |
| 336 | + files_scanned += 1 |
| 337 | + for url, srcs in find_urls_in_file(fp, URL_REGEX).items(): |
| 338 | + urls.setdefault(url, set()).update(srcs) |
| 339 | + elif os.path.isfile(full): |
| 340 | + files_scanned += 1 |
| 341 | + for url, srcs in find_urls_in_file(full, URL_REGEX).items(): |
| 342 | + urls.setdefault(url, set()).update(srcs) |
| 343 | + else: |
| 344 | + urls, files_scanned = collect_urls(root, exclude, include) |
| 345 | + |
| 346 | + if not urls: |
| 347 | + print("No URLs found in the scanned files.") |
| 348 | + return 0 |
| 349 | + |
| 350 | + print(f"Found {len(urls)} unique URL(s) across {files_scanned} file(s). " |
| 351 | + f"Probing with {args.workers} workers (timeout {args.timeout}s)...\n") |
| 352 | + results = probe_all(urls.keys(), args.timeout, args.workers) |
| 353 | + |
| 354 | + print(format_report(results, files_scanned, urls)) |
| 355 | + write_step_summary(results, files_scanned, urls) |
| 356 | + |
| 357 | + broken = [r for r in results if not r.ok] |
| 358 | + if broken and not args.allow_fail: |
| 359 | + return 1 |
| 360 | + return 0 |
| 361 | + |
| 362 | + |
| 363 | +if __name__ == "__main__": |
| 364 | + sys.exit(main()) |
0 commit comments