Queue-North-Website/scripts/audit-html.js

179 lines
7.2 KiB
JavaScript

#!/usr/bin/env node
//
// Audits what the site actually serves, in two modes.
//
// node scripts/audit-html.js # dist/, run as guard 15-built-html
// node scripts/audit-html.js --url https://queuenorth.com
// node scripts/audit-html.js --url http://localhost:3001 --agents Googlebot
//
// Build mode is a gate: it reads dist/ and refuses on a finding. URL mode
// fetches every page in the live sitemap once per crawler user agent and reports
// what those crawlers actually receive. URL mode is a check to run AFTER a
// deploy, deliberately not wired into deploy.sh: a check that runs after
// publication cannot stop it, and pretending otherwise is worse than not having
// it (GUARDS.md rule 6).
//
// Exit 0 clean, 1 findings, 2 nothing was audited.
import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { services } from '../src/data/services.js'
import { industries } from '../src/data/industries.js'
import { auditLinks, auditPage, auditSitemap, parseSitemap } from './lib/html-audit.js'
import { ROUTES } from './lib/routes.js'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const distDir = path.join(root, 'dist')
// The crawlers this site is written for. A spoofed agent string is not the real
// crawler, and a "verified bots only" rule at the edge would answer this with a
// 403 while serving the real one, so treat a pass as evidence the ORIGIN is not
// blocking, not as proof the crawler is happy.
const AGENTS = {
'OAI-SearchBot': 'Mozilla/5.0 (compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot)',
PerplexityBot: 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)',
ClaudeBot: 'Mozilla/5.0 (compatible; ClaudeBot/1.0; [email protected])',
Googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
bingbot: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
}
const argv = process.argv.slice(2)
const flag = (name) => {
const at = argv.indexOf(`--${name}`)
return at === -1 ? null : argv[at + 1]
}
const origin = flag('url')
const agents = (flag('agents')?.split(',') ?? Object.keys(AGENTS)).filter((name) => AGENTS[name])
const descriptionSource = (routePath) => {
const service = services.find((item) => routePath === `/services/${item.id}`)
if (service) return { shortDesc: service.shortDesc, approvedDescription: Boolean(service.page?.seo?.description) }
const industry = industries.find((item) => routePath === `/industries/${item.id}`)
if (industry) return { shortDesc: industry.shortDesc, approvedDescription: false }
return { shortDesc: null, approvedDescription: false }
}
const report = (findings, checked, what) => {
if (findings.length) {
console.error(`audit: ${findings.length} finding(s) in ${what}:`)
for (const finding of findings) console.error(` ${finding}`)
process.exit(1)
}
console.log(`audit: ${checked} page(s) in ${what}, nothing wrong.`)
}
// --- build mode --------------------------------------------------------------
const newestSourceMtime = () => {
let newest = 0
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else newest = Math.max(newest, statSync(full).mtimeMs)
}
}
for (const dir of ['src', 'public', 'scripts/lib']) walk(path.join(root, dir))
for (const file of ['index.html', 'scripts/prerender.js']) newest = Math.max(newest, statSync(path.join(root, file)).mtimeMs)
return newest
}
const auditBuild = () => {
const sitemapPath = path.join(distDir, 'sitemap.xml')
if (!existsSync(sitemapPath)) {
console.error('audit: dist/sitemap.xml is missing, so the build never finished and NOTHING was audited. Run npm run build.')
process.exit(2)
}
// The prerender writes the sitemap last. A source file newer than it means
// dist/ is stale, and auditing stale output is auditing nothing.
if (newestSourceMtime() > statSync(sitemapPath).mtimeMs) {
console.error('audit: dist/ is older than the sources, so NOTHING was audited. Run npm run build.')
process.exit(2)
}
const entries = parseSitemap(readFileSync(sitemapPath, 'utf8'))
const findings = [...auditSitemap(entries)]
const pages = []
for (const routePath of ROUTES) {
const file = routePath === '/' ? path.join(distDir, 'index.html') : path.join(distDir, routePath, 'index.html')
if (!existsSync(file)) {
findings.push(`${routePath}: the build produced no page, so the server would answer it with 404.html`)
continue
}
const html = readFileSync(file, 'utf8')
pages.push([routePath, html])
findings.push(...auditPage(html, { path: routePath, ...descriptionSource(routePath) }))
}
const notFound = path.join(distDir, '404.html')
if (!existsSync(notFound)) findings.push('404.html: missing from the build')
else findings.push(...auditPage(readFileSync(notFound, 'utf8'), { path: '/404', notFound: true }))
findings.push(...auditLinks(pages, { fileExists: (href) => existsSync(path.join(distDir, href.replace(/^\//, ''))) }))
report(findings, pages.length, 'the build')
}
// --- url mode ----------------------------------------------------------------
const fetchAs = async (url, agent) => {
const response = await fetch(url, {
headers: { 'User-Agent': AGENTS[agent] },
redirect: 'manual',
signal: AbortSignal.timeout(20000),
})
return { status: response.status, body: await response.text() }
}
const auditOrigin = async () => {
let entries
try {
const response = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(20000) })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
entries = parseSitemap(await response.text())
} catch (error) {
console.error(`audit: could not read ${origin}/sitemap.xml (${error.message}), so NOTHING was audited.`)
process.exit(2)
}
if (!entries.length) {
console.error(`audit: ${origin}/sitemap.xml lists no pages, so NOTHING was audited.`)
process.exit(2)
}
const findings = [...auditSitemap(entries, { requireLastmod: true })]
const pages = []
for (const entry of entries) {
const url = `${origin}${entry.path}`
const bodies = new Map()
for (const agent of agents) {
let result
try {
result = await fetchAs(url, agent)
} catch (error) {
findings.push(`${entry.path}: ${agent} could not fetch it (${error.message})`)
continue
}
if (result.status !== 200) findings.push(`${entry.path}: ${agent} got HTTP ${result.status}`)
bodies.set(agent, result.body)
}
const distinct = new Set(bodies.values())
if (distinct.size > 1) {
findings.push(`${entry.path}: crawlers were served different bytes (${bodies.size} agents, ${distinct.size} versions)`)
}
const body = bodies.values().next().value
if (body) {
pages.push([entry.path, body])
findings.push(...auditPage(body, { path: entry.path, ...descriptionSource(entry.path) }))
}
}
findings.push(...auditLinks(pages, { fileExists: () => true }))
report(findings, pages.length, `${origin} as ${agents.length} crawler(s)`)
}
if (origin) await auditOrigin()
else auditBuild()