Skip to content

Commit d06c2e7

Browse files
TMHSDigitalclaude
andcommitted
Link check: known script-refusing hosts listed as checked by hand; Evidence licence pages fetched (#89)
- site/config.json linkCheck.manualHosts lists the 17 hosts that refused the checker every week in #77; their entries move from "Refused" to a collapsed "Checked by hand" list with the entry's verified date - the URLs quoted in each entry's ## Evidence are fetched too (once each), and a licence page that no longer loads is its own counted section - lib/link-check.mjs manualHostReason and evidenceUrls, with tests Not done from the issue: no retry with browser-like headers (the checker does not pose as a browser to get past a site's bot blocking), and no "only when changed" diffing of the refused list. Not run live here; the weekly workflow exercises it. Closes #89 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXQPaUhxjgqdqnk6BrJ
1 parent 9a55f1d commit d06c2e7

5 files changed

Lines changed: 105 additions & 7 deletions

File tree

‎site/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ Script options:
213213
| Command | Options |
214214
| --- | --- |
215215
| `node site/build.mjs` | `--clean` empties `site/dist` first (otherwise only changed files are written) |
216-
| `node site/check-links.mjs` | `--report <file.md>` writes the report to a file; `--stale-days <n>` (365) flags entries verified longer ago. Set `GITHUB_TOKEN`: without it the archived-repository checks hit GitHub's anonymous API limit and report "Could not check" |
216+
| `node site/check-links.mjs` | `--report <file.md>` writes the report to a file; `--stale-days <n>` (365) flags entries verified longer ago. Set `GITHUB_TOKEN`: without it the archived-repository checks hit GitHub's anonymous API limit and report "Could not check" It also fetches the licence pages quoted in each entry's Evidence and reports one that no longer loads. Entries on hosts in `site/config.json` → `linkCheck.manualHosts` (hosts that refuse scripts every week) are listed as checked by hand with their verified date, not as refused. |
217217
| `node site/reverify-batch.mjs` | `--report <file.md>`; `--size <n>` overrides the computed batch size |
218218
| `node site/thumbs.mjs` | `--only <id,id>` limits the run to those entries; `--force` refetches records that are not due; `--limit <n>` caps fetches per run; `--dry-run` writes nothing; `--report <file.md>` |
219219
| `node site/serve.mjs` | `--port <n>`, or the `PORT` environment variable (3000) |

‎site/check-links.mjs‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,16 @@ import { safeFetch } from "./lib/safe-fetch.mjs";
2828
import { isListed } from "./lib/shared.mjs";
2929
import { normalizeEntry } from "./lib/entry-fields.mjs";
3030
import { cliArgs } from "./lib/cli.mjs";
31-
import { checkMaintenanceRepos, classify, reportUrl, staleDaysFrom } from "./lib/link-check.mjs";
31+
import { checkMaintenanceRepos, classify, evidenceUrls, manualHostReason, reportUrl, staleDaysFrom } from "./lib/link-check.mjs";
32+
import { evidenceSection } from "./lib/frontmatter.mjs";
3233

3334
const __dirname = path.dirname(fileURLToPath(import.meta.url));
3435
const ROOT = path.resolve(__dirname, "..");
3536
const CATALOG = path.join(ROOT, "catalog");
36-
const REPO = JSON.parse(fs.readFileSync(path.join(__dirname, "config.json"), "utf8")).site.repo;
37+
const CONFIG = JSON.parse(fs.readFileSync(path.join(__dirname, "config.json"), "utf8"));
38+
const REPO = CONFIG.site.repo;
39+
// Hosts that refuse scripts every week (#89): listed as checked by hand.
40+
const MANUAL_HOSTS = CONFIG.linkCheck?.manualHosts || {};
3741
const CONCURRENCY = 8;
3842
const TIMEOUT_MS = 20000;
3943
const UA =
@@ -46,7 +50,9 @@ function loadEntries() {
4650
for (const r of readCatalog(CATALOG, { root: ROOT })) {
4751
if (!usableRecord(r) || !r.parsed.meta.url) continue;
4852
const { id, url, verified, status, maintenance = null } = normalizeEntry(r.parsed.meta, r.rel);
49-
out.push({ id, url, verified, status, maintenance, rel: r.rel });
53+
// The licence pages the Evidence quotes, other than the entry's own url.
54+
const licencePages = evidenceUrls(evidenceSection(r.parsed.body)).filter((u) => u !== url);
55+
out.push({ id, url, verified, status, maintenance, rel: r.rel, licencePages });
5056
}
5157
return out.sort((a, b) => a.id.localeCompare(b.id));
5258
}
@@ -76,6 +82,8 @@ async function run(entries) {
7682
// One retry for anything that did not answer cleanly, except a full
7783
// 20 s timeout: a second one would cost a worker 40 s per dead host.
7884
if (r.kind === "dead" && !r.timedOut) r = await check(entries[i]);
85+
// A refusal is not retried, and the checker never poses as a browser:
86+
// a host that refuses scripts goes on the manual list (config.json).
7987
results[i] = r;
8088
}
8189
})
@@ -107,6 +115,11 @@ if (staleArg.error) {
107115
const staleDays = staleArg.days;
108116
const entries = loadEntries().filter(isListed);
109117
const results = await run(entries);
118+
// Licence pages quoted in Evidence, each fetched once (#89). Only a page that
119+
// is gone is reported; a refusal says nothing about the licence.
120+
const pageJobs = [...new Map(entries.flatMap((e) => e.licencePages.map((url) => [url, { id: e.id, rel: e.rel, url }]))).values()];
121+
const pageResults = await run(pageJobs);
122+
const deadPages = pageJobs.map((j, i) => ({ ...j, ...pageResults[i] })).filter((r) => r.kind === "dead");
110123
const { mismatches: maintenance, skipped } = await checkMaintenance(entries);
111124
const cutoff = new Date(Date.now() - staleDays * 86400000).toISOString().slice(0, 10);
112125
const rows = entries.map((e, i) => ({ ...e, ...results[i] }));
@@ -116,8 +129,10 @@ const line = (r) => `- [ ] [\`${r.id}\`](${REPO}/blob/main/${r.rel}): ${reportUr
116129

117130
const dead = pick("dead");
118131
const moved = pick("moved");
119-
const blocked = pick("blocked");
120-
const problems = dead.length + moved.length + stale.length + maintenance.length;
132+
const refused = pick("blocked");
133+
const blocked = refused.filter((r) => !manualHostReason(r.url, MANUAL_HOSTS));
134+
const byHand = refused.filter((r) => manualHostReason(r.url, MANUAL_HOSTS));
135+
const problems = dead.length + moved.length + stale.length + maintenance.length + deadPages.length;
121136
const date = new Date().toISOString().slice(0, 10);
122137
const report = [
123138
`Link check of ${entries.length} active and needs-review entries, run ${date}.`,
@@ -132,6 +147,10 @@ const report = [
132147
"",
133148
...(moved.length ? moved.map(line) : ["None."]),
134149
"",
150+
`### Licence pages quoted in Evidence that no longer load (${deadPages.length})`,
151+
"",
152+
...(deadPages.length ? deadPages.map(line) : ["None."]),
153+
"",
135154
`### Verified more than ${staleDays} days ago (${stale.length})`,
136155
"",
137156
...(stale.length ? stale.map((e) => `- [ ] [\`${e.id}\`](${REPO}/blob/main/${e.rel}): verified ${e.verified}`) : ["None."]),
@@ -151,10 +170,16 @@ const report = [
151170
"",
152171
"</details>",
153172
"",
173+
`<details><summary>Checked by hand (${byHand.length}): hosts that refuse scripts (site/config.json linkCheck), last read on the verified date</summary>`,
174+
"",
175+
...(byHand.length ? byHand.map((r) => `- [\`${r.id}\`](${REPO}/blob/main/${r.rel}): verified ${r.verified || "never"} (${manualHostReason(r.url, MANUAL_HOSTS)})`) : ["None."]),
176+
"",
177+
"</details>",
178+
"",
154179
].join("\n");
155180

156181
const reportPath = flags.report ?? null;
157182
if (reportPath) fs.writeFileSync(reportPath, report);
158183
else process.stdout.write(report);
159184
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `problems=${problems}\n`);
160-
console.error(`${entries.length} checked: ${dead.length} dead, ${moved.length} moved, ${stale.length} stale, ${maintenance.length} maintenance, ${blocked.length} blocked, ${skipped.length} API checks skipped`);
185+
console.error(`${entries.length} checked: ${dead.length} dead, ${moved.length} moved, ${stale.length} stale, ${maintenance.length} maintenance, ${deadPages.length} dead licence pages, ${blocked.length} blocked, ${byHand.length} checked by hand, ${skipped.length} API checks skipped`);

