badseo.dev:OpenSEO 审计引擎的 e2e 测试靶场——从 SEO 缺陷 Fixture 到可运行审计断言
【免费下载链接】open-seoOpen source alternative to Semrush and Ahrefs项目地址: https://gitcode.com/GitHub_Trending/op/open-seo
badseo.dev 是 OpenSEO 仓库中一个“故意做坏”的网站:每一页只犯一类常见技术 SEO 错误,同时它也是 OpenSEO site audit 的端到端测试夹具——一个 harness 驱动真实审计引擎爬取站点,断言每页恰好触发它声明的问题。读完本文,你可以理解这套“缺陷即测试”的 e2e 架构、Fixture 的类型化契约与字节级响应控制实现,并能按步骤在本地运行审计、按规范新增自己的缺陷页面。
1. 定位:SEO 反面教材 + 审计引擎的回归测试
仓库根目录下的 badseo/README.md 对它的定义是:
A test site full of SEO mistakes.badseo.dev is a set of open-source web pages. Each page breaks one common technical-SEO rule: a missing
<title>, a redirect loop, a page nothing links to, thin content. Point an SEO crawler at it and check what the crawler catches.
它承担两个角色:
- 可人工浏览的反面教材:每个缺陷页面都在页面上展示一个“What this page tests”测试面板(含该页应触发的审计问题 chips),读者可以直接用浏览器或任意第三方爬虫验证“错误长什么样、后果是什么”。
- OpenSEO site audit 的 e2e 夹具:每个 Fixture 用
expectedIssues声明自己应触发的审计问题 id,harness 对运行中的副本跑真实的 OpenSEO 爬取 + 问题检测函数,逐页核对“恰好触发且仅触发”声明的问题,任何偏差都以非零退出码失败。
package.json的描述也明确了这一双重身份(见 badseo/package.json):a deliberately broken website full of SEO mistakes, used as e2e test fixtures for the OpenSEO site audit。
2. Fixture 覆盖的完整问题矩阵
README 按类别给出了覆盖总表,并强调“审计引擎的每一种问题类型都至少被一个页面覆盖(由 harness 强制)”。以下是 README 原文的类别矩阵(首页/#issues可浏览全部页面):
| 类别 | 页面 |
|---|---|
| Head tags & headings | missing title、title 过长/过短、missing meta、meta 过长、missing H1、multiple H1、heading-level skip |
| Content quality | thin content、images missing alt、duplicate content、duplicate title、duplicate meta description |
| Indexability & canonical | noindex(meta +X-Robots-Tagheader)、canonicalized to another URL、conflicting canonicals |
| HTTP status & links | 404、500、403 (blocked)、broken internal link |
| Redirects | redirect chain、redirect loop、trailing-slash canonical(redirect-cycle trap) |
| Performance | slow server response (TTFB) |
| Site structure | orphan page、deep click-path |
| Kitchen sink | 一页同时犯 6 种错误 |
从 src/shared/audit-issues.ts 中 OpenSEO 共享的问题注册表看,引擎共定义 25 种 issue 类型,severity 分三级(critical/warning/info),fixture 的expectedIssues就是对这些 id 的引用:
- critical(4):
blocked-page、server-error(5xx)、broken-internal-link、missing-title - warning(12):
broken-page(4xx)、duplicate-title、duplicate-meta-description、duplicate-content、missing-meta-description、missing-h1、multiple-h1、redirect-chain、redirect-loop、canonical-conflict、thin-content、images-missing-alt、orphan-page、no-outgoing-links - info(9):
title-too-long、title-too-short、meta-description-too-long、meta-description-too-short、heading-order-skip、slow-response(TTFB 超过 1.5s)、noindex-page、canonicalized-page、deep-page(距首页 5 次点击以上)
该注册表同时被服务端(问题引擎、MCP 工具)与客户端(问题 UI、CSV 导出)共享;badseo.dev 的测试面板正是直接引用注册表里的title与explanation渲染 chips(见 badseo/src/lib.ts 的issueChips),保证页面声明与引擎措辞一致。
3. 架构:TanStack Start + Cloudflare Worker + 字节级响应控制
3.1 整体技术栈
README 的 “How it's built” 一节给出的实现要点如下,均与源码对应:
- badseo 是一个TanStack Start 应用,部署在 Cloudflare Worker 上,与仓库的
web/应用采用相同的 Vite + Cloudflare 配置; - TanStack React 路由只负责健康的首页与隐私政策(badseo/src/routes/index.tsx、badseo/src/routes/privacy.tsx);
- 一个TanStack catch-all server 路由承载所有刻意的 fixture,以“原始响应”形式返回,从而获得对状态码、重定向、响应头(
X-Robots-Tag、Link: …; rel=canonical)、时序(TTFB 延迟)以及畸形<head>状态的字节级控制权。
catch-all 的实现只有 10 行(badseo/src/routes/$.ts):
export const Route = createFileRoute("/$")({ server: { handlers: { GET: ({ request }) => handleFixtureRequest(request), }, }, });3.2 Fixture 契约:每个缺陷页就是一个带断言的对象
Fixture 的类型定义在 badseo/src/fixtures/types.ts,核心字段:
export interface Fixture { path: string; // 规范 URL 路径,如 "/head/missing-title",必须以 "/" 开头 extraPaths?: string[]; // 字节级完全相同的额外 URL,用于建模多 URL 的重复页 category: string; // 目录页展示的分组 name: string; // 人类可读名称 summary: string; // 页面测试面板中的一行描述 lesson?: string; // 为什么重要 / 如何修复 expectedIssues: IssueId[]; // 该页被“工程化”触发的问题 id 集合, // 同时是 e2e harness 的断言真值;空数组 = “该页必须干净” support?: boolean; // 仅用于支撑其它 fixture 可达的辅助页(如重定向链的中转跳), // 被爬取但不展示在目录中,harness 断言其为 clean inSitemap?: boolean; // 是否写入 sitemap.xml(默认 true) linkedFromCatalog?: boolean; // 目录页是否链接它(孤儿页设为 false) handler: (ctx: FixtureContext) => Response | Promise<Response>; // 完整控制 status、headers、timing 的响应生产函数 }其中IssueId直接类型导入自主项目(同一行注释说明了原因):
// 从 OpenSEO 审计引擎直接引入问题 id 联合类型, // 使每个 fixture 的 expectedIssues 都针对真实注册表做类型检查。 // 类型级导入——构建期擦除,不会打进 Worker。 import type { AuditIssueType } from "../../../src/shared/audit-issues"; export type IssueId = AuditIssueType;这意味着expectedIssues中写一个不存在的 id(或引擎新增/改名 id 后忘记同步)会在npm run typecheck阶段直接报错——类型系统替 harness 挡下了第一道错误。
Fixture 按类别分文件存放(badseo/src/fixtures/下head-tags.ts、content.ts、indexability.ts、http-status.ts、redirects.ts、performance.ts、structure.ts、kitchen-sink.ts),再由 badseo/src/fixtures/registry.ts 聚合并导出派生集合:
allFixtures:目录顺序的全部 fixture;catalogLinkedFixtures(linkedFromCatalog !== false):目录页会链接到的 fixture——链接即“内链”,让爬虫可达且不变成孤儿页;sitemapFixtures(inSitemap !== false):写入 sitemap.xml 的路径;duplicateUrlLinks(各 fixture 的extraPaths):重复/备选 URL。目录页也链接它们,使重复页被爬取,且不会被误判为孤儿页。
以 head 类为例,badseo/src/fixtures/head-tags.ts 中“缺失 title”的 fixture 展示了“刻意省略一个<head>元素即制造缺陷”的惯用手法:
const missingTitle: Fixture = { path: "/head/missing-title", category: "Head tags & headings", name: "Missing <title> tag", summary: "This page has no <title> element at all.", lesson: "The title is the strongest signal of what a page is about, …", expectedIssues: ["missing-title"], handler: () => htmlResponse( renderPage({ fixture: missingTitle, // title intentionally omitted metaDescription: "This page is fine except for one thing: …", bodyHtml: article({ h1: "A page with no title", lede: "…", sections: [/* … */] }), }), ), };同类中还包含 title 过长(99 字符的longTitle)、title 过短("Hi")、meta description 缺失/过长/过短、缺失 H1、空 H1(expectedIssues: ["missing-h1"],与“无 H1”共享同一 issue id)、多个 H1、标题层级跳级(H1→H4)等页面。
3.3 请求分发与爬虫发现响应
badseo/src/server/badseo.ts 的handleFixtureRequest是 fixture 的分发中枢:
- 把 pathname 归一化(非根路径去掉末尾斜杠)后查
routeTable(path与所有extraPaths都注册进同一张表); - 命中则调用
fixture.handler(context),FixtureContext提供origin(由请求的host头推导,因此同一个站点在 localhost 与 badseo.dev 上生成的绝对 URL 都正确)、request、path; - 特殊拦截:命中
TRAILING_SLASH_CANONICAL(/redirect/trailing-slash,常量见 badseo/src/fixtures/redirects.ts)时,301 跳转到带斜杠形式——这是“尾斜杠规范化页”陷阱的前半段; - 未命中则返回一个手写的 404 页(
no-store)。
同文件还提供爬虫发现所需的两个端点:
robotsResponse:User-agent: * / Allow: /,并动态输出Sitemap: ${origin}/sitemap.xml——“broken on purpose, but it lets crawlers in”;sitemapResponse:把/、/privacy加上全部sitemapFixtures的path+extraPaths拼成标准urlsetXML(badseo/src/routes/robots[.]txt.ts 与 badseo/src/routes/sitemap[.]xml.ts 分别对接)。
3.4 SEO 中立的渲染层:让审计只量到注入的缺陷
badseo/src/lib.ts 是全部 fixture 共用的渲染原语,其头部注释点明了设计纪律:
Everything the shared chrome emits (nav, footer, and the "what this page tests" panel) is deliberatelySEO-NEUTRAL: no
<h1>–<h6>and no<img>. That way each fixture's headings and images are fully under the fixture's own control, and the audit measures exactly the defect we injected — not accidental noise from the layout.
关键函数:
renderDocument(opts):构建完整 HTML 文档,对<head>有逐字段控制权——title/metaDescription整个省略即不输出对应元素(用于 missing-title / missing-meta);可选canonical、robotsMeta(<meta name="robots">)、headExtra(原始<head>注入,用于 JSON-LD 等附加标签)、lang;htmlResponse(html, { status?, headers?, delayMs? }):返回Response,默认200+text/html; charset=utf-8+cache-control: no-store;delayMs在首字节前人为延迟,是 slow-TTFB 类 fixture 的实现基础;redirect(location, status = 301):手工 3xx 跳转。README 特别注明“爬虫会把每一跳记录为独立页面行”,这正是 redirect-chain 检测的输入;renderPage(带测试面板)与renderShell(首页/目录用的无面板外壳);lorem(words):确定性填充文本,让页面词数能越过 thin-content 阈值,避免“测别的缺陷时误伤薄内容检查”。
“性能”与“组合错误”两个 fixture 恰好展示了delayMs与多缺陷叠加的用法:
- badseo/src/fixtures/performance.ts:
/perf/slow-response用{ delayMs: 1700 }制造约 1.7 秒 TTFB(审计阈值 1.5s,见注册表slow-response描述),expectedIssues: ["slow-response"]; - badseo/src/fixtures/kitchen-sink.ts:
/kitchen-sink一页同时具备 6 个缺陷——过长的 title、缺失 meta description、两个 H1、H1→H4 跳级、无 alt 的<img>、以及{ delayMs: 1700 }的慢响应,expectedIssues列出全部 6 个 id。它的lesson点明了设计意图:“真实坏页面通常不止一个问题,审计应当报告其中每一个,而不是停在第一个。”
redirects 类中最具工程价值的是尾斜杠陷阱(badseo/src/fixtures/redirects.ts):规范形式/redirect/trailing-slash/返回 200,无斜杠形式 301 到它——与 WordPress 等 CMS 的常规行为一致。一个会把/foo/归一化为/foo的爬虫会陷入“取回 301 → 再去/foo→ 301 回去 → 再 strip”的死循环(508 Loop Detected 一类 bug,注释中指向主仓库 PR #61 的回归背景)。该 fixture 的expectedIssues是空数组:正确行为是规范页被爬一次并返回 200,且不误报 redirect-loop。harness 还为此设了显式回归守卫(见下节)。
4. 端到端审计运行器:驱动真实引擎,逐页精确断言
harness 是 badseo/scripts/run-audit.ts。其文件头注释准确概括了策略:
This drives theREALOpenSEO audit engine (the same crawl + issue-detection functions the production Worker uses) against a running badseo.dev… It reimplementsonly the crawl frontier loop— deliberately, so it can crawl localhost (the production frontier's SSRF policy blocks private hosts). Every actual detection call below is imported straight from
../src.
即:只有爬取 frontier 循环是本地重写的(为了绕过生产爬虫对私有主机的 SSRF 拦截从而支持 localhost),所有真正的问题检测调用都从主仓库直接导入:
import { crawlPage } from "../../src/server/workflows/site-audit-workflow-helpers"; import { discoverUrls, parseRobotsTxt } from "../../src/server/lib/audit/discovery"; import { normalizeUrl, isSameOrigin } from "../../src/server/lib/audit/url-utils"; import { runPageReporters } from "../../src/server/lib/audit/issues/page-reporters"; import { findDuplicates, findRedirectChainsAndLoops, type SlimPage } from "../../src/server/lib/audit/issues/multipage-checks"; import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues";运行参数与流程:
- 目标 origin 取命令行参数,默认
http://localhost:8787;MAX_PAGES = 200、CONCURRENCY = 10; - 预热:正式爬取前并发请求首页、
/privacy和所有 fixture 路径(redirect: "manual"),避免 dev server 冷启动让健康页被误判为慢响应; - BFS frontier:链接发现的 URL 优先于仅 sitemap 可达的 URL(sitemap-only 的页面
depth = null,与真实审计区分);robots 不允许的 URL 不入队;每个页面的内部链接收集为CrawlLink(source → target)供多页检查使用; - 检测阶段:逐页
runPageReporters(page)得到页面级问题;再把页面压成SlimPage跑findDuplicates(重复 title/meta/content)与findRedirectChainsAndLoops;两个依赖生产环境 D1 存储的多页检查以内存等价实现替代——findBrokenInternalLinks(目标页 4xx/5xx 的内部链接)与findOrphanPages(无任何非自引用入链、且非重定向目标的 2xx 页;仅在爬取完整未截断时执行); - 断言语义(核心):对每个 fixture,要求
实际检测到的问题集合 == expectedIssues,缺一个(missing)或多一个(extra)都判失败;首页与隐私页必须完全干净(check("Homepage", "/", [], false)等);标记support: true的辅助页只允许携带 info 级噪音(例如深层链路上的中转点自身可能就是 deep page),出现 critical/warning 即失败; - 尾斜杠显式回归守卫:断言
TRAILING_SLASH_CANONICAL的两种形式中至少有一个被爬成 200,且相关 URL 无redirect-loop、无 5xx/抓取错误。注释说明该守卫“故意对具体修复方式保持中立”——无论修复后 200 落在斜杠形式(保留斜杠的根因修复)还是非斜杠形式(旧版 strip 后 inline-follow 的行为)都算通过;若爬虫仍会 strip 且不 follow,则表现为 508 或自环,必然失败; - 输出与退出码:打印逐页 pass/fail 矩阵(ANSI 着色,✓/✗),以及一行问题类型覆盖率
issue-type coverage: N/25(枚举AUDIT_ISSUE_TYPES的全部 key,列出未被任何 fixture 覆盖的 id);failures > 0时process.exit(1)——因此它可以原样作为审计引擎的 CI 门禁。
5. 本地运行、添加 Fixture 与部署
5.1 本地运行与跑审计
# from the badseo/ directory npm run dev # serves on http://localhost:8787badseo/vite.config.ts 把 dev server 绑定在127.0.0.1:8787,插件为cloudflare()+tanstackStart()+react(),SSR 解析条件为["worker", "import", "module", "default"]。
# with `npm run dev` running in another terminal: npm run audit -- http://localhost:8787audit脚本的实际定义是cd .. && tsx badseo/scripts/run-audit.ts(test:e2e与之相同),即从仓库根目录用tsx直跑 harness,也可传任意 origin 参数对部署后的站点复跑。
5.2 添加一个新 Fixture:新 Fixture = 新回归测试
README 的 “Add a fixture” 一节给出了最小对象模板(可直接对照types.ts的完整字段使用):
const myFixture: Fixture = { path: "/category/my-mistake", category: "Content quality", name: "My SEO mistake", summary: "One-line description shown in the on-page test panel.", lesson: "Why it matters / how to fix it.", expectedIssues: ["thin-content"], // the audit issue ids this page must trigger handler: () => htmlResponse( renderPage({ fixture: myFixture, title: "…", metaDescription: "…", bodyHtml: "…", }), ), };然后把它加入所属类别的导出数组(headTagFixtures等)即可被registry.ts聚合。README 给出的三条守则值得原样保留:
- 每页只隔离一个问题。主题页除了展示的缺陷外必须在其它一切方面健康,保证审计结果无歧义(kitchen-sink 页是刻意例外);
- 全站保持 title 与 meta description 唯一,否则会意外制造 duplicate-title / duplicate-meta 分组(刻意的重复对除外,它们用
extraPaths建模); - 文案保持朴素:说清页面做了什么、为什么这个错误重要,不要渲染。
类型层面:expectedIssues对照真实审计注册表做类型检查,harness 在运行时再“按它办事”——静态与动态双重约束。
5.3 构建与部署
npm run build # Vite build + typecheck npm run deploy # build + wrangler deploy → badseo.devbuild=vite build && tsc --noEmit(先构建、后类型检查),deploy在其基础上追加wrangler deploy。badseo/wrangler.jsonc 的关键配置:
{ "name": "badseo", "compatibility_date": "2026-02-19", "compatibility_flags": ["nodejs_compat"], "main": "@tanstack/react-start/server-entry", // TanStack server 入口 "assets": { "directory": "./dist/client", // 构建后的客户端静态资源 "html_handling": "none", "not_found_handling": "none", }, "routes": [ { "pattern": "badseo.dev", "custom_domain": true }, { "pattern": "www.badseo.dev", "custom_domain": true } ] }html_handling: "none"表示静态资源命中不了时全部交给 Worker 处理——这正是 catch-all fixture 路由能接住任意 URL 的前提。
6. 分析统计:Plausible + 带同意的 Google Analytics
README 的 “Analytics” 一节与源码实现一致:
- Plausible(无 cookie 的聚合基线)通过站点专属脚本加载于每页,见 badseo/src/plausible.ts 导出的
PLAUSIBLE_SCRIPT_SRC/PLAUSIBLE_INIT_SCRIPT,并被renderDocument写入每个 fixture 文档的<head>; - Google Analytics使用 measurement ID
G-7MXV9FH7SS。badseo/public/analytics.js 是 TanStack 页面与原始 fixture 文档共享的小型同意脚本:在访客点击 Accept 之前不请求 Google 的 tag、不写入分析 cookie;选择(granted/denied)只存在访客浏览器localStorage的badseo.analyticsConsent键中,可从页脚Cookie settings重新打开横幅更改;拒绝时还会主动清除_ga*cookie。
7. 小结:这套“靶场”设计可复用的三点
- 缺陷即断言:每个 fixture 的
expectedIssues既是页面文档(测试面板),又是 e2e 断言真值;配合“恰好匹配(missing 与 extra 都算失败)”的语义,能同时抓出检测器的漏报与误报。 - 单一事实源:issue id 联合类型、severity、标题与说明全部来自主仓库共享注册表 src/shared/audit-issues.ts,badseo 侧零复制——引擎改名一个 id,类型检查立刻在 fixture 侧报警。
- 字节级可控的原始响应:只让框架渲染“健康页”,让 fixture 用
Response直接生产状态码、头、时序与畸形<head>,并用 SEO 中立的共享 chrome 排除布局噪音;爬虫发现文件(robots/sitemap)按 fixture 元数据动态生成,使 orphan、duplicate、deep-click 这类结构性缺陷也能被精确构造。
对想给自己的爬虫/审计工具做回归验证的团队,这套“fixture 声明期望问题 + harness 驱动真实引擎 + 覆盖率强制 100%”的模式可以直接借鉴:新增一个缺陷页,就是免费新增一个回归测试。
【免费下载链接】open-seoOpen source alternative to Semrush and Ahrefs项目地址: https://gitcode.com/GitHub_Trending/op/open-seo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考