mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
The one-off version of this found that 118 of 144 docs.hanzo.ai links and 43 of 210 github.com links were dead, including the shared footer's Quickstarts link on all 950 pages and /computer's primary call to action. A build, a typecheck and 102 gates were green on every one of them, because none of them asks what an href points AT. It is a tool, not a gate, and must stay one: e2e/gates/links.spec.ts checks internal links against out/ and stays offline on purpose, because a suite that fetches the internet fails when somebody else's host is slow. Three things it does that a naive checker does not, each learned by getting it wrong first: 403 IS NOT DEAD -- npmjs.com and openai.com refuse a script and serve a browser. Only 404 and 410 count; the rest is reported separately for a human. A SITEMAP IS NOT EVIDENCE -- docs.hanzo.ai lists pages in sitemap.xml that answer 404. Ask the URL. PRIVATE IS NOT MISSING -- a private GitHub repo 404s anonymously. Both are dead for a reader and the remedies are opposite (publish the repo, or drop the link), so --github re-asks with gh when it is authenticated. Co-authored-by: Hanzo Dev <[email protected]>
128 lines
5.0 KiB
JavaScript
128 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Ask every address the export links to whether it answers.
|
|
*
|
|
* This is NOT a gate and must not become one: `e2e/gates/links.spec.ts` checks
|
|
* internal links against `out/` and stays offline on purpose, because a suite
|
|
* that fetches the internet fails when someone else's host is slow. This is the
|
|
* other half — the outbound links — and it is a tool you RUN, whose output is
|
|
* read by a person.
|
|
*
|
|
* It exists because the one-off version of it found that 118 of 144
|
|
* docs.hanzo.ai links and 43 of 210 github.com links were dead, including the
|
|
* shared footer's Quickstarts link on all 950 pages and /computer's primary
|
|
* call to action. None of that was visible to a build, a typecheck or 102 gates.
|
|
*
|
|
* Three things it does that a naive checker does not, each learned by getting
|
|
* it wrong first:
|
|
*
|
|
* - 403 IS NOT DEAD. npmjs.com and openai.com refuse a script and serve a
|
|
* browser. Only 404 and 410 count; everything else is reported separately
|
|
* so a human decides.
|
|
* - A SITEMAP IS NOT EVIDENCE. docs.hanzo.ai lists pages in sitemap.xml that
|
|
* answer 404. Ask the URL.
|
|
* - PRIVATE IS NOT MISSING. A private GitHub repo 404s anonymously. Both are
|
|
* dead for a reader and the remedies are opposite — publish the repo, or
|
|
* drop the link — so `--github` re-asks with `gh` when it is authenticated.
|
|
*
|
|
* Usage: node scripts/link-audit.mjs [--host docs.hanzo.ai] [--github] [--json]
|
|
*/
|
|
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
import { join, relative } from 'node:path'
|
|
import { execFileSync } from 'node:child_process'
|
|
|
|
const OUT = 'out'
|
|
const args = process.argv.slice(2)
|
|
const only = args.includes('--host') ? args[args.indexOf('--host') + 1] : null
|
|
const wantGithub = args.includes('--github')
|
|
const asJson = args.includes('--json')
|
|
|
|
if (!existsSync(OUT)) {
|
|
console.error('no out/ — run `pnpm build` first; this reads the bytes that ship')
|
|
process.exit(2)
|
|
}
|
|
|
|
/** Every external URL in the export, with the pages that carry it. */
|
|
function collect() {
|
|
const urls = new Map()
|
|
const walk = (dir) => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
const p = join(dir, e.name)
|
|
if (e.isDirectory()) { walk(p); continue }
|
|
if (!e.name.endsWith('.html')) continue
|
|
for (const m of readFileSync(p, 'utf8').matchAll(/href="(https?:\/\/[^"]+)"/g)) {
|
|
const u = m[1].split('#')[0].replace(/\/+$/, '')
|
|
let host
|
|
try { host = new URL(u).host } catch { continue }
|
|
if (only && host !== only) continue
|
|
if (!urls.has(u)) urls.set(u, new Set())
|
|
urls.get(u).add('/' + relative(OUT, p).replace(/\.html$/, ''))
|
|
}
|
|
}
|
|
}
|
|
walk(OUT)
|
|
return urls
|
|
}
|
|
|
|
async function status(url) {
|
|
try {
|
|
const c = new AbortController()
|
|
const t = setTimeout(() => c.abort(), 20000)
|
|
const r = await fetch(url, {
|
|
signal: c.signal, redirect: 'follow',
|
|
headers: { 'user-agent': 'Mozilla/5.0 (compatible; hanzo-link-audit)' },
|
|
})
|
|
clearTimeout(t)
|
|
return r.status
|
|
} catch { return 'unreachable' }
|
|
}
|
|
|
|
/** A GitHub 404 is either "gone" or "private", and only an authenticated ask can tell. */
|
|
function githubKind(url) {
|
|
const m = /^https:\/\/github\.com\/([^/]+)\/([^/?#]+)$/.exec(url)
|
|
if (!m) return null
|
|
try {
|
|
const out = execFileSync('gh', ['api', `repos/${m[1]}/${m[2]}`, '--jq', '.private'],
|
|
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
return out === 'true' ? 'private' : out === 'false' ? 'public' : 'missing'
|
|
} catch { return 'missing' }
|
|
}
|
|
|
|
const urls = collect()
|
|
const list = [...urls.keys()]
|
|
const results = []
|
|
let i = 0
|
|
await Promise.all(Array.from({ length: 6 }, async () => {
|
|
while (i < list.length) {
|
|
const u = list[i++]
|
|
results.push({ url: u, status: await status(u), pages: [...urls.get(u)] })
|
|
}
|
|
}))
|
|
|
|
const dead = results.filter((r) => r.status === 404 || r.status === 410)
|
|
const odd = results.filter((r) => r.status !== 200 && !dead.includes(r))
|
|
|
|
if (wantGithub) for (const r of dead) if (r.url.startsWith('https://github.com/')) r.kind = githubKind(r.url)
|
|
|
|
if (asJson) { console.log(JSON.stringify({ results }, null, 1)); process.exit(dead.length ? 1 : 0) }
|
|
|
|
console.log(`[links] ${results.length} external URLs · ${results.length - dead.length - odd.length} answer · ${dead.length} DEAD · ${odd.length} other`)
|
|
const byHost = new Map()
|
|
for (const r of dead) {
|
|
const h = new URL(r.url).host
|
|
if (!byHost.has(h)) byHost.set(h, [])
|
|
byHost.get(h).push(r)
|
|
}
|
|
for (const [h, rows] of [...byHost].sort((a, b) => b[1].length - a[1].length)) {
|
|
console.log(`\n${h} — ${rows.length} dead`)
|
|
for (const r of rows.sort((a, b) => b.pages.length - a.pages.length).slice(0, 40)) {
|
|
const kind = r.kind ? ` [${r.kind}]` : ''
|
|
console.log(` ${String(r.pages.length).padStart(4)} pages ${r.url.replace(`https://${h}`, '') || '/'}${kind}`)
|
|
}
|
|
}
|
|
if (odd.length) {
|
|
console.log(`\nnot 200 and not dead — read these, do not assume:`)
|
|
for (const r of odd.slice(0, 20)) console.log(` ${String(r.status).padStart(11)} ${r.url}`)
|
|
}
|
|
process.exit(dead.length ? 1 : 0)
|