‎site/config.json‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@
77
"siteUrl": "https://tmhsdigital.github.io/Free-Game-Dev-Assets/"
88
},
99
"expectedEntryCount": 421,
10+
"linkCheck": {
11+
"_comment": "Hosts that refuse scripted requests whatever the source's health (403/429 every week in the link report, #77). check-links lists their entries under 'Checked by hand' with the entry's verified date instead of 'Refused'. Remove a host when it starts answering.",
12+
"manualHosts": {
13+
"fab.com": "Fab answers scripts with 403; read listings in a browser",
14+
"blendkit.com": "answers scripts with 403",
15+
"pixabay.com": "answers scripts with 403",
16+
"pexels.com": "answers scripts with 403",
17+
"zapsplat.com": "answers scripts with 403",
18+
"vidsplay.com": "answers scripts with 403",
19+
"si.edu": "answers scripts with 403",
20+
"myminifactory.com": "answers scripts with 403",
21+
"earthdata.nasa.gov": "answers scripts with 403",
22+
"destockd.com": "answers scripts with 403",
23+
"cloudcompare.org": "answers scripts with 403",
24+
"vroid.com": "answers scripts with 403",
25+
"sonniss.com": "answers scripts with 403",
26+
"simpleicons.org": "answers scripts with 403",
27+
"poly.pizza": "answers scripts with 403",
28+
"musopen.org": "answers scripts with 403",
29+
"freepbr.com": "answers scripts with 403"
30+
}
31+
},
1032
"categories": {
1133
"3d": { "label": "3D", "blurb": "Models, scans, PBR textures", "view": "cards" },
1234
"environment": { "label": "Environment", "blurb": "HDRI, terrain, geodata", "view": "cards" },

‎site/lib/lib.test.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2398,6 +2398,25 @@ await section("shortlist owes and view actions (#110)", async () => {
23982398
for (const id of ["shortlist-owes", "view-actions", "copy-view-link"]) has(`homepage has #${id}`, tpl, `id="${id}"`);
23992399
});
24002400

2401+
/* link check: manual hosts and licence pages (#89) ------------------------- */
2402+
await section("link check: manual hosts and licence pages (#89)", async () => {
2403+
const { manualHostReason, evidenceUrls } = await import("./link-check.mjs");
2404+
const hosts = { "fab.com": "403 to scripts", "earthdata.nasa.gov": "403" };
2405+
eq("manual host: www. is ignored", manualHostReason("https://www.fab.com/listings/x", hosts), "403 to scripts");
2406+
eq("manual host: a subdomain matches", manualHostReason("https://cdn.fab.com/x", hosts), "403 to scripts");
2407+
eq("manual host: a lookalike does not", manualHostReason("https://notfab.com/x", hosts), null);
2408+
eq("manual host: a sibling of a listed subdomain does not", manualHostReason("https://www.nasa.gov/x", hosts), null);
2409+
eq("manual host: not a URL", manualHostReason("nope", hosts), null);
2410+
const ev = "## Evidence\n- Licence page (2026-09-01): https://x.test/licence. \"CC0\"\n- Repo LICENSE (2026-09-01): <https://github.com/o/r/blob/main/LICENSE>\n- Again: https://x.test/licence";
2411+
eq("evidence urls: in order, once, without trailing punctuation", evidenceUrls(ev).join(" "), "https://x.test/licence https://github.com/o/r/blob/main/LICENSE");
2412+
eq("evidence urls: none", evidenceUrls(null).length, 0);
2413+
const fsm = (await import("node:fs")).default;
2414+
const cfg = JSON.parse(fsm.readFileSync(new URL("../config.json", import.meta.url), "utf8"));
2415+
for (const [host, reason] of Object.entries(cfg.linkCheck.manualHosts)) {
2416+
eq(`config manual host ${host} is a bare lower-case host with a reason`, /^[a-z0-9.-]+\.[a-z]+$/.test(host) && !host.startsWith("www.") && reason.length > 0, true);
2417+
}
2418+
});
2419+
24012420
/* report (keep last) ------------------------------------------------------ */
24022421
if (failures.length) {
24032422
console.error(`lib.test failed (${failures.length}):`);

‎site/lib/link-check.mjs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,38 @@ export function siteOf(hostname) {
2121
return labels.slice(-take).join(".");
2222
}
2323

24+
/**
25+
* The reason a host is on the manual-check list (site/config.json
26+
* linkCheck.manualHosts), or null. A listed host matches itself and its
27+
* subdomains (#89).
28+
*/
29+
export function manualHostReason(url, manualHosts = {}) {
30+
let host;
31+
try {
32+
host = new URL(url).hostname.toLowerCase().replace(/^www\./, "");
33+
} catch {
34+
return null;
35+
}
36+
for (const [listed, reason] of Object.entries(manualHosts)) {
37+
if (host === listed || host.endsWith(`.${listed}`)) return reason;
38+
}
39+
return null;
40+
}
41+
42+
/**
43+
* The http(s) URLs quoted in an entry's `## Evidence` section, once each and
44+
* in order, without trailing punctuation: the licence pages the entry rests
45+
* on, which the weekly check fetches too (#89).
46+
*/
47+
export function evidenceUrls(evidence) {
48+
const out = [];
49+
for (const m of String(evidence || "").matchAll(/https?:\/\/[^\s<>"'`)\]]+/g)) {
50+
const url = m[0].replace(/[.,;:!?]+$/, "");
51+
if (!out.includes(url)) out.push(url);
52+
}
53+
return out;
54+
}
55+
2456
/** owner/repo of a github.com URL, lowercased. A transferred repository redirects within github.com. */
2557
export const repoOf = (url) => new URL(url).pathname.split("/").slice(1, 3).join("/").toLowerCase();
2658

0 commit comments

Comments
 (0)