Skip to content

Commit d24f974

Browse files
Merge branch 'main' into main
2 parents 41cf007 + 7db82bc commit d24f974

5 files changed

Lines changed: 549 additions & 3 deletions

File tree

.github/scripts/check_urls.py

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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

Comments
 (0)