|
1 | 1 | import assert from "node:assert/strict"; |
2 | 2 | import { readFile } from "node:fs/promises"; |
| 3 | +import { execFileSync } from "node:child_process"; |
3 | 4 | import { onRequest } from "../functions/_middleware.js"; |
4 | 5 |
|
5 | 6 | const origin = "https://global-address.com"; |
| 7 | +const excludedHtml = new Set(["404.html", "baidu_verify_codeva-DlQjPzG0IB.html"]); |
| 8 | +const criticalPages = { |
| 9 | + "index.html": { |
| 10 | + title: "地址生成器 - 免费生成多国随机地址、姓名和电话", |
| 11 | + canonical: `${origin}/`, |
| 12 | + h1: "地址生成器", |
| 13 | + }, |
| 14 | + "us-address-generator.html": { |
| 15 | + title: "美国地址生成器 - 随机美国地址 | Global Address Generator", |
| 16 | + canonical: `${origin}/us-address-generator`, |
| 17 | + h1: "美国地址生成器", |
| 18 | + }, |
| 19 | + "japan-address-generator.html": { |
| 20 | + title: "日本地址随机生成器 - Global Address Generator", |
| 21 | + canonical: `${origin}/japan-address-generator`, |
| 22 | + h1: "日本地址随机生成器", |
| 23 | + }, |
| 24 | + "uk-address-generator.html": { |
| 25 | + title: "英国地址生成器 - Global Address Generator", |
| 26 | + canonical: `${origin}/uk-address-generator`, |
| 27 | + h1: "随机生成英国地址", |
| 28 | + }, |
| 29 | + "korea-address-generator.html": { |
| 30 | + title: "韩国地址生成器 - Global Address Generator", |
| 31 | + canonical: `${origin}/korea-address-generator`, |
| 32 | + h1: "随机生成韩国地址", |
| 33 | + }, |
| 34 | + "eu-address-generator.html": { |
| 35 | + title: "欧盟国家地址生成器 - Global Address Generator", |
| 36 | + canonical: `${origin}/eu-address-generator`, |
| 37 | + h1: "随机生成欧盟国家地址", |
| 38 | + }, |
| 39 | + "southeast-asia-address-generator.html": { |
| 40 | + title: "东南亚地址生成器 - Global Address Generator", |
| 41 | + canonical: `${origin}/southeast-asia-address-generator`, |
| 42 | + h1: "随机生成东南亚国家地址", |
| 43 | + }, |
| 44 | + "guides.html": { |
| 45 | + title: "地址知识中心 - 美国地址生成器", |
| 46 | + canonical: `${origin}/guides`, |
| 47 | + h1: "地址知识中心", |
| 48 | + }, |
| 49 | +}; |
| 50 | + |
| 51 | +function trackedHtmlFiles() { |
| 52 | + return execFileSync("git", ["ls-files", "*.html"], { encoding: "utf8" }) |
| 53 | + .trim() |
| 54 | + .split("\n") |
| 55 | + .filter(Boolean) |
| 56 | + .filter((file) => !excludedHtml.has(file)); |
| 57 | +} |
| 58 | + |
| 59 | +function canonicalPathForFile(file) { |
| 60 | + if (file === "index.html") return "/"; |
| 61 | + if (file === "en/index.html") return "/en/"; |
| 62 | + return `/${file.replace(/\.html$/, "")}`; |
| 63 | +} |
| 64 | + |
| 65 | +function matchText(html, pattern, label) { |
| 66 | + const match = html.match(pattern); |
| 67 | + assert.ok(match, `${label} must exist`); |
| 68 | + return match[1].replace(/<[^>]+>/g, "").trim(); |
| 69 | +} |
6 | 70 |
|
7 | 71 | async function middlewareResult(path, hostname = "global-address.com") { |
8 | 72 | return onRequest({ |
@@ -51,4 +115,64 @@ assert.match( |
51 | 115 | "sitemap must include the canonical homepage", |
52 | 116 | ); |
53 | 117 |
|
54 | | -console.log(`SEO regression checks passed (${redirectCases.length + 4} assertions groups).`); |
| 118 | +const sitemapLocations = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]); |
| 119 | +assert.equal(new Set(sitemapLocations).size, sitemapLocations.length, "sitemap URLs must be unique"); |
| 120 | + |
| 121 | +for (const location of sitemapLocations) { |
| 122 | + const url = new URL(location); |
| 123 | + assert.equal(url.origin, origin, `sitemap URL must use the canonical origin: ${location}`); |
| 124 | + assert.ok(!url.pathname.endsWith(".html"), `sitemap URL must be extensionless: ${location}`); |
| 125 | + assert.ok(!url.pathname.endsWith("/index"), `sitemap URL must not expose index paths: ${location}`); |
| 126 | +} |
| 127 | + |
| 128 | +const htmlFiles = trackedHtmlFiles(); |
| 129 | +const expectedLocations = new Set(htmlFiles.map((file) => `${origin}${canonicalPathForFile(file)}`)); |
| 130 | +assert.deepEqual( |
| 131 | + new Set(sitemapLocations), |
| 132 | + expectedLocations, |
| 133 | + "sitemap must contain every indexable HTML page exactly once", |
| 134 | +); |
| 135 | + |
| 136 | +for (const file of htmlFiles) { |
| 137 | + const html = await readFile(new URL(`../${file}`, import.meta.url), "utf8"); |
| 138 | + const canonical = matchText( |
| 139 | + html, |
| 140 | + /<link\s+rel="canonical"\s+href="([^"]+)"/i, |
| 141 | + `${file} canonical`, |
| 142 | + ); |
| 143 | + assert.equal( |
| 144 | + canonical, |
| 145 | + `${origin}${canonicalPathForFile(file)}`, |
| 146 | + `${file} canonical must match its extensionless URL`, |
| 147 | + ); |
| 148 | + assert.doesNotMatch(html, /<meta\s+name="robots"\s+content="[^"]*noindex/i, `${file} must remain indexable`); |
| 149 | + |
| 150 | + for (const hrefMatch of html.matchAll(/href="([^"]+)"/g)) { |
| 151 | + const href = hrefMatch[1]; |
| 152 | + if (href.startsWith("./") || href.startsWith("/")) { |
| 153 | + assert.ok(!/\.html(?:[?#]|$)/.test(href), `${file} contains a legacy internal .html link: ${href}`); |
| 154 | + assert.ok(!/(?:^|\/)index(?:[?#]|$)/.test(href), `${file} contains a legacy internal index link: ${href}`); |
| 155 | + } |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +for (const [file, expected] of Object.entries(criticalPages)) { |
| 160 | + const html = await readFile(new URL(`../${file}`, import.meta.url), "utf8"); |
| 161 | + assert.equal(matchText(html, /<title>([^<]+)<\/title>/i, `${file} title`), expected.title); |
| 162 | + assert.equal( |
| 163 | + matchText(html, /<link\s+rel="canonical"\s+href="([^"]+)"/i, `${file} canonical`), |
| 164 | + expected.canonical, |
| 165 | + ); |
| 166 | + assert.equal(matchText(html, /<h1[^>]*>([\s\S]*?)<\/h1>/i, `${file} h1`), expected.h1); |
| 167 | +} |
| 168 | + |
| 169 | +const homeH1Position = home.indexOf('<h1 id="pageTitle">地址生成器</h1>'); |
| 170 | +const countrySectionPosition = home.indexOf('<section id="countries"'); |
| 171 | +const qaLabPosition = home.indexOf('<section class="reference-section qa-promo"'); |
| 172 | +assert.ok(homeH1Position >= 0, "homepage must keep the address generator H1"); |
| 173 | +assert.ok(countrySectionPosition > homeH1Position, "country generators must follow the homepage H1"); |
| 174 | +assert.ok(qaLabPosition > countrySectionPosition, "QA Lab must not appear before the country generators"); |
| 175 | + |
| 176 | +console.log( |
| 177 | + `SEO regression checks passed (${htmlFiles.length} indexable pages, ${sitemapLocations.length} sitemap URLs).`, |
| 178 | +); |
0 commit comments