mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-30 14:58:08 +00:00
Search Console had 5,812 hanzo.ai URLs as 404, 1,323 as duplicates without a canonical and 165 as noindex. Three causes: the export shipped a noindex page that forwarded in the browser at each retired address, every page also answered at /x/, /x.html and www, and each page's .md twin was an indexable copy of it. Retired addresses are now 301s at the edge, rendered into hanzoai/universe by scripts/edge.mjs from lib/edge.ts: MOVED grows the old product taxonomy, the first site's routes, pages later dropped and the gateway's old docs; SHELVES sends the rest of /docs/ to docs.hanzo.ai; and lib/data/retired.json holds the 256 model pages the catalog has dropped, read against the catalog each build renders. The shells go, except the four that are Go import paths, which ?go-get=1 still reads. The sitemap lists only pages at their own address, each with a lastmod from its files' last commit, so deploy and gates check out the whole history. /privacy, /terms and /cookies stay up for the shared footer but point at /legal/*, so they leave the sitemap. /pay and /v1 join the Disallow list. Gates run the edge's rules over the export: every retired address lands on a page in one hop, every page answers at one address, every sitemap URL is final and dated, and no page links a redirect. scripts/crawl.mjs asks production the same questions, and reads a Search Console export.
136 lines
6.2 KiB
TypeScript
136 lines
6.2 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
import { readFileSync } from 'node:fs'
|
|
import oss from '../../lib/data/oss.json' with { type: 'json' }
|
|
import { answer } from '../../lib/edge'
|
|
import { forward } from '../../lib/moved'
|
|
import { NOINDEX, indexable } from '../../lib/publish'
|
|
import { copy } from '../../lib/routes'
|
|
import { pages, read, serveExport } from './export'
|
|
|
|
/**
|
|
* The sitemap and the pages agree, in both directions, and nothing links a
|
|
* repository a reader cannot open.
|
|
*
|
|
* every URL offered renders served, with a heading and words to read
|
|
* every public page is offered the dynamic segments included
|
|
* every offered page is linked from some other page, or it is an orphan
|
|
* every GitHub link opens a repository of ours must be public
|
|
* every URL offered is final one self-canonical, no noindex, no redirect, a lastmod
|
|
* no link redirects the export links each page at its one address
|
|
*/
|
|
|
|
/** Every route sitemap.xml offers. */
|
|
function offered(): string[] {
|
|
return [...read('sitemap.xml').matchAll(/<loc>https:\/\/hanzo\.ai([^<]*)<\/loc>/g)].map((m) => m[1] || '/')
|
|
}
|
|
|
|
/** Every same-site path the export's pages link to, as the path it names. */
|
|
function linked(): Map<string, Set<string>> {
|
|
const into = new Map<string, Set<string>>()
|
|
for (const { route, file } of pages()) {
|
|
for (const m of readFileSync(file, 'utf8').matchAll(/href="((?:https:\/\/hanzo\.ai)?\/[^"#?]*)/g)) {
|
|
const to = m[1].replace(/^https:\/\/hanzo\.ai/, '').replace(/\/+$/, '') || '/'
|
|
if (to === route) continue
|
|
if (!into.has(to)) into.set(to, new Set())
|
|
into.get(to)!.add(route)
|
|
}
|
|
}
|
|
return into
|
|
}
|
|
|
|
test('every URL the sitemap offers renders', async ({ request }) => {
|
|
const site = await serveExport()
|
|
try {
|
|
const urls = offered()
|
|
expect(urls.length, 'the sitemap offers the site').toBeGreaterThan(500)
|
|
const broken: string[] = []
|
|
for (const route of urls) {
|
|
const res = await request.get(site.url + encodeURI(route))
|
|
const html = await res.text()
|
|
if (res.status() !== 200) broken.push(`${route} → ${res.status()}`)
|
|
else if (!/<h1[\s>]/.test(html)) broken.push(`${route} → no <h1>`)
|
|
else if (copy(html).length < 200) broken.push(`${route} → ${copy(html).length} characters`)
|
|
}
|
|
expect(broken, 'offered in sitemap.xml and does not render a page').toEqual([])
|
|
} finally {
|
|
await site.close()
|
|
}
|
|
})
|
|
|
|
test('every public page is in the sitemap', () => {
|
|
const inMap = new Set(offered())
|
|
const missing = pages()
|
|
.map(({ route }) => route)
|
|
.filter((route) => indexable(route) && !inMap.has(route))
|
|
expect(missing, 'public, shipped, and not offered to a crawler').toEqual([])
|
|
})
|
|
|
|
test('every page the sitemap offers is linked from another page', () => {
|
|
const into = linked()
|
|
const orphans = offered().filter((route) => route !== '/' && !into.get(route)?.size)
|
|
expect(orphans, 'offered in sitemap.xml and linked from nowhere').toEqual([])
|
|
})
|
|
|
|
test('no page links a repository of ours that a reader cannot open', () => {
|
|
const ours = new Set(oss.orgs.map((o) => o.name))
|
|
const open = new Set(oss.public)
|
|
const closed = new Map<string, Set<string>>()
|
|
for (const { route, file } of pages()) {
|
|
for (const m of readFileSync(file, 'utf8').matchAll(/href="https:\/\/github\.com\/([^/"#?]+)\/([^/"#?]+)/g)) {
|
|
const repo = `${m[1]}/${m[2].replace(/\.git$/, '')}`
|
|
if (!ours.has(m[1]) || open.has(repo)) continue
|
|
if (!closed.has(repo)) closed.set(repo, new Set())
|
|
closed.get(repo)!.add(route)
|
|
}
|
|
}
|
|
const report = [...closed].map(([repo, from]) => `${repo} <- ${[...from].slice(0, 3).join(', ')}`)
|
|
expect(report, 'links to repositories that are private or gone').toEqual([])
|
|
})
|
|
|
|
test('every URL the sitemap offers is final: one self-canonical, no noindex, no redirect, a lastmod', () => {
|
|
const files = new Map(pages().map(({ route, file }) => [route, file]))
|
|
const entries = [...read('sitemap.xml').matchAll(/<url>([\s\S]*?)<\/url>/g)].map((m) => m[1])
|
|
expect(entries.length, 'the sitemap offers the site').toBeGreaterThan(500)
|
|
const wrong: string[] = []
|
|
for (const entry of entries) {
|
|
const loc = entry.match(/<loc>([^<]+)<\/loc>/)?.[1] ?? ''
|
|
const route = loc.replace(/^https:\/\/hanzo\.ai/, '') || '/'
|
|
const lastmod = entry.match(/<lastmod>([^<]+)<\/lastmod>/)?.[1]
|
|
if (!lastmod || Number.isNaN(Date.parse(lastmod))) wrong.push(`${loc}: no lastmod`)
|
|
if (forward(route)) wrong.push(`${loc}: retired, the edge sends it to ${forward(route)}`)
|
|
const a = answer(route === '/' ? 'https://hanzo.ai/' : loc)
|
|
if ('location' in a) wrong.push(`${loc}: redirects to ${a.location}`)
|
|
const file = files.get(route)
|
|
if (!file) {
|
|
wrong.push(`${loc}: no page`)
|
|
continue
|
|
}
|
|
const html = readFileSync(file, 'utf8')
|
|
const head = html.slice(0, html.indexOf('</head>'))
|
|
const canonical = [...head.matchAll(/<link[^>]+rel="canonical"[^>]*href="([^"]+)"/g)].map((m) => m[1])
|
|
if (canonical.length !== 1 || canonical[0] !== loc) wrong.push(`${loc}: canonical ${canonical.join(' ') || 'missing'}`)
|
|
if (html.includes(NOINDEX) || /<meta[^>]+name="robots"[^>]+noindex/.test(head)) wrong.push(`${loc}: noindex`)
|
|
}
|
|
expect(wrong, 'offered in sitemap.xml and not a final, indexable, canonical page').toEqual([])
|
|
})
|
|
|
|
test('no page links an address the edge redirects', () => {
|
|
// Every same-site href in the export, deduplicated, asked of the edge's own
|
|
// rules: a trailing slash, a .html, www, http or a retired address is a
|
|
// redirect a crawler follows and reports, from a link we wrote.
|
|
const from = new Map<string, string>()
|
|
for (const { route, file } of pages()) {
|
|
for (const m of readFileSync(file, 'utf8').matchAll(/href="((?:https?:\/\/(?:www\.)?hanzo\.ai)?\/(?!\/)[^"#]*)"/g)) {
|
|
if (!from.has(m[1])) from.set(m[1], route)
|
|
}
|
|
}
|
|
const redirected = [...from]
|
|
.map(([href, route]) => {
|
|
const a = answer(href.startsWith('/') ? `https://hanzo.ai${href}` : href.replace(/^http:/, 'https:'))
|
|
if (href.startsWith('http://')) return `${href} on ${route}: http`
|
|
return 'location' in a ? `${href} on ${route} -> ${a.location}` : null
|
|
})
|
|
.filter(Boolean)
|
|
expect(redirected, 'links to an address that answers with a redirect').toEqual([])
|
|
})
|