Files
systemic-game-design-course/tools/check-links.mjs
T
Lucas WintherandClaude Opus 5.5 ad0701395d fix(tools): stage course cards from M5, audit noun words without flagging identifiers, Node<24.2 main guard, rename-proof expansion-check, safe check-links
- 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]>
2026-10-29 19:06:56 +01:00

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();
}