Files
joshuaandClaude Opus 5.5 54ae9c49a1 Tested: static Astro site answering hard Bible questions
Built from the kit specs (CLAUDE.md, docs/01-05): question index import,
status seals, connections graph, Pagefind search, Scripture margin,
and 157 evidence articles (drafts unless published) with sources and
verified "Learn more" links. Includes link, owner-notes, a11y and
publish scripts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:46:19 +02:00

117 lines
4.2 KiB
JavaScript

#!/usr/bin/env node
// Checks every external link in the articles (Learn more links and source URLs).
//
// npm run check-links check all articles
// npm run check-links -- 2-1 7-9 check only these article files
// npm run check-links -- --fresh ignore the cache and re-check everything
//
// YouTube links are checked through YouTube's oEmbed endpoint, which confirms the video exists
// and returns its real title and channel, so you can see that each video is what it claims to be.
// Other links get a normal request. Results are cached in .cache/links.json for a week.
// Needs internet access, so it isn't part of `npm run build`.
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { parse } from "yaml";
const dir = "src/content/articles";
const cacheFile = ".cache/links.json";
const WEEK = 7 * 24 * 60 * 60 * 1000;
const args = process.argv.slice(2);
const fresh = args.includes("--fresh");
const only = args.filter((a) => !a.startsWith("--")).map((a) => a.replace(/\.mdx$/, ""));
const cache = !fresh && existsSync(cacheFile) ? JSON.parse(readFileSync(cacheFile, "utf8")) : {};
const frontmatter = (text) => parse(text.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? "") ?? {};
// Collect links per article.
const jobs = [];
for (const file of readdirSync(dir).filter((f) => f.endsWith(".mdx"))) {
const id = file.replace(/\.mdx$/, "");
if (only.length && !only.includes(id)) continue;
const data = frontmatter(readFileSync(`${dir}/${file}`, "utf8"));
for (const l of data.learnMore ?? [])
jobs.push({ file, url: l.url, title: l.title, type: l.type });
for (const s of data.sources ?? [])
if (s.url) jobs.push({ file, url: s.url, title: s.title, type: "source" });
}
const youtubeId = (url) =>
url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})/)?.[1];
const UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130 Safari/537.36";
async function check(url) {
const hit = cache[url];
if (hit && Date.now() - hit.at < WEEK) return hit;
let result;
try {
const id = youtubeId(url);
if (id) {
const res = await fetch(
`https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${id}`)}&format=json`,
);
result = res.ok
? {
ok: true,
status: res.status,
...(({ title, author_name }) => ({ title, channel: author_name }))(await res.json()),
}
: { ok: false, status: res.status };
} else {
const res = await fetch(url, {
redirect: "follow",
headers: { "User-Agent": UA, Accept: "text/html,application/xhtml+xml,*/*" },
signal: AbortSignal.timeout(20000),
});
// Some sites refuse automated requests (403/429) even though the page exists.
result = {
ok: res.ok,
blocked: [401, 403, 429].includes(res.status),
status: res.status,
final: res.url,
};
}
} catch (e) {
result = { ok: false, status: 0, error: String(e.cause?.code ?? e.message) };
}
result.at = Date.now();
cache[url] = result;
return result;
}
// Check with limited concurrency.
const unique = [...new Set(jobs.map((j) => j.url))];
const queue = [...unique];
await Promise.all(
Array.from({ length: 8 }, async () => {
while (queue.length) await check(queue.shift());
}),
);
mkdirSync(".cache", { recursive: true });
writeFileSync(cacheFile, JSON.stringify(cache, null, 2));
let broken = 0;
let blocked = 0;
const videos = [];
for (const j of jobs) {
const r = cache[j.url];
if (youtubeId(j.url) && r.ok)
videos.push(` ${j.file.padEnd(10)} “${r.title}” — ${r.channel} (listed as: ${j.title})`);
if (r.ok) continue;
if (r.blocked) {
blocked++;
console.log(`? ${j.file.padEnd(10)} ${r.status} (site refuses automated checks) ${j.url}`);
} else {
broken++;
console.log(`✗ ${j.file.padEnd(10)} ${r.status || r.error} ${j.url}`);
}
}
if (videos.length) console.log(`\nVideos (real titles from YouTube):\n${videos.join("\n")}`);
console.log(
`\n${jobs.length} links in ${new Set(jobs.map((j) => j.file)).size} articles: ${broken} broken, ${blocked} could not be checked automatically.`,
);
process.exit(broken ? 1 : 0);