- cardDirs: course/cards only from Module 5 (spec 6.2a); fixture modules 04/05 and tests - coupling-audit: noun words match as UPPERCASE, PascalCase or a whole string literal; lowercase identifiers like key/flag pass; card names keep every variant - isMain() argv guard replaces import.meta.main in coupling-audit and verify-references; engines.node >=22 - expansion-check: git diff --no-renames so moving engine code into content fails - start-module and other tools: clear error when .course.json is missing - check-links: malformed URLs reported as invalid URL instead of throwing Co-Authored-By: Claude Opus 5.5 <[email protected]>
149 lines
4.9 KiB
JavaScript
149 lines
4.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Report-only external link check. Lists every external URL found in course/,
|
|
// docs/research/ and README.md with its HTTP status. Always exits 0.
|
|
//
|
|
// node tools/check-links.mjs (or: npm run check:links)
|
|
//
|
|
// Reddit URLs are listed as "unverifiable": reddit blocks automated fetches,
|
|
// and the course already excludes reddit as a source.
|
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join, relative, extname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".astro", ".git"]);
|
|
const EXTS = new Set([".md", ".json", ".rs", ".ts", ".mjs"]);
|
|
const TIMEOUT_MS = 20000;
|
|
const CONCURRENCY = 6;
|
|
const HOST_GAP_MS = 1000;
|
|
|
|
/** Extract http(s) URLs from text. Handles markdown links and trailing punctuation. */
|
|
export function extractUrls(text) {
|
|
const out = [];
|
|
for (const m of text.matchAll(/https?:\/\/[^\s<>"'`\]]+/g)) {
|
|
let url = m[0];
|
|
// Drop trailing sentence punctuation.
|
|
url = url.replace(/[.,;:!?*]+$/, "");
|
|
// Drop closing parens that aren't balanced inside the URL (markdown "(url)").
|
|
while (url.endsWith(")") && count(url, "(") < count(url, ")")) {
|
|
url = url.slice(0, -1).replace(/[.,;:!?*]+$/, "");
|
|
}
|
|
out.push(url);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const count = (s, ch) => s.split(ch).length - 1;
|
|
|
|
function* walk(path) {
|
|
const st = statSync(path);
|
|
if (st.isFile()) {
|
|
if (EXTS.has(extname(path))) yield path;
|
|
return;
|
|
}
|
|
for (const e of readdirSync(path)) {
|
|
if (SKIP_DIRS.has(e)) continue;
|
|
yield* walk(join(path, e));
|
|
}
|
|
}
|
|
|
|
/** Hostname of `url`, or null when `url` does not parse (reported as "invalid URL"). */
|
|
export function hostOf(url) {
|
|
try {
|
|
return new URL(url).hostname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function classifyUnverifiable(url) {
|
|
const host = hostOf(url);
|
|
if (host === null) return "invalid URL";
|
|
if (/(^|\.)reddit\.com$/.test(host)) return "unverifiable (reddit blocks fetch tools; excluded as a source)";
|
|
return null;
|
|
}
|
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
async function get(url, method) {
|
|
return fetch(url, { method, redirect: "follow", signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
}
|
|
|
|
// Fandom answers 403 to scripts. Its public MediaWiki API still says whether the page exists.
|
|
async function fandomExists(url) {
|
|
const u = new URL(url);
|
|
const m = u.hostname.endsWith(".fandom.com") && u.pathname.match(/^\/wiki\/(.+)$/);
|
|
if (!m) return null;
|
|
const api = `${u.origin}/api.php?action=query&format=json&titles=${m[1]}`;
|
|
try {
|
|
const pages = (await (await get(api, "GET")).json()).query?.pages ?? {};
|
|
return Object.keys(pages).some((k) => k !== "-1") ? "page exists per MediaWiki API" : "page MISSING per MediaWiki API";
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function status(url) {
|
|
try {
|
|
let r = await get(url, "HEAD");
|
|
if (r.status >= 400) r = await get(url, "GET");
|
|
for (let tries = 0; r.status === 429 && tries < 3; tries++) {
|
|
const wait = Math.min(Number(r.headers.get("retry-after")) || 10, 30);
|
|
await sleep(wait * 1000);
|
|
r = await get(url, "GET");
|
|
}
|
|
if (r.status === 403 || r.status === 402) {
|
|
const note = await fandomExists(url);
|
|
if (note) return `${r.status} (${note})`;
|
|
}
|
|
return String(r.status);
|
|
} catch (e) {
|
|
return `error (${e.cause?.code ?? e.name})`;
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const root = join(fileURLToPath(import.meta.url), "..", "..");
|
|
const seen = new Map(); // url -> Set(file)
|
|
for (const top of ["course", "docs/research", "README.md"]) {
|
|
for (const f of walk(join(root, top))) {
|
|
for (const url of extractUrls(readFileSync(f, "utf8"))) {
|
|
if (!seen.has(url)) seen.set(url, new Set());
|
|
seen.get(url).add(relative(root, f));
|
|
}
|
|
}
|
|
}
|
|
const urls = [...seen.keys()].sort();
|
|
// One request at a time per host (wikis rate-limit), up to CONCURRENCY hosts at once.
|
|
const byHost = new Map();
|
|
for (const url of urls) {
|
|
const host = hostOf(url) ?? "(invalid)";
|
|
if (!byHost.has(host)) byHost.set(host, []);
|
|
byHost.get(host).push(url);
|
|
}
|
|
const queues = [...byHost.values()];
|
|
const results = new Map();
|
|
await Promise.all(
|
|
Array.from({ length: CONCURRENCY }, async () => {
|
|
for (let q = queues.shift(); q; q = queues.shift()) {
|
|
for (const url of q) {
|
|
results.set(url, classifyUnverifiable(url) ?? (await status(url)));
|
|
await sleep(HOST_GAP_MS);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
const tally = {};
|
|
for (const url of urls) {
|
|
const s = results.get(url);
|
|
const key = /^\d/.test(s) ? s : s.split(" ")[0];
|
|
tally[key] = (tally[key] ?? 0) + 1;
|
|
console.log(`${s}\t${url}\t${[...seen.get(url)].join(", ")}`);
|
|
}
|
|
console.log(`\n${urls.length} unique external URLs. By status: ${JSON.stringify(tally)}`);
|
|
console.log("Report only: exit code is always 0.");
|
|
}
|
|
|
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
|
await main();
|
|
}
|