Agent 部署验证 Gate 设计:发布后的真实性检查
⚡ 30 秒要点
- CI/CD 的「部署成功」只验证字节到达,不验证内容正确——这是两类完全不同的检查
- VERIFIED Gate 通过 10+ 独立维度(HTTP 状态、canonical、hreflang、JSON-LD、安全头、sitemap、首页卡片、commit 一致性)自动化验证线上内容
- 核心定义:VERIFIED Gate 是一个确定性、幂等的部署后检查,跨 N 个独立维度验证已部署内容,每个维度独立输出 PASS/FAIL,所有维度通过时 Gate 才通过
- Gate 失败不是灾难——明确的恢复路径(修复 → 重新部署 → 重新验证)比「不知道有没有问题」安全得多
一、引言:为什么 CI/CD 的「部署成功」不够
CI/CD 流水线变绿了。GitHub Actions 显示 ✅ deploy succeeded。rsync 返回码为 0,nginx reload 成功,端口监听正常。从运维视角看,部署没有发生任何错误。
但搜索引擎不这么看。
两天后,你发现新发布的英文文章出现在中文搜索结果中,而中文版本完全未被索引。排查发现:canonical URL 错误地指向了英文版——中文页面的 <link rel="canonical"> 里写的是 /en/posts/...。同时,hreflang 标签只有单向引用:英文页面声明了中文替代版本,但中文页面没有反向声明英文版本。Google 因此无法识别双语结构,中文页面沦为孤立页面。
更糟的是:CI/CD 对此完全不知情。它的职责是验证「字节是否到达了服务器」——文件传输完成、服务重启成功、端口监听就绪。它不解析 HTML,不读 canonical,不管 hreflang 的双向性。这些语义层的错误对 CI/CD 完全透明。
这里藏着两个层级的验证鸿沟:
- 传输层验证(CI/CD 的领域):文件到了没?服务起了没?端口监听没?——回答「部署是否完成」。
- 内容层验证(VERIFIED Gate 的领域):canonical 指向对了吗?hreflang 双向互指了吗?JSON-LD 可解析吗?安全头生效了吗?——回答「部署是否正确」。
对于人类作者生产的内容,这个鸿沟通常不明显——因为人类在开发过程中会反复预览和检查,大部分语义错误在本地就被发现并修复了。但对于 Agent 生产的内容,情况完全不同。Agent 可以产出结构完整、语法正确的 HTML,但可能包含微妙的语义错误:canonical URL 指向了 staging 域名而非生产域名,hreflang 只生成了单语言版本,JSON-LD 中的 URL 用了 localhost 前缀。这些错误在本地预览时完全看不出来——页面渲染正常,HTML 结构合法——只有部署到线上、被搜索引擎解析之后才会暴露。
这就是问题的关键:Agent 的职责结束于 commit;但验证的职责开始于部署后。Agent 写完了代码,提交了 PR,合并了分支——它的工作完成了。但部署到线上的内容是否正确,需要在真实的线上环境中、从「内容消费者」的视角进行独立验证。
本文提出的 VERIFIED Gate 正是为此而设计:一个部署后的确定性检查门禁,跨 10+ 个独立维度验证已部署内容的完整性和正确性。它不是 CI/CD 的替代,而是 CI/CD 之后、面向内容语义的补充验证层。只有当所有维度都通过时,部署才被标记为 VERIFIED——真正「已发布且正确」。
这篇文章是 Agent 发布与运营系列的一部分。本系列始于 Agent Release Gate 设计(定义了完整的 8 阶段发布管道——VERIFIED Gate 是其中的 Phase 8,也是最后一个阶段)。VERIFIED Gate 的在线验证能力依赖于 Agent 可观测性 中建立的监控信号体系——部署后检查本质上是可观测性信号的一次集中消费。
以下是 VERIFIED Gate 覆盖的 10 个核心验证维度,每一个维度都独立判定 PASS/FAIL:
| 维度 | 检查项 | 失败模式 |
|---|---|---|
| HTTP 状态 | 每个页面返回 200 OK,无重定向循环 | 404/500/301 循环——CI/CD 显示部署成功但文件路径错误 |
| Content-Type | text/html; charset=utf-8 | 纯文本/二进制输出——nginx mime.types 未加载或路径匹配错误 |
| 页面大小 | 大于 1KB | 空页面/错误页面——rsync 中断或占位文件覆盖了正式内容 |
| Canonical | 自引用,URL 完整且协议为 https | 跨语言/跨域名指向——Agent 生成了 staging URL 或 http 协议 |
| Hreflang | 双向互指 + x-default 指向 en | 单向/缺失/指向错误——Agent 只生成了 zh 版本或 URL 路径不对 |
| JSON-LD | Article + BreadcrumbList + FAQPage 三种类型均存在且格式正确 | 缺失/格式错误——Agent 省略了某种结构化数据或 JSON 语法错误 |
| FAQ 可见性 | details/summary 元素存在且可交互 | FAQ 内容存在但被 CSS 隐藏,或使用非标准标记导致不可见 |
| 安全头 | HSTS / X-Content-Type-Options / X-Frame-Options / Referrer-Policy / Permissions-Policy 全部返回 | 缺失/nginx 继承陷阱——server 块的头被 location 块覆盖 |
| Sitemap | zh/en URL 均存在于 sitemap.xml 中 | URL 缺失/格式错——部署后 sitemap 未更新或 rsync 覆盖了旧版 |
| 首页卡片 | 文章链接出现在 zh/index.html 的卡片列表中 | 链接缺失/断裂——index.html 部署了旧版本 |
二、验证维度矩阵:从 HTTP 状态到内容一致性
上表的 10 个维度构成了 VERIFIED Gate 的核心检查矩阵。每个维度独立判定 PASS/FAIL,有自己的检查方法、失败模式和修复路径。理解每个维度的诊断价值——它能捕获什么问题、什么信号会导致误判——是设计可靠 Gate 的前提。以下从 HTTP 基础设施到业务语义逐层展开。
2.1 维度一:HTTP 状态 (200)
检查内容:目标 URL 返回 HTTP 200 OK,且不经过任何 3xx 重定向。这是所有后续检查的前置条件——如果页面根本不可达,其他维度的验证没有意义。
为什么单单 200 不够:两个容易被忽视的陷阱:
- 重定向掩盖:CI/CD 的 smoke test 通常跟随重定向。如果
/zh/posts/new-article返回 301 →/zh/posts/new-article/再返回 200,CI/CD 标记为成功。但 Google 视 301 为「永久移动」信号,会将原 URL 的排名权重转移,导致搜索结果中出现带尾部斜杠的 URL,破坏 canonical 一致性。 - 200 伪装:nginx 默认欢迎页返回 200 OK,但 body 是
<title>Welcome to nginx!</title>。某些 PHP 框架的错误处理器也返回 200 但 body 是 "500 Internal Server Error"。一个 HTTP 200 不能证明「正确的页面被返回了」。 - 重定向循环:两个 URL 互相 301 指向对方(
/a→/a/→/a),curl 默认跟随重定向会超时或达到最大重定向次数而报错,但错误信息不直观——你只知道「curl 失败了」,却不知道原因是循环重定向。使用curl -fsS不会跟随重定向,直接暴露问题。
curl 检查方法:
# 正确:不跟随重定向,直接检查初始响应
curl -fsS -o /dev/null -w '%{http_code}' https://xslyl.com/zh/posts/slug.html
# 期望输出:200(且没有 Location 头)
# 如果输出 301/302/303/307/308:URL 结构问题
# 如果输出 404:文件未部署到正确路径
# 如果输出 500:服务器端错误(nginx 配置、PHP 错误等)
# 如果输出 000:连接失败(DNS、TLS、端口未监听)
# 同时检查是否有 Location 头(重定向方向)
curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1 | grep -i '^location:'
# 如果有输出:存在重定向,需要排查 nginx rewrite 规则
失败模式:404(文件路径错误或 rsync 未完成)、500(nginx 配置语法错误或后端故障)、301 循环(URL rewrite 规则冲突)。恢复路径:确认文件存在于服务器目标路径 → 检查 nginx location 匹配 → 修正后重新部署。
2.2 维度二:Content-Type
检查内容:HTTP 响应头中 Content-Type 必须包含 text/html,且推荐包含 charset=utf-8。
为什么重要:浏览器根据 Content-Type 决定如何渲染响应。如果服务器返回 text/plain 或 application/octet-stream,浏览器会显示 HTML 源码而非渲染页面。搜索引擎同样依赖 Content-Type 判断内容类型——返回错误的 MIME 类型可能导致页面被当作纯文本处理,丢失所有 SEO 信号。
常见失败场景:
- nginx mime.types 未加载:nginx 配置中缺少
include mime.types;,所有.html文件被当作application/octet-stream返回——浏览器弹出下载对话框而不是渲染页面。 - 部署管道 gzip 配置错误:某些 CI/CD 管道在传输前 gzip 压缩文件但没有设置
Content-Encoding: gzip头,导致浏览器收到压缩后的二进制数据。nginx 的gzip_static on;可以自动处理.html.gz文件,但前提是文件扩展名正确。 - CDN 覆盖:CDN(如 Cloudflare)可能修改 Content-Type 头。如果源站返回正确的
text/html但 CDN 因缓存配置错误返回了text/plain,问题只在 CDN 启用后才暴露。 - charset 缺失对中文页面的影响:对于纯 ASCII 英文页面,缺少
charset=utf-8可能不会立即暴露问题。但中文页面包含多字节 UTF-8 字符,如果浏览器按 Latin-1 解码,所有中文字符将显示为乱码(æ–‡ç«而不是文章)。VERIFIED Gate 检查 charset 能预防这种仅在中文用户浏览器上可见的回归。
curl 检查方法:
# 检查 Content-Type 头
curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1 | grep -i '^content-type:'
# 期望输出:content-type: text/html; charset=utf-8
# 如果只有 text/html 没有 charset:警告级别
# 如果是 text/plain:FAIL——浏览器不会渲染为 HTML
# 如果是 application/octet-stream:FAIL——浏览器可能触发下载
2.3 维度三:页面大小
检查内容:响应体大小必须大于 1KB(1024 字节)。这是一个轻量级但高效的「空壳检测」机制。
为什么 >1KB:任何正常的 HTML 文章页面——仅 HTML 标签、meta 信息、面包屑导航、页脚——就会超过 1KB。一个完整的文章页面(含内联 CSS、结构化 JSON-LD、FAQ 标记、正文内容)通常在 15-80KB 之间。小于 1KB 的响应只有以下几种情况:
- 空文件/占位文件:rsync 传输中断导致文件大小为 0 或几百字节的不完整内容。
- 服务器错误页返回 200:nginx 的默认欢迎页通常约 600 字节(一个简单的 HTML 骨架)。某些应用框架的自定义错误页也小于 1KB。这些页面的共同特征:返回 200 但内容不完整。
- 文件路径错误但无 404:请求
/zh/posts/new-article.html,nginx 因 try_files 配置返回了/index.html(SPA fallback),该文件可能是一个极简的 SPA 入口,只有<div id="app"></div>和 script 标签——小于 1KB。
阈值调整注意事项:1KB 是一个保守阈值。如果你的站点使用外部 CSS(无内联样式)、最小化 HTML 结构,正常页面可能落在 2-5KB 范围。但如果页面使用了 SPA 架构(内容由 JavaScript 动态渲染),HTML 文件可能确实很小——此时需要配合其他检查(如 <title> 标签存在性、JSON-LD 存在性)进行交叉验证。对于 xslyl.com 的静态文章页面(含内联 CSS 和完整结构化数据),1KB 阈值是安全的。
curl 检查方法:
# 检查响应体大小(字节数)
curl -fsS -o /dev/null -w '%{size_download}' https://xslyl.com/zh/posts/slug.html
# 期望输出:> 1024(通常 15000-80000)
# 如果输出 < 1024:空壳页面的强烈信号
# 如果输出 0-100:文件不存在或传输失败
# 更精确:获取大小并判断
size=$(curl -fsS -o /dev/null -w '%{size_download}' https://xslyl.com/zh/posts/slug.html)
if [ "$size" -lt 1024 ]; then
echo "FAIL: page size ${size} bytes < 1KB threshold"
fi
2.4 维度四:Canonical 自引用
检查内容:页面中 <link rel="canonical"> 的 href 值必须严格等于当前页面的完整 URL(协议 + 域名 + 路径)。即 canonical URL 是自引用的——页面声称自己就是权威版本。
为什么自引用而非简单「存在」:Agent 生成的 HTML 中最常见的 SEO 缺陷不是缺少 canonical 标签,而是 canonical 指向了错误的目标:
- 跨语言泄露:zh 页面的 canonical 指向了 en 版本的 URL。后果:Google 认为 en 版本是权威版本,zh 页面被视为「重复内容」而未被索引。对于双语站点,这是最隐蔽也是最具破坏性的 canonical 错误——页面完全可访问、渲染正常、所有其他 SEO 标签正确,但搜索引擎不索引它。
- staging 域名残留:Agent 在开发环境中生成 HTML,canonical 写的是
http://localhost:3000/zh/posts/...或https://staging.xslyl.com/...。部署到生产环境后,canonical 仍指向不可达的开发地址。 - HTTP 协议:canonical 使用
http://而非https://。Google 视 HTTP 和 HTTPS 为不同 URL,会将其视为两个独立页面并可能选择 HTTP 版本作为 canonical——导致 HTTPS 版本不被索引。 - 路径差异:canonical 指向
/zh/posts/slug(无.html扩展名)或/zh/posts/slug/(尾部斜杠),与实际 URL 不匹配。搜索引擎可能因此将两个变体都索引,导致重复内容惩罚。
curl 检查方法:
# 提取 canonical href
curl -fsS https://xslyl.com/zh/posts/slug.html | \
grep -oP '<link\s+rel="canonical"\s+href="[^"]*"' | \
grep -oP 'href="\K[^"]*'
# 期望输出:https://xslyl.com/zh/posts/slug.html
# 即精确等于被请求的 URL
# 脚本化比对
EXPECTED="https://xslyl.com/zh/posts/slug.html"
ACTUAL=$(curl -fsS "$EXPECTED" | grep -oP '<link\s+rel="canonical"\s+href="[^"]*"' | grep -oP 'href="\K[^"]*')
if [ "$ACTUAL" != "$EXPECTED" ]; then
echo "FAIL: canonical mismatch — expected $EXPECTED, got $ACTUAL"
fi
2.5 维度五:Hreflang 双向 + x-default
检查内容:双语页面的 hreflang 标签必须满足三个条件:(1) zh 页面包含 hreflang="en" 指向 en 版本、(2) en 页面包含 hreflang="zh" 指向 zh 版本、(3) 两个页面都包含 hreflang="x-default" 指向 en 版本。这被称为「双向互指 + x-default」模式。
为什么必须双向:Google 官方文档明确要求 hreflang 标签必须双向确认——如果页面 A 声明页面 B 是其替代版本,页面 B 也必须声明页面 A 是其替代版本。单向 hreflang 被 Google 忽略。这是 Agent 生成内容中最常见的国际化错误——Agent 在生成 zh 页面时添加了 en 的 hreflang,但在生成 en 页面时遗漏了 zh 的 hreflang。
典型失败模式:
- 单向 hreflang:zh 页面有
hreflang="en"但 en 页面没有hreflang="zh"。Google 完全忽略该 hreflang 注解,两个页面被视为独立页面——用户搜索中文关键词可能被导向英文页面。 - 缺失 x-default:两个页面互相声明了替代版本,但没有
hreflang="x-default"。x-default 告诉搜索引擎「当用户语言与所有声明的语言都不匹配时,使用哪个版本」。对于 xslyl.com,x-default 指向 en(英文作为默认回退语言)。缺少 x-default 不会导致 hreflang 完全失效,但会降低非中英文用户的搜索体验。 - URL 路径不一致:zh 页面的
hreflang="en"指向/en/post/slug.html(单数 post)而非/en/posts/slug.html(复数 posts)。Agent 在推断 URL 时可能使用错误的路径模式。这种错误很难人工发现——因为两个 URL 只差一个字母。 - 跨页面交叉验证的必要性:一个 hreflang 检查需要验证两个页面(zh 和 en)。这意味着不能仅通过检查单个页面完成——必须发起两个 HTTP 请求,分别解析两个页面的 hreflang 标签,然后交叉比对。这增加了 Gate 的复杂度,但这是唯一能确保 hreflang 配置正确的方法。
curl 检查方法(需要两个请求 + 交叉比对):
# Step 1: 提取 zh 页面的 hreflang 标签
ZH_HREFS=$(curl -fsS https://xslyl.com/zh/posts/slug.html | \
grep -oP '<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="[^"]*"')
# 期望包含:
# hreflang="zh" href="https://xslyl.com/zh/posts/slug.html"
# hreflang="en" href="https://xslyl.com/en/posts/slug.html"
# hreflang="x-default" href="https://xslyl.com/en/posts/slug.html"
# Step 2: 提取 en 页面的 hreflang 标签
EN_HREFS=$(curl -fsS https://xslyl.com/en/posts/slug.html | \
grep -oP '<link\s+rel="alternate"\s+hreflang="[^"]*"\s+href="[^"]*"')
# 期望包含:
# hreflang="en" href="https://xslyl.com/en/posts/slug.html"
# hreflang="zh" href="https://xslyl.com/zh/posts/slug.html"
# hreflang="x-default" href="https://xslyl.com/en/posts/slug.html"
# Step 3: 交叉验证
# - zh 页面声明了 en 替代版本 ✓
# - en 页面声明了 zh 替代版本 ✓ (双向确认)
# - 两个页面都有 x-default 指向 en ✓
2.6 维度六:JSON-LD 结构化数据
检查内容:页面必须包含三个独立的 JSON-LD 块——Article、BreadcrumbList、FAQPage——每个都可以被 JSON 解析器正确解析,且包含 Google 要求的必填字段。
三个 JSON-LD 块各自的作用:
- Article(必需):告诉搜索引擎这是一个文章页面,提供标题、描述、发布日期、作者。缺失时页面仍可被索引,但不会出现在 Google 的「头条新闻」或「文章」富文本结果中。
- BreadcrumbList(必需):告诉搜索引擎页面的层级位置。缺失时 Google 搜索结果中显示的是 URL 而非面包屑路径(如
xslyl.com/zh/posts/...而非xslyl.com > 文章 > Agent 部署验证 Gate 设计)。面包屑能提升点击率(CTR)约 5-10%。 - FAQPage(有条件必需——含 FAQ 文章):告诉搜索引擎页面包含问答内容。如果页面确实有 FAQ 部分但 JSON-LD 缺失,Google 不会显示 FAQ 富文本结果(可展开的问答列表),这是当前搜索中点击率最高的富文本格式之一。
常见失败模式:
- 缺失某个块:Agent 生成了 Article 和 FAQPage 但遗漏了 BreadcrumbList。Google 的结构化数据测试工具不会报错(它只验证单个块),但搜索结果中面包屑会回退到 URL 显示。
- @type 错误:Agent 写了
"@type": "BlogPosting"而非"@type": "Article"。两者在功能上接近,但 Google 对 BlogPosting 的支持较 Article 弱(如 BlogPosting 不出现在 Google News 中)。 - @id 缺失或冲突:每个 JSON-LD 块应该有唯一的
@id(如"@id": "https://xslyl.com/zh/posts/slug.html#article")。缺失 @id 不会导致错误,但会让外部系统(如知识图谱)难以引用该实体。 - JSON 语法错误:多余的逗号、未转义的引号、多字节字符编码问题——任何一个都会导致整个 JSON-LD 块被 Google 忽略,且没有任何可见的错误提示(浏览器渲染正常,只有用 Google Rich Results Test 才能发现)。
- URL 协议错误:JSON-LD 中的 URL 使用了
http://而非https://。Google 可能将其视为不同的实体。
curl 检查方法:
# 提取所有 JSON-LD 块
curl -fsS https://xslyl.com/zh/posts/slug.html | \
grep -oP '<script\s+type="application/ld\+json">\K.*?(?=</script>)'
# 管道到 Python 解析验证
curl -fsS https://xslyl.com/zh/posts/slug.html > /tmp/page.html
python3 - <<'PY'
import json, re
with open('/tmp/page.html') as f:
html = f.read()
# Extract all JSON-LD blocks
blocks = re.findall(
r'<script\s+type="application/ld\+json">(.*?)</script>',
html, re.DOTALL
)
types = []
for block in blocks:
try:
data = json.loads(block)
types.append(data.get('@type'))
except json.JSONDecodeError as e:
print(f"FAIL: JSON-LD parse error: {e}")
required = {'Article', 'BreadcrumbList', 'FAQPage'}
found = set(types)
missing = required - found
if missing:
print(f"FAIL: missing JSON-LD block(s): {missing}")
else:
print("PASS: all 3 JSON-LD blocks present and valid")
PY
2.7 维度七:FAQ 可见性
检查内容:页面中必须存在 <details> 和 <summary> HTML 元素,且它们包含与 JSON-LD FAQPage 一致的问答内容。FAQ 内容必须在 HTML 中可见渲染(未被 display:none 隐藏),因为 Google 会惩罚「结构化数据与用户可见内容不一致」的页面。
为什么 JSON-LD 不够——Google 的「内容一致」要求:Google 的结构化数据指南明确要求:JSON-LD 中声明的 FAQ 内容必须在页面上对用户可见。如果 JSON-LD 中有 5 个 FAQ 问答,但 HTML 中只有 3 个(或完全隐藏),Google 可能发出「结构化数据与内容不匹配」的手动操作警告。对于 Agent 生成的页面,典型风险是:Agent 正确生成了 JSON-LD FAQPage 块,但在 HTML body 中使用了 <div class="faq-hidden"> 或其他非标准标记来包裹 FAQ 内容,导致内容虽然存在于 DOM 中但对用户不可见。
正确的 FAQ HTML 标记:
<!-- 正确的 FAQ 标记(使用 details/summary)-->
<details>
<summary>CI/CD 显示部署成功,为什么还需要 VERIFIED Gate?</summary>
<p>CI/CD 的「部署成功」只验证了字节是否到达服务器...</p>
</details>
常见失败模式:
- FAQ 在 JSON-LD 中但不在 HTML 中:Agent 在生成页面时在 JSON-LD 中定义了 FAQ,但在 HTML body 中使用了普通段落
<p>而非<details>/<summary>。Google 检测到不一致后可能禁用该页面的 FAQ 富文本。 - 使用非标准标记:Agent 使用了
<div class="faq">而非<details>。虽然 div 也可以通过 CSS 实现展开/折叠效果,但<details>是 HTML5 语义元素,Google 专门识别它作为 FAQ 内容的标记。 - CSS 隐藏:FAQ 内容被
display:none或visibility:hidden隐藏。即使 HTML 中有<details>元素,如果初始状态通过 CSS 隐藏,Google 可能视为不可见。 - 内容不匹配:JSON-LD 中的 FAQ 问题文字与
<summary>中的文字不一致(如大小写差异、标点符号差异),Google 的精确匹配算法可能视其为内容不一致。
curl 检查方法:
# 检查 details/summary 元素是否存在
curl -fsS https://xslyl.com/zh/posts/slug.html | grep -c '<details>'
# 期望:>= 5(与 FAQPage 中的问题数量一致)
curl -fsS https://xslyl.com/zh/posts/slug.html | grep -c '<summary>'
# 期望:>= 5
# 检查是否有 CSS 隐藏 FAQ
curl -fsS https://xslyl.com/zh/posts/slug.html | grep -i 'display:\s*none'
# 如果匹配行包含 details 或 FAQ 相关 class:警告
2.8 维度八:安全头
检查内容:HTTP 响应必须包含以下 5 个安全相关响应头,且每个头的值必须符合安全最佳实践:
| 安全头 | 期望值 | 作用 |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
强制浏览器在指定时间内只通过 HTTPS 访问 |
X-Content-Type-Options |
nosniff |
禁止浏览器 MIME 类型嗅探,防止将 HTML 当 JS 执行 |
X-Frame-Options |
SAMEORIGIN |
防止页面被嵌入 iframe(clickjacking 防御) |
Referrer-Policy |
strict-origin-when-cross-origin |
控制 Referer 头的发送策略,跨域时只发送域名 |
Permissions-Policy |
至少包含 camera=(), microphone=(), geolocation=() |
禁用浏览器 API(摄像头、麦克风、地理位置等) |
nginx add_header 继承陷阱——安全头检查中最隐蔽的失败模式:
nginx 的 add_header 指令有一个反直觉的行为:当在 server 块中设置了安全头后,如果某个 location 块中也使用了 add_header(即使是添加完全不同的头),server 块中的所有 add_header 都会被该 location 块覆盖——而不是合并。这是 nginx 文档中明确说明但经常被忽略的行为。
# nginx 配置示例——这个配置有继承陷阱
server {
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=()" always;
location /zh/posts/ {
# 这行 add_header 会导致上面 server 块中
# 所有 5 个安全头在 /zh/posts/ 路径下全部失效!
add_header Cache-Control "public, max-age=3600";
}
}
在这个例子中,访问 /zh/posts/any-article.html 时,浏览器只会收到 Cache-Control 头——所有 5 个安全头全部丢失。这个陷阱的隐蔽之处在于:(1) 全局页面(首页、关于页)的安全头正常工作;(2) 运维人员测试首页时看到所有安全头都正确;(3) 问题只影响特定 location 下的页面,可能几周后才被发现。
解决方案:在每个需要自定义头的 location 块中冗余声明所有安全头(而非依赖 server 块的继承),或使用 ngx_headers_more 模块的 more_set_headers 指令(该指令会合并而非覆盖)。VERIFIED Gate 的安全头检查对每个 URL 独立验证,能暴露这种仅影响特定路径的继承陷阱。
curl 检查方法:
# 检查所有 5 个安全头是否返回
curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1 | grep -iE \
'^(strict-transport-security|x-content-type-options|x-frame-options|referrer-policy|permissions-policy):'
# 期望输出(5 行):
# strict-transport-security: max-age=31536000; includeSubDomains
# x-content-type-options: nosniff
# x-frame-options: SAMEORIGIN
# referrer-policy: strict-origin-when-cross-origin
# permissions-policy: camera=(), microphone=(), geolocation=()
# 脚本化检查
REQUIRED_HEADERS=(
"strict-transport-security"
"x-content-type-options"
"x-frame-options"
"referrer-policy"
"permissions-policy"
)
HEADERS=$(curl -fsSI https://xslyl.com/zh/posts/slug.html 2>&1)
for h in "${REQUIRED_HEADERS[@]}"; do
if ! echo "$HEADERS" | grep -qi "^$h:"; then
echo "FAIL: missing security header: $h"
fi
done
2.9 维度九:Sitemap URL 存在性
检查内容:新部署文章的 zh 和 en URL 都必须出现在 sitemap.xml 中。sitemap.xml 是搜索引擎爬虫发现新页面的主要入口——如果文章 URL 不在 sitemap 中,Google 可能需要数天甚至数周才能通过内部链接发现新页面。
为什么 sitemap 检查不是简单「文件存在」:
- 部署顺序问题:如果部署流程是先 rsync HTML 文件、后重新生成 sitemap,但 rsync 成功而 sitemap 生成失败,那么文章在线但搜索引擎不知道。CI/CD 只看退出码(rsync 返回 0)不会发现这个断链。
- 旧版覆盖:如果部署脚本在生成 sitemap 之前 rsync 了旧的 sitemap.xml(缓存残留),新文章的 URL 可能被旧版 sitemap 覆盖。
- zh/en 不对称:zh URL 在 sitemap 中但 en URL 不在,或反之。这意味着一个语言版本能被搜索引擎快速发现,另一个需要等待自然爬取。
- URL 格式不一致:sitemap 中使用了
http://协议(而非https://),或路径格式与实际 URL 不同(尾斜杠差异、扩展名差异)。
curl 检查方法:
# 检查 zh URL 在 sitemap 中
curl -fsS https://xslyl.com/sitemap.xml | \
grep -c 'https://xslyl.com/zh/posts/slug.html'
# 期望输出:1(恰好一次)
# 检查 en URL 在 sitemap 中
curl -fsS https://xslyl.com/sitemap.xml | \
grep -c 'https://xslyl.com/en/posts/slug.html'
# 期望输出:1
# 脚本化双 URL 检查
ZH_IN_SITEMAP=$(curl -fsS https://xslyl.com/sitemap.xml | grep -c 'zh/posts/slug.html')
EN_IN_SITEMAP=$(curl -fsS https://xslyl.com/sitemap.xml | grep -c 'en/posts/slug.html')
if [ "$ZH_IN_SITEMAP" -eq 0 ]; then echo "FAIL: zh URL missing from sitemap"; fi
if [ "$EN_IN_SITEMAP" -eq 0 ]; then echo "FAIL: en URL missing from sitemap"; fi
2.10 维度十:首页卡片
检查内容:新部署文章的链接必须出现在 zh/index.html 和 en/index.html 的文章卡片列表中。首页是最重要的内部链接——它不仅帮助用户发现内容,更是搜索引擎爬虫评估页面重要性的关键信号。Google 的 PageRank 算法对「从首页可直达」的页面赋予更高的初始权重。
为什么首页卡片检查是必要的:
- 部署版本不一致:文章 HTML 文件已部署到服务器(rsync 了
zh/posts/目录),但index.html使用了缓存或旧版本——新文章在服务器上但在首页中不可见。这个错误的可怕之处在于:(1) 直接访问文章 URL 返回 200——看起来一切正常;(2) 只有通过首页导航的用户和搜索引擎爬虫会发现文章「消失」了;(3) CI/CD 成功标记全绿,因为所有文件都传输成功了。 - zh/en 不对称更新:Agent 更新了 zh/index.html 但遗漏了 en/index.html,或反之。导致一个语言版本的首页显示新文章,另一个不显示。
- 链接格式错误:首页卡片中的 <a> 标签 href 使用了相对路径(
./posts/slug.html)而非绝对路径或正确相对路径,导致从首页点击时 404。
curl 检查方法:
# 检查文章 slug 出现在 zh 首页
curl -fsS https://xslyl.com/zh/ | grep -c 'slug'
# 期望输出:>= 1(文章链接存在于首页卡片中)
# 检查文章 slug 出现在 en 首页
curl -fsS https://xslyl.com/en/ | grep -c 'slug'
# 期望输出:>= 1
# 更精确:检查完整的 href 属性
curl -fsS https://xslyl.com/zh/ | grep -oP 'href="[^"]*slug[^"]*"'
# 期望输出:href="/zh/posts/slug.html" 或完整 URL
2.11 Tier Classification:四层分级体系
不是所有维度同等重要。根据对用户体验、搜索引擎可见性和安全性的影响程度,10 个维度分为四个优先级(Tier)。这个分级决定了 Gate 失败时的响应策略——哪些维度失败时必须阻断发布,哪些可以降级处理。
| Tier | 包含维度 | Gate 行为 | 失败后果 | 典型恢复路径 |
|---|---|---|---|---|
| Tier 1 阻断级 |
HTTP 200 Canonical 自引用 Hreflang 双向互指 |
🔴 FAIL 阻断发布 |
页面不可达(HTTP 失败)、搜索引擎无法正确索引(canonical 错误)、双语结构不被识别(hreflang 缺失)——直接损害发布目的,发布等于没发布 | 修复 HTML/nginx → 重新部署 → 重新验证 |
| Tier 2 高优先级 |
JSON-LD 结构化数据 安全头(5 个) |
🟠 FAIL 建议阻断 |
搜索富文本不展示(JSON-LD 问题 → 搜索结果中无 FAQ 展开、无面包屑),站点安全评级下降(安全头缺失 → SSL Labs 评级降低、浏览器控制台警告) | 修复后重新部署;紧急时可降级为 VERIFIED_WITH_WARNING 先上线、后修复 |
| Tier 3 中优先级 |
FAQ 可见性 Content-Type 页面大小 首页卡片 |
🟡 WARN 不阻断 |
用户可见体验下降(FAQ 不可展开、首页无入口),但不影响搜索引擎索引和页面基本可访问性。可在后续部署周期中修复 | 记录警告,纳入下一轮部署修复清单 |
| Tier 4 运营级 |
Sitemap URL 存在性 robots.txt 一致性 |
⚪ NOTE 仅记录 |
搜索引擎爬取效率降低(新页面发现延迟),但已部署内容完全可访问。robots.txt 错误可能阻止搜索引擎爬取,但通常需要数天才会产生可察觉的影响 | 记录备注,定期批量修复 sitemap |
Tier 机制的三个核心原则:
- Tier 1 失败 = 发布未完成。HTTP 200、canonical 和 hreflang 这三个维度定义了「页面是否可以被搜索引擎正确索引」。Tier 1 中任意一个 FAIL,Gate 必须输出 FAIL,状态不得进入 VERIFIED。没有例外——即使时间紧迫,Tier 1 失败意味着「发布了等于没发布」。
- Tier 2 失败可以降级,但降级必须有记录。JSON-LD 和安全头问题不影响页面的基本可访问性和可索引性。在紧急发布场景(如安全漏洞修复、关键信息更新),可以降级为
VERIFIED_WITH_WARNING先上线。但降级必须在 verify-report.json 中记录:(a) 哪个维度失败、(b) 降级原因、(c) 计划修复时间、(d) 审批人。不允许无记录的静默降级。 - Tier 分类是默认值,可根据站点策略调整。对于安全敏感的站点(如金融、医疗),Tier 2 的安全头检查应该升级为 Tier 1 阻断。对于 SEO 驱动的站点,sitemap 检查也应该从 Tier 4 升级到 Tier 2。分类体系是一个配置项,而非硬编码的规则。xslyl.com 当前的 Tier 分类是基于「技术博客」的定位——内容可访问性优先于搜索优化。
Gate 输出格式:每个维度的检查结果和 Tier 分类都写入 verify-report.json,格式如下:
{
"status": "VERIFIED",
"overall": "PASS",
"checks": {
"http_status": {"result": "PASS", "tier": 1, "detail": "200 OK, no redirect"},
"canonical": {"result": "PASS", "tier": 1, "detail": "self-referencing https://..."},
"hreflang": {"result": "PASS", "tier": 1, "detail": "bidirectional confirmed"},
"jsonld": {"result": "PASS", "tier": 2, "detail": "3 blocks: Article, BreadcrumbList, FAQPage"},
"security_headers": {"result": "PASS", "tier": 2, "detail": "5/5 headers present"},
"faq_visibility": {"result": "PASS", "tier": 3, "detail": "6 details/summary elements"},
"content_type": {"result": "PASS", "tier": 3, "detail": "text/html; charset=utf-8"},
"page_size": {"result": "PASS", "tier": 3, "detail": "28473 bytes"},
"homepage_card_zh": {"result": "PASS", "tier": 3, "detail": "found in zh/index.html"},
"homepage_card_en": {"result": "PASS", "tier": 3, "detail": "found in en/index.html"},
"sitemap_zh": {"result": "PASS", "tier": 4, "detail": "URL present in sitemap.xml"},
"sitemap_en": {"result": "PASS", "tier": 4, "detail": "URL present in sitemap.xml"},
"robots_txt": {"result": "PASS", "tier": 4, "detail": "no disallow on /zh/posts/"}
},
"tier1_failures": [],
"tier2_failures": [],
"tier3_warnings": [],
"tier4_notes": []
}
这个结构确保了下游系统(如状态面板、告警系统、自动修复流水线)可以根据 tier 字段做出差异化响应——Tier 1 失败触发告警和自动回滚,Tier 4 失败仅生成工单。
三、HTTP 与内容层检查
HTTP 层检查是 VERIFIED Gate 的第一道防线。在解析 HTML、验证 SEO 元数据之前,必须先确认服务器返回了正确的 HTTP 响应。这个看似简单的步骤有几个容易被忽略的细节。
3.1 为什么不跟随重定向
大多数 HTTP 客户端(包括浏览器、curl 默认模式、Python requests 库的默认配置)会自动跟随 301/302 重定向。这在日常使用中是便利的,但在部署验证中是危险的——它掩盖了 URL 配置错误。
典型场景:部署完成后,https://xslyl.com/zh/posts/new-article.html 返回 301 重定向到 https://xslyl.com/zh/posts/new-article.html/(多了一个尾部斜杠),然后该 URL 又返回 301 重定向回来——重定向循环。跟随重定向的客户端会超时或报错,但不会告诉你原因是 URL 配置问题。
VERIFIED Gate 使用 follow_redirects=False,直接检查目标 URL 的初始响应。期望值:200 OK,无重定向。如果返回 301/302,说明以下可能问题:
- nginx rewrite 规则错误
- 文件路径与实际 URL 结构不匹配
- HTTP → HTTPS 重定向在应用层而非基础设施层处理
3.2 为什么检查响应体前 500 字节
HTTP 200 OK 不等于「正确页面」。很多 Web 服务器的默认错误页面返回 200 状态码:
- nginx 默认欢迎页:
<html><head><title>Welcome to nginx!</title>...</html> - 某些 CDN 的自定义错误页:
200 OK但 body 是The page you requested was not found - 应用框架的 fallback 页:Laravel 的 Whoops 页面返回 200
VERIFIED Gate 在收到 200 后,检查响应体前 500 字节中是否包含错误特征字符串("404"、"Not Found"、"nginx"、"Error")。这不是完美的启发式检查——可能会有误判(如果文章标题恰好包含 "404")——但配合其他维度(页面大小、slug 匹配)可以大幅降低误判率。
3.3 页面大小:防「空壳」部署
页面大小检查的目标是防止「空壳部署」——文件存在、HTTP 200、但内容不完整。常见原因:
- rsync 中断:网络断开导致文件传输不完整
- git pull 冲突:merge conflict marker 留在文件中,导致页面渲染异常
- 路径错误:部署到了正确的目录但是错误的文件(如同名的旧版本占位文件)
- 编码问题:文件以错误编码传输,导致多字节字符(如中文)损坏
阈值设置为 1KB 是一个保守但有效的判断。任何正常的中文技术文章,仅 HTML 标签和文本内容就会超过 1KB。
3.4 实现:简化版 HTTP/内容验证
以下是一个简化的 Python 实现,展示了 HTTP 与内容层检查的核心逻辑:
# Simplified gate check for basic HTTP/content validation
import httpx
def verify_http_and_content(url: str, slug: str) -> dict:
"""
Verify basic HTTP response and content sanity.
Returns a dict of check_name to bool.
All checks must pass for this dimension to be green.
"""
checks = {}
# 1. HTTP layer: do NOT follow redirects
resp = httpx.get(url, follow_redirects=False, timeout=10)
checks["status_200"] = resp.status_code == 200
checks["no_redirect"] = resp.status_code not in (301, 302, 303, 307, 308)
checks["content_type_html"] = "text/html" in resp.headers.get(
"content-type", ""
)
body = resp.text
# 2. Content sanity: page is not empty or trivially small
checks["page_not_empty"] = len(body) > 1024
checks["page_not_huge"] = len(body) < 500 * 1024 # 500KB sanity cap
# 3. Error page detection: check first 500 chars for error signatures
first_chunk = body[:500].lower()
error_signatures = ["404", "not found", "welcome to nginx", "error"]
checks["not_error_page"] = not any(
sig in first_chunk for sig in error_signatures
)
# 4. Content identity: slug must appear in page title or early body
checks["correct_article"] = slug in body[:2000]
# 5. Robots check: verify robots meta does not block indexing
checks["robots_allows"] = "noindex" not in body[:2000].lower()
# 6. Basic HTML structure
checks["has_html_tag"] = "<html" in body[:500].lower()
checks["has_body_tag"] = "<body" in body[:3000].lower()
return checks
# Usage example
if __name__ == "__main__":
import sys
url = sys.argv[1]
slug = sys.argv[2]
results = verify_http_and_content(url, slug)
passed = all(results.values())
failed = [k for k, v in results.items() if not v]
print(f"URL: {url}")
print(f"Overall: {'PASS' if passed else 'FAIL'}")
if failed:
print(f"Failed checks: {', '.join(failed)}")
for check, value in results.items():
status = "✓" if value else "✗"
print(f" {status} {check}")
几点设计说明:
- httpx 而非 requests:httpx 默认超时配置更严格,且对 HTTP/2 有更好的支持。如果你的生产环境 nginx 上启用了 HTTP/2,requests 库可能无法正确验证。
- slug 匹配放在前 2000 字节:大多数文章会在
<title>或<h1>中包含文章 slug。检查前 2000 字节而非全文是为了性能——不需要解析整个 80KB 的 HTML 来确认身份。 - robots 检查在内容层而非 header 层:robots 指令可以通过 HTTP header(
X-Robots-Tag)或 HTML meta 标签设置。在 VERIFIED Gate 的 HTTP 检查阶段,我们只检查 body 中的noindex。HTTP header 层的 robots 检查放在安全头验证阶段。 - Error 签名列表需要维护:不同 Web 服务器的错误页特征不同。nginx 返回 "Welcome to nginx",Apache 返回 "It works",Caddy 返回 "Caddy"。如果你的部署环境使用了自定义错误页,需要将自定义错误页的特征字符串加入签名列表。
3.5 robots.txt 的特殊性
robots.txt 的检查被放在内容层而非 HTTP 层,原因在于它的验证逻辑与页面验证不同:
- 它不是页面:robots.txt 是纯文本文件,没有 HTML 结构,不需要检查 header/title/body 标签
- 独立 URL:它位于站点根路径(
/robots.txt),与文章页面完全独立 - 检查重点不同:主要验证 (1) robots.txt 是否允许搜索引擎爬取文章页面,(2) 是否错误地 Disallow 了关键路径,(3) Sitemap 指令是否指向正确的 sitemap URL
一个常见的部署事故:robots.txt 中包含 Disallow: /zh/posts/——这是开发环境中用于防止搜索引擎索引测试页面的配置,但被错误地部署到了生产环境。VERIFIED Gate 的 robots.txt 检查应该验证:站点根路径下的文章目录没有被 Disallow,且 Sitemap 指令存在并指向 HTTPS URL。
四、HTTP 与内容层检查 — Python 实现详解
上一节从验证维度矩阵的角度讨论了 HTTP 检查的设计原则——不跟随重定向、检查页面大小、检测错误页签名。本节将这些设计原则转化为可运行的 Python 代码,并深入解释每条检查背后的设计哲学,以及为什么这些检查无法在 CI/CD 中完成。
4.1 完整实现:verify_http_content.py
# verify_http_content.py — Basic HTTP and content-level gate checks
import httpx
CHECKS = {}
def check_http_and_content(url: str, slug: str):
resp = httpx.get(url, follow_redirects=False, timeout=10)
CHECKS["status_200"] = resp.status_code == 200
# Reason: follow_redirects=False catches redirect-to-error-page patterns
# where a 301→302→200 chain silently "passes" but the final 200 is an error page
ct = resp.headers.get("content-type", "")
CHECKS["content_type_html"] = "text/html" in ct
# Reason: Some CDNs/content servers serve .html files as
# application/octet-stream or text/plain
body = resp.text
CHECKS["page_not_empty"] = len(body) > 1024
# Reason: A 200 with < 1KB probably means an error page template
CHECKS["not_error_page"] = all(
marker not in body[:500]
for marker in ["404", "Not Found", "Internal Server Error"]
)
# Reason: Servers can configure error pages that return 200
CHECKS["slug_in_title"] = slug in body[body.find("<title>"):body.find("</title>")]
# Reason: Verify the correct article is served at this URL
return CHECKS
4.2 设计哲学:每条检查为什么这样设计
4.2.1 status_200 + follow_redirects=False
为什么必须禁用重定向跟随:CI/CD 的 smoke test 通常使用 curl 默认模式或 requests.get() 默认配置——两者都会自动跟随 3xx 重定向。这在日常使用中是便利的,但在部署验证中会产生错误遮蔽。以下是三个经典场景:
- 301 循环不被发现:URL A 返回 301 指向 URL B,URL B 返回 301 指向 URL A。跟随重定向的客户端会超时并报
TooManyRedirects,但这个错误信息极其模糊——你只知道「请求失败了」,不知道原因是重定向循环。使用follow_redirects=False,你会直接看到第一次 301 响应,立刻定位到 rewrite 规则问题。 - 重定向到错误页:一个特别阴险的场景:文章 URL 返回 301 →
/error.html(nginx 自定义错误页),该错误页返回 200 OK。如果跟随重定向,最终响应是 200,看起来一切正常。但实际访问的页面是错误页,用户看到了「页面未找到」而非文章内容。只有禁用重定向跟随,才能发现初始 URL 根本没有返回 200。 - HTTP → HTTPS 重定向不在基础设施层处理:理想的架构中,HTTP → HTTPS 重定向由 CDN 或负载均衡器在边缘完成,应用服务器只处理 HTTPS。但某些部署中,nginx 被配置为对 HTTP 请求返回 301。如果 HTTP URL 的检查被自动跟随到 HTTPS,你会得到 HTTPS URL 的 200 响应——误认为 HTTP 也配置正确了。实际上 HTTP URL 本身从未返回过 200。
为什么 CI/CD 做不到:CI/CD 的 smoke test 目的是验证「服务是否存活」,跟随重定向是合理的行为——只要最终有一个 200 就说明服务在运行。但 VERIFIED Gate 的目的是验证「正确的页面是否在正确的 URL 上返回了正确的状态码」,所以必须检查初始响应的状态码。两个层级的目标截然不同。
4.2.2 content_type_html
为什么 text/html 在 content-type 中而非严格等于:检查使用 "text/html" in ct 而非 ct == "text/html",是为了兼容包含 charset 声明的 Content-Type 头(如 text/html; charset=utf-8)。但仅限于 text/html 的包含性检查——如果 Content-Type 是 application/xhtml+xml,虽然技术上也是 HTML,但对 xslyl.com 的静态 HTML 文件来说,这通常意味着 mime.types 配置错误。
易被忽视的 CDN 陷阱:某些 CDN 提供商(如 Cloudflare)在开启「Auto Minify」功能后,会修改 Content-Type 头。具体来说,如果源站返回 text/html; charset=utf-8,Cloudflare 的压缩引擎可能剥离 charset 部分,返回 text/html。这通常不是问题(浏览器默认 UTF-8),但如果没有在 VERIFIED Gate 中显式验证 Content-Type,你可能永远不会发现 CDN 修改了响应头——直到某天 CDN 配置变更导致更严重的头篡改。
4.2.3 page_not_empty(>1KB 阈值)
1KB 阈值的选择逻辑:一个有完整 HTML 结构的最小合法页面(包含 DOCTYPE、html、head、title、body 标签,以及面包屑导航和页脚)大约需要 600-800 字节。如果加上任何正文内容(哪怕只有一段话)、meta 标签、以及 JSON-LD 结构化数据,轻松超过 1024 字节。一个小于 1KB 的「页面」只有三种可能:空文件、截断文件、或服务器错误页模板(如 nginx 默认欢迎页约 600 字节)。
误判边界:对于 SPA 应用,HTML 文件可能确实很小(仅包含 <div id="app"></div> 和 script 标签),大小可能落在 500-800 字节。但对于 xslyl.com 的静态文章页面(所有内容内联在 HTML 中),1KB 阈值没有误判风险。如果你的站点使用 SPA 架构,可以将此阈值下调至 256 字节,但必须同时启用其他交叉验证(如 slug_in_title、jsonld 存在性)来弥补小页面的检测盲区。
4.2.4 not_error_page(前 500 字节签名检查)
为什么只检查前 500 字节:错误页面的标识性特征("404"、"Not Found"、"Error"、"nginx")几乎总是出现在 HTML 的头部区域——<title> 标签或页面顶部的大标题。检查前 500 字节已经足够捕获绝大多数服务器默认错误页。检查全文(如 80KB)只会增加 CPU 消耗和误判率——一篇技术文章中很可能包含 "404 Not Found" 这类的技术讨论文字。
签名列表维护的实操建议:错误签名列表不应该硬编码在 Gate 脚本中。更好的做法是维护一个 config/error_signatures.json 文件,包含不同 Web 服务器的特征字符串:
{
"nginx": ["welcome to nginx", "404 not found", "50x"],
"apache": ["it works", "apache http server"],
"generic": ["internal server error", "service unavailable"]
}
部署时根据实际使用的 Web 服务器加载对应的签名列表。这样当你更换 Web 服务器(如从 nginx 迁移到 Caddy)时,只需更新配置文件,无需修改 Gate 代码。
4.2.5 slug_in_title(身份验证)
为什么检查 slug 在 title 中而不检查完整标题:验证「正确文章被返回」的最简单方法是检查文章的唯一标识符(slug)是否出现在页面标题中。为什么不检查完整标题?因为标题可能在本地构建和线上部署之间有细微差异(如标点符号的全角/半角转换、HTML 实体的编码方式)。slug 是 URL 的一部分,是稳定且唯一的标识符——如果 slug 出现在 title 中,几乎可以确定是正确页面。
为什么只在 title 标签内搜索而非全文:全文搜索 slug 会产生大量误判——文章 body 中可能多次出现 slug 词汇(如文章中讨论自己的 URL)。但 title 标签只出现一次,且几乎总包含文章的主要标识。在 title 标签内搜索 slug 是最精确的身份验证方法,同时避免了对 80KB 全文的昂贵的正则搜索。
4.3 为什么 CI/CD 捕获不了这些
CI/CD 流水线运行在隔离的构建环境中,它的验证范围是文件本身——文件是否存在、语法是否正确、构建是否成功。它不触及线上环境,因此无法验证以下问题:
| 问题类型 | 具体场景 | 为什么 CI/CD 无法捕获 |
|---|---|---|
| 部署传输错误 | rsync 中断、网络超时导致文件不完整、磁盘满导致文件截断 | CI/CD 检查的是源文件(Git 仓库中的文件),不是部署后服务器上的文件。传输层的错误只发生在 CI/CD 和服务器之间——这是 CI/CD 的盲区 |
| Web 服务器配置错误 | nginx mime.types 缺失、rewrite 规则错误导致 301、location 块覆盖 Content-Type | CI/CD 不管理运行时 Web 服务器配置。nginx 的配置文件可能由运维人员手动修改,与 CI/CD 流水线完全解耦 |
| CDN/缓存层污染 | CDN 缓存了旧版错误页、CDN 修改了 Content-Type 头、边缘节点返回过期缓存的 404 | CI/CD 运行在内网环境,不经过 CDN。CDN 的行为对 CI/CD 完全透明 |
| 环境差异 | 开发/预发布环境配置泄漏到生产(staging URL、开发用 robots.txt 等) | CI/CD 在统一的环境中构建/测试。生产环境的特定配置(域名、HTTPS 证书、robots.txt)可能与构建环境不同,CI/CD 无法模拟 |
核心洞见:CI/CD 的验证范围止于「构建产物是否正确」,VERIFIED Gate 的验证范围始于「部署结果是否正确」。两者之间横跨着网络传输、文件系统、Web 服务器配置、CDN 缓存——这些都在 CI/CD 的控制范围之外,但直接影响用户和搜索引擎看到的最终结果。
五、SEO 元数据验证:canonical、hreflang、JSON-LD
SEO 元数据是 VERIFIED Gate 中最复杂、也最容易出错的验证维度。HTML 中的 SEO 标签不是独立存在的——它们之间存在交叉引用关系(hreflang 的双向性)、自引用约束(canonical 必须指向自己)、以及跨块一致性要求(JSON-LD FAQPage 必须与 HTML 中的 FAQ 内容一致)。本节给出完整的 Python 验证实现,并深入解释每条规则背后的搜索引擎行为逻辑。
5.1 完整实现:verify_seo.py
# verify_seo.py — SEO metadata gate checks
import re
def check_canonical(html: str, expected_url: str) -> bool:
"""Verify canonical URL is self-referencing and matches expected URL."""
match = re.search(r'<link\s+rel="canonical"\s+href="([^"]+)"', html)
return match is not None and match.group(1) == expected_url
def check_hreflang_bidirectional(html: str, lang: str, other_lang: str,
zh_url: str, en_url: str, default_url: str) -> dict:
"""Check bidirectional hreflang + x-default."""
patterns = re.findall(
r'<link\s+rel="alternate"\s+hreflang="([^"]+)"\s+href="([^"]+)"',
html
)
result = {
"zh_to_zh": False,
"zh_to_en": False,
"en_to_en": False,
"en_to_zh": False,
"x_default": False,
"bidirectional": False
}
for hl, href in patterns:
if hl == "zh" and href == zh_url:
result["zh_to_zh"] = True
if hl == "en" and href == en_url:
result["en_to_en"] = True
if hl == "x-default" and href == default_url:
result["x_default"] = True
# Bidirectional: zh page declares en, AND en page declares zh
result["bidirectional"] = all([
result["zh_to_zh"], result["zh_to_en"],
result["en_to_en"], result["en_to_zh"]
]) if lang == "zh" else all([
result["en_to_en"], result["en_to_zh"]
])
return result
def check_jsonld_blocks(html: str) -> dict:
"""Verify Article, BreadcrumbList, FAQPage are all present."""
blocks = re.findall(r'"@type":\s*"([^"]+)"', html)
types_found = set(blocks)
return {
"has_article": "Article" in types_found,
"has_breadcrumb": "BreadcrumbList" in types_found,
"has_faqpage": "FAQPage" in types_found,
"all_three_present": all(
t in types_found for t in ["Article", "BreadcrumbList", "FAQPage"]
)
}
5.2 canonical 必须自引用:防止跨语言 SEO 泄露
什么是「自引用 canonical」:页面 <link rel="canonical"> 的 href 值严格等于当前页面的完整 URL。即页面声称自己就是权威版本——/zh/posts/article.html 的 canonical 指向 https://xslyl.com/zh/posts/article.html,而不是任何其他 URL。
为什么必须精确匹配整个 URL:如果 canonical 指向 /zh/posts/article(无 .html 扩展名),Google 会将其视为不同的 URL。搜索引擎可能在索引中同时保留两个版本,并将排名权重分散到两个 URL 上——这被称为「重复内容稀释」。更致命的是跨语言泄露:
- 跨语言 canonical 泄露:zh 页面的 canonical 错误地指向了
/en/posts/article.html。Google 收到这个信号后,认为 en 版本是权威版本,zh 版本是「重复内容」。后果:zh 页面从中文搜索结果中消失,所有中文搜索流量归零。这个错误极其隐蔽——zh 页面完全可访问、渲染正常、所有其他 SEO 标签正确,只是在搜索引擎眼中「不存在」。 - staging 域名残留:canonical 写的是
https://staging.xslyl.com/...。部署到生产后,Google 尝试访问 staging URL(如果 staging 环境仍在线),将 staging 版本索引为权威版本。如果 staging 已下线,Google 会将整个页面标记为「canonical URL 不可达」,严重降低索引优先级。 - HTTP 协议:canonical 使用
http://而非https://。Google 视 HTTP 和 HTTPS 为不同 URL。如果 HTTP 版本存在 301 到 HTTPS,但 canonical 仍指向 HTTP,Google 会陷入矛盾——canonical 说 HTTP 是权威版本,但 HTTP 又重定向到 HTTPS。
Agent 为什么会犯这个错:Agent 在生成 HTML 时通常从模板或上下文变量中推断 canonical URL。如果模板中的域名变量在开发环境设置为 localhost 或 staging,部署时没有覆盖,canonical 就会带着错误的域名上线。对于双语站点的 Agent 工作流,Agent 可能在生成 zh 页面后,用 zh 页面的 HTML 作为模板生成 en 页面,但忘记更新 canonical 标签——导致 en 页面的 canonical 仍指向 zh URL。
5.3 hreflang 必须双向:Google 将单向视为未确认
Google 的 hreflang 确认机制:Google 官方文档明确指出——hreflang 注解必须双向确认。如果页面 A 引用页面 B 作为替代版本,页面 B 也必须引用页面 A 作为替代版本。单向的 hreflang 被 Google 完全忽略(不是降级,是忽略)。这是搜索引擎领域少有的「全有或全无」规则。
为什么设计成这样:从 Google 的角度,单向 hreflang 是不可信的——任何人都可以在自己的页面中添加 hreflang 标签声称某个 URL 是自己的替代版本。只有双向确认能证明两个页面的所有者是同一个人(因为你需要修改两个页面),从而防止恶意第三方通过 hreflang 劫持搜索流量。
双向验证的实际执行:Verifying hreflang 不能仅通过检查单个页面的 HTML 完成。必须:
- 请求 zh 页面,提取其所有 hreflang 标签
- 请求 en 页面,提取其所有 hreflang 标签
- 交叉比对:zh 页面是否声明了 en?(zh_to_en)
- 交叉比对:en 页面是否声明了 zh?(en_to_zh)
- 两者都成立 → 双向确认 → PASS
- 任一缺失 → 单向 → FAIL
这要求 VERIFIED Gate 对每个部署任务发起至少 2 个 HTTP 请求(zh 和 en 页面各一个),并进行交叉引用比对。这是所有验证维度中唯一需要跨页面协调的检查——其余 9 个维度都可以独立验证单个页面。
x-default 的特殊作用:hreflang="x-default" 告诉搜索引擎「当用户的语言与所有声明的 hreflang 值都不匹配时,应使用哪个页面」。对于 xslyl.com,x-default 指向 en(英文作为默认回退语言)。缺少 x-default 不会导致 hreflang 完全失效,但当用户使用非中英文浏览器(如日语、法语)访问时,搜索引擎可能随机选择一个语言版本,而非预期的英文版。
Agent 生成的双语页面为什么容易出错:Agent 通常分两步生成双语内容:先生成 zh 页面,然后「翻译」生成 en 页面。如果 en 页面的生成过程使用了简化模板(仅替换了正文内容),可能遗漏了 hreflang 标签的更新。或者 Agent 正确生成了 zh 页面的 hreflang(含 en 引用),但在生成 en 页面时只添加了 zh 的 hreflang(因为 Agent「知道」有中文版本),却没有添加自引用(en 的 hreflang="en")——这同样破坏了双向性。
5.4 JSON-LD 必须包含三种类型:FAQPage 缺失是 Google 政策违规
三种 JSON-LD 块各自不可替代:
| JSON-LD 类型 | 搜索结果效果 | 缺失后果 | Agent 典型错误 |
|---|---|---|---|
| Article | 搜索结果中显示标题、描述、发布日期、作者;可出现在 Google News 中 | 搜索结果仅显示 URL 和页面片段(无结构化预览),Google News 不收录 | Agent 用 BlogPosting 代替 Article;缺少必填字段(datePublished、author) |
| BreadcrumbList | 搜索结果中显示面包屑导航(xslyl.com > 文章 > 标题),点击率提升约 5-10% | 搜索结果仅显示裸 URL(不显示层级结构),用户无法判断页面在站点中的位置 | Agent 生成 Article 和 FAQPage 但遗漏 BreadcrumbList;position 编号不连续 |
| FAQPage | 搜索结果中显示可展开的问答列表(FAQs),占据更多搜索版面,点击率最高 | FAQ 内容被忽略,搜索结果中不显示问答列表——失去最具视觉冲击力的搜索展示 | FAQ JSON-LD 存在但对应的 HTML 无 details/summary;或 FAQ JSON-LD 的 question 文本与 summary 文本不一致 |
FAQPage 的 Google 政策违规风险:FAQPage 是三个 JSON-LD 块中监管最严格的。Google 明确要求:JSON-LD 中声明的 FAQ 内容必须在页面上对用户可见。如果 JSON-LD 包含 5 个 FAQ,但 HTML 中只有 3 个使用 <details>/<summary> 标记(另外 2 个是普通段落),Google 的结构化数据测试工具会发出警告,严重情况可能导致该页面的所有结构化数据被忽略——包括 Article 和 BreadcrumbList。这是「连带惩罚」——一个 JSON-LD 块的违规影响其他合法的块。
JSON-LD 语法错误的最隐蔽特征:JSON-LD 块的语法错误(多余的逗号、未转义的引号)在浏览器中不可见。浏览器渲染 HTML 时忽略 <script type="application/ld+json"> 标签内的内容,JSON 解析错误不会触发任何浏览器控制台警告。页面看起来完美,但所有结构化数据对搜索引擎静默失效。只有两个方法能发现:Google Rich Results Test(手动工具)和 VERIFIED Gate 的 JSON.parse 验证(自动化)。
5.5 SEO 验证的 CI/CD 盲区
与 HTTP 层检查类似,SEO 元数据检查也无法在 CI/CD 中完成:
- canonical URL 的域名依赖运行时环境:CI/CD 构建时,
https://xslyl.com不是一个可达的域名(CI runner 在内网)。即使构建脚本尝试解析 canonical 标签,它也无法验证该 URL 是否真实可访问——因为实际的 DNS 解析和 HTTPS 握手发生在生产环境中。 - hreflang 的双向验证需要两个已部署的页面:在 CI/CD 构建环境中,只有当前构建的文件,没有已部署的对侧语言页面。hreflang 的双向交叉验证必须等到两个语言版本都部署到生产环境后才能执行。
- JSON-LD 的 URL 一致性无法在构建时验证:构建脚本可以检查 JSON 语法,但无法验证 JSON-LD 中的 URL 是否与实际部署 URL 一致——因为部署 URL 在构建时是未知的(可能通过环境变量注入,或由 CDN 动态拼接)。
- FAQ 内容一致性需要可见渲染验证:Google 的「结构化数据与用户可见内容一致」要求本质上是一个运行时检查——需要检查浏览器渲染后的 DOM。CI/CD 无法进行浏览器渲染。
这些SEO 验证必须作为部署后的自动化检查运行——这是 VERIFIED Gate 的独特价值。
六、安全头验证与 nginx add_header 继承陷阱
安全头验证在 VERIFIED Gate 中属于 Tier 2(高优先级)——失败时建议阻断发布,因为安全头缺失会直接降低站点安全评级。但安全头检查中最隐蔽的失败模式不是「忘记配置」,而是 nginx 的 add_header 继承陷阱——运维人员在 server 块中正确配置了所有安全头,测试首页时一切正常,但特定路径下的页面实际上没有任何安全头返回。
6.1 五个必需安全头及其验证方法
VERIFIED Gate 验证以下 5 个 HTTP 安全响应头,每个头都有明确的期望值和失败后果:
Strict-Transport-Security:期望值max-age=31536000; includeSubDomains。强制浏览器在接下来一年内只通过 HTTPS 访问本站,阻断所有中间人降级攻击。缺失时:浏览器允许用户通过 HTTP 访问,存在 SSLStrip 攻击面。X-Content-Type-Options:期望值nosniff。禁止浏览器进行 MIME 类型嗅探——如果没有这个头,浏览器可能将用户上传的 HTML 文件当作文本渲染而执行其中的脚本。缺失时:存在 MIME 混淆攻击面。X-Frame-Options:期望值SAMEORIGIN。防止页面被其他域名的<iframe>嵌入(clickjacking 防御)。缺失时:任意第三方网站都可以将你的页面嵌入 iframe 并进行点击劫持。Referrer-Policy:期望值strict-origin-when-cross-origin。控制 Referer 请求头的发送策略——同源时发送完整 URL,跨域时只发送域名(不含路径和查询参数),HTTPS→HTTP 降级时不发送。缺失时:浏览器默认行为(可能泄露完整 URL 给第三方)。Permissions-Policy:期望值至少包含camera=(), microphone=(), geolocation=()。显式禁用浏览器敏感 API(摄像头、麦克风、地理位置),防止恶意脚本或 XSS 注入后调用这些 API。缺失时:页面中的任何脚本(包括第三方广告脚本)都可以请求这些权限。
6.2 nginx add_header 继承陷阱:问题解剖
nginx 的 add_header 指令有一条文档中写明但常被忽视的规则:当子级上下文(location 块)中出现任何 add_header 指令时,父级上下文(server 块)中所有 add_header 都会被丢弃——不是合并,是完全覆盖。
这个行为的文档来源是 nginx 官方文档中对 add_header 的描述:"There could be several add_header directives. These directives are inherited from the previous configuration level if and only if there are no add_header directives defined on the current level." 翻译:「当且仅当当前层级没有定义任何 add_header 指令时,才会从上一配置层级继承。」
这意味着:只要 location 块中出现了哪怕一行 add_header,server 块中所有安全头的层层保护就在这个 location 下全部失效。
以下是最常见的陷阱配置:
# ❌ 有陷阱的 nginx 配置
server {
listen 443 ssl;
# 运维人员在 server 块中添加了 5 个安全头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
# ✅ 没有 add_header → 继承 server 块的所有头
try_files $uri $uri/ =404;
}
location /stats/ {
# ❌ 只添加了 Cache-Control,但忘记了安全头
# 结果:server 块中所有 5 个安全头在此 location 下全部消失!
add_header Cache-Control "public, max-age=3600";
# 只有 Cache-Control 被返回——Strict-Transport-Security、X-Content-Type-Options 等全都不存在
}
}
这个陷阱为什么容易被忽视:
- 首页测试正常:
location /块没有 add_header,安全头正常继承。运维人员在浏览器 DevTools 中打开首页,看到所有 5 个安全头返回正确——误以为全局配置生效。 - 只影响特定路径:
/stats/路径下的页面完全没有安全头,但除非有人专门检查这个路径,否则不会被发现。 - nginx -t 不会报错:
nginx -t只检查语法合法性,不检查逻辑一致性。这个配置在语法上完全合法。 - 延迟发现:问题可能几周甚至几个月后才通过安全扫描工具暴露——而在此期间,特定路径下的页面一直以不安全的方式被访问。
6.3 正确的 nginx 配置
方案一(推荐——冗余声明):在每个需要自定义头(哪怕只是 Cache-Control)的 location 块中重新声明所有 5 个安全头。虽然冗余,但消除了继承陷阱,且不依赖第三方模块。
# ✅ 正确——每个 location 重新声明所有 5 个安全头
server {
listen 443 ssl;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
# ✅ 没有 add_header → 继承 server 块的所有头
try_files $uri $uri/ =404;
}
location /stats/ {
# ✅ 重新声明所有 5 个安全头 + 此 location 自定义头
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Cache-Control "public, max-age=3600";
# 现在 /stats/ 路径下的页面既有安全头也有 Cache-Control
}
}
方案二(需要安装第三方模块——使用 ngx_headers_more):more_set_headers 指令会合并而非覆盖父级的头(类似于 map 的叠加语义),从根本上消除了继承陷阱。但需要额外编译或安装 nginx-extras 包。如果你的部署环境可以安装第三方模块,这是更优雅的解决方案。
方案三(使用 include 文件——适合大型配置):将 5 个安全头提取到单独的配置文件 /etc/nginx/conf.d/security-headers.conf,在每个需要安全头的 location 块中 include 它。这避免了在每个 location 块中重复 5 行 add_header,同时保持了显式声明的好处。
# /etc/nginx/conf.d/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# 在需要自定义头的 location 块中
location /stats/ {
include conf.d/security-headers.conf; # 引入安全头
add_header Cache-Control "public, max-age=3600"; # 自定义头
}
6.4 VERIFIED Gate 安全头验证——Python 实现
验证脚本对每个目标 URL 独立发起请求,检查 5 个必需安全头是否全部返回且值符合预期。因为 nginx 继承陷阱只影响特定 location,所以必须对每个 URL 独立验证——不能依赖「首页检查通过就全局通过」的假设。
# verify_security_headers.py — 安全头验证
import httpx
REQUIRED_HEADERS = {
"strict-transport-security": "max-age=31536000",
"x-content-type-options": "nosniff",
"x-frame-options": "SAMEORIGIN",
"referrer-policy": "strict-origin-when-cross-origin",
# Permissions-Policy 至少包含 camera=(), microphone=(), geolocation=()
}
def check_security_headers(url: str) -> dict:
"""
验证目标 URL 是否返回所有必需的安全响应头。
每个头进行子串匹配——例如 'max-age=31536000' in 头的完整值。
这允许头的值包含额外参数(如 includeSubDomains),同时仍然验证核心要求。
Returns:
dict: {header_name: bool} — True 表示该头存在且值包含预期内容
"""
resp = httpx.get(url, follow_redirects=False, timeout=10)
checks = {}
lower_headers = {k.lower(): v for k, v in resp.headers.items()}
for header, expected_value in REQUIRED_HEADERS.items():
actual = lower_headers.get(header, "")
checks[header] = expected_value in actual
# Permissions-Policy 特殊检查:必须包含至少 3 个禁用的 API
pp = lower_headers.get("permissions-policy", "")
checks["permissions-policy"] = all(
f"{api}=()" in pp for api in ["camera", "microphone", "geolocation"]
)
checks["all_passed"] = all(checks.values())
return checks
# 使用示例
if __name__ == "__main__":
import sys
url = sys.argv[1] if len(sys.argv) > 1 else "https://xslyl.com/zh/posts/test.html"
results = check_security_headers(url)
print(f"URL: {url}")
print(f"Overall: {'PASS' if results.pop('all_passed') else 'FAIL'}")
for header, passed in results.items():
print(f" {'✓' if passed else '✗'} {header}")
设计要点:
- 子串匹配而非精确匹配:安全头的值可能包含额外参数(如
Strict-Transport-Security的完整值可能是max-age=31536000; includeSubDomains; preload)。子串匹配确保核心要求被满足,同时允许合理的扩展。 follow_redirects=False:与 HTTP 状态检查一致——如果 URL 经过重定向,原始 URL 的安全头配置可能不同。检查必须针对目标 URL 的直接响应。- Permissions-Policy 独立检查:由于 Permissions-Policy 的格式更灵活(指令可以以任意顺序出现),单独检查其中至少包含 3 个关键 API 的禁用声明。
- 头名称大小写不敏感:HTTP 头名称在协议层面是大小写不敏感的,但 Python 的 httpx 保留原始大小写。将响应头字典转换为小写键以避免大小写导致的遗漏。
为什么 CI/CD 无法完成安全头验证:
- 安全头由 nginx 在响应中添加,而非 HTML 文件中:CI/CD 可以检查构建产物中的 HTML meta 标签(如
<meta http-equiv="Content-Security-Policy">),但 HTTP 响应头(X-Frame-Options、HSTS 等)只能由 Web 服务器在运行时添加。CI/CD 构建环境中没有运行中的 nginx。 - location 块的行为无法在 CI/CD 中模拟:nginx 的配置继承逻辑(特别是 add_header 的覆盖行为)依赖于实际的请求-响应周期。即使 CI/CD 读取了 nginx 配置文件,也无法静态分析出某个 location 块是否会触发继承陷阱——因为陷阱取决于运行时实际匹配的 location。
- CDN 层可能修改/剥离安全头:某些 CDN 提供商会剥离它们不识别的响应头。如果 CI/CD 直接测试源站而生产流量经过 CDN,CI/CD 检查将漏掉 CDN 导致的安全头缺失。
七、站点地图与首页卡片一致性检查
VERIFIED Gate 的最后两个维度——Sitemap URL 存在性和首页卡片——看似简单,但它们在验证体系中扮演着关键角色:确保已部署的内容既能被搜索引擎发现,也能被人类用户找到。这两个维度都属于中低优先级(Tier 3-4),但它们捕获的失败模式能解释大量「文章在线上但没人看」的沉默故障。
7.1 为什么必须同时检查 Sitemap 和首页
Sitemap 和首页服务于两个完全不同的发现路径:
| 对比维度 | Sitemap | 首页卡片 | 不一致后果 |
|---|---|---|---|
| 使用者 | 搜索引擎爬虫(Googlebot、Bingbot 等) | 人类用户(通过浏览器访问首页) | 搜索引擎能发现页面但用户通过首页找不到 → 搜索流量到站后用户困惑 |
| 更新频率 | 部署时重新生成的静态 XML 文件 | 部署时更新的 HTML 页面(卡片列表) | 部署脚本可能更新 sitemap 但未更新 index.html,或反之 → 不一致 |
| 失败模式 | sitemap.xml 未包含新 URL(生成步骤失败 / 旧版本覆盖) | index.html 未更新(rsync 覆盖了旧版 / 生成步骤遗漏) | 两个路径的失败模式不同 → 必须独立检查,不能互相替代 |
| 影响速度 | 缺失时:新页面需要数天甚至数周才能被搜索引擎自然发现 | 缺失时:人类用户访问首页时看不到新文章,影响即时的用户体验 | 搜索引擎影响滞后数天(难以快速定位根因),用户影响即时(但只有主动访问首页的人发现) |
核心洞见:Sitemap 和首页是两扇独立的门。一个可以打开而另一个关闭。如果只检查 Sitemap(认为「搜索引擎能找到就行」),你会漏掉首页卡片断裂——用户通过搜索来到站点后,点击首页想浏览其他文章,却找不到刚刚读完的那篇。如果只检查首页(认为「用户能看到就行」),你会漏掉 Sitemap 缺失——搜索引擎爬虫不会主动遍历你的首页链接列表,它依赖 Sitemap 作为新内容的信号。
VERIFIED Gate 对两者进行独立验证,是因为它们由不同的生成步骤产生、有不同的失败模式、服务于不同的受众。两者同时 PASS 才是真正完整的部署。
7.2 检查一:Sitemap URL 存在性
检查内容:部署目标文章的 zh 和 en URL 都必须出现在 https://xslyl.com/sitemap.xml 中。这是最直接的检查——字符串匹配,不存在歧义。
为什么检查 zh 和 en 两个 URL:对于双语站点,Sitemap 的生成可能是分步的——例如部署脚本先生成了 zh 页面的 sitemap 条目,但生成 en 页面的条目时出错。或 sitemap 生成模板只包含了 /zh/posts/ 目录的文章,遗漏了 /en/posts/ 目录。只检查一个语言版本会漏掉不对称的生成错误。
URL 格式验证的细节:检查时应该验证完整 URL(含 https:// 前缀),而非仅 slug。原因:(1) 如果 sitemap 中意外使用了 http:// 协议,搜索引擎会收到矛盾的信号(sitemap 说 http,但页面 canonical 说 https);(2) 如果 sitemap 中使用了错误的域名(如 staging 域名残留),搜索引擎会尝试爬取不可达的 URL。
7.3 检查二:Sitemap 结构完整性
检查内容:Sitemap XML 必须具有正确的结构——以 <urlset> 开标签开始,以 </urlset> 闭标签结束,且在 </urlset> 之后没有额外的字符或截断内容。
为什么这个检查是必要的:
- 中断的生成过程:sitemap 生成脚本可能在运行时崩溃——文件有开标签但无闭标签。搜索引擎解析 XML 时遇到不完整文档会直接丢弃整个 sitemap(不是丢弃最后一条记录,是丢弃所有记录)。
- 进程残留:sitemap 生成脚本的输出可能被 shell 重定向截断(如磁盘满导致 write 系统调用返回错误)。文件系统上存在该文件,但内容是截断的。
- 模板注入错误:如果 sitemap 生成使用模板引擎,模板错误可能导致多余字符附加在
</urlset>之后(如 PHP 的 warning 消息、Python traceback、或空白字符)。XML 解析器严格要求根元素之后不能有内容。 - 搜索引擎对损坏 XML 的处理:Google 和 Bing 都遵循 XML 1.0 规范——根元素之后任何非空白字符都会导致解析失败。搜索引擎不会「尽力解析」损坏的 sitemap,而是直接忽略整个文件。这意味着你可能有一个 99% 完整的 sitemap 但搜索引擎完全不用它。
检查方法:不仅仅是 grep '</urlset>'(这只能证明闭标签存在于文件中的某处)。正确的检查是验证:(1) 闭标签存在;(2) 闭标签之后没有非空白字符。一个简单的检查方法:获取 sitemap 文本,找到 </urlset> 的位置,确认其后所有的字符都是空白(换行、空格、制表符)。
7.4 检查三:首页卡片存在性
检查内容:部署目标文章的链接(至少是 slug)必须出现在 https://xslyl.com/zh/index.html 和 https://xslyl.com/en/index.html 的文章卡片列表中。
首页卡片检查的微妙之处:
- 不是检查页面可访问性,而是检查可发现性:文章 URL 本身返回 200 只能证明「如果用户知道 URL,可以访问」。首页卡片检查验证的是「如果用户访问首页,能否发现这篇文章」。两者是完全不同的保证。
- zh/en 不对称更新的高发性:在 Agent 发布管道中,zh 版本通常是主要生成目标,en 版本是翻译衍生版。Agent 在生成 zh 内容后可能自动更新了 zh/index.html,但在翻译生成 en 内容时遗漏了更新 en/index.html。这种不对称更新是 Agent 工作流的典型缺陷。
- 链接格式的潜在问题:即使卡片存在,链接的 href 属性也可能是错误的——如使用了
./posts/slug.html而非/zh/posts/slug.html(在 en/index.html 中这会导致 404,因为 en 版本的首页在/en/目录下)。验证应同时检查链接格式的正确性。
7.5 VERIFIED Gate Sitemap 与首页验证——Python 实现
以下是一个整合的验证脚本,一次性完成三个检查:
# verify_sitemap_and_homepage.py — Sitemap 与首页卡片一致性验证
import httpx
import re
def check_sitemap_and_homepage(slug: str) -> dict:
"""
验证已部署文章在 sitemap 和首页中的存在性与一致性。
Args:
slug: 文章的唯一标识符(URL slug),如 "agent-verified-deployment-gate-design"
Returns:
dict: 包含三个检查维度结果的字典,每个维度为 bool
"""
results = {}
# ========== 检查一:Sitemap URL 存在性 ==========
try:
sitemap_resp = httpx.get("https://xslyl.com/sitemap.xml", timeout=10)
sitemap = sitemap_resp.text
zh_url = f"/zh/posts/{slug}"
en_url = f"/en/posts/{slug}"
results["zh_url_in_sitemap"] = zh_url in sitemap
results["en_url_in_sitemap"] = en_url in sitemap
# 额外验证:URL 使用 https 协议(而非 http)
# 在 sitemap 中搜索包含该 slug 的完整 URL
zh_full_pattern = re.findall(
rf'(https?://[^<]*{re.escape(zh_url)}[^<]*)', sitemap
)
en_full_pattern = re.findall(
rf'(https?://[^<]*{re.escape(en_url)}[^<]*)', sitemap
)
results["zh_sitemap_https"] = all(
url.startswith("https://") for url in zh_full_pattern
) if zh_full_pattern else results["zh_url_in_sitemap"]
results["en_sitemap_https"] = all(
url.startswith("https://") for url in en_full_pattern
) if en_full_pattern else results["en_url_in_sitemap"]
except Exception as e:
results["zh_url_in_sitemap"] = False
results["en_url_in_sitemap"] = False
results["sitemap_error"] = str(e)
# ========== 检查二:Sitemap 结构完整性 ==========
try:
# 验证 XML 结构:必须有 urlset 闭标签
# 且闭标签之后不能有非空白字符(XML 1.0 规范要求)
results["sitemap_closed"] = "</urlset>" in sitemap
if results["sitemap_closed"]:
# 检查闭标签之后是否只有空白字符
closing_pos = sitemap.rfind("</urlset>") + len("</urlset>")
after_closing = sitemap[closing_pos:]
results["sitemap_well_formed"] = after_closing.strip() == ""
else:
results["sitemap_well_formed"] = False
# 额外验证:开标签存在(确认不是一个碰巧包含 </urlset> 字符串的文本文件)
results["sitemap_has_opening"] = " 1 else "agent-verified-deployment-gate-design"
results = check_sitemap_and_homepage(slug)
print(f"Slug: {slug}")
print(f"Overall: {'PASS' if results.pop('all_critical_passed') else 'FAIL'}")
print()
print("=== Sitemap ===")
print(f" {'✓' if results['zh_url_in_sitemap'] else '✗'} zh URL in sitemap.xml")
print(f" {'✓' if results['en_url_in_sitemap'] else '✗'} en URL in sitemap.xml")
print(f" {'✓' if results.get('sitemap_closed', False) else '✗'} </urlset> closing tag")
print(f" {'✓' if results.get('sitemap_well_formed', False) else '✗'} Well-formed XML")
print()
print("=== Homepage ===")
print(f" {'✓' if results['zh_slug_in_home'] else '✗'} slug in zh/index.html")
print(f" {'✓' if results['en_slug_in_home'] else '✗'} slug in en/index.html")
7.6 设计哲学:为什么检查看似「显而易见」
有人可能质疑:Sitemap 和首页都是部署过程自动生成的,为什么要专门验证?答案在于部署过程的原子性缺陷。
部署脚本通常包含多个步骤——rsync 文章文件、重新生成 sitemap、更新 index.html、rsync 更新后的文件——但这些步骤之间没有事务保证。步骤 1(rsync 文章文件)可能成功,但步骤 2(重新生成 sitemap)因为磁盘空间不足而静默失败(sitemap 生成器输出了错误消息到 stderr 但没有返回非零退出码)。步骤 3(更新 index.html)可能因为模板变量未传递而使用了旧版缓存。
CI/CD 看到所有步骤的退出码都是 0(因为每个步骤都「执行了」,即使输出不正确),标记部署成功。但最终用户看到的是一个不一致的站点状态:文章存在但搜索引擎不知道,或文章存在但首页没有入口。
VERIFIED Gate 的 Sitemap 和首页检查正是在验证「部署过程的最终输出是不是一致的」——这是一个整合检查,而非步骤检查。它不关心每个部署步骤是否成功执行,只关心最终状态是否正确。这个区别是 VERIFIED Gate 和 CI/CD 最根本的哲学差异。
7.7 恢复路径:Sitemap 或首页检查失败时
Sitemap 和首页都属于低优先级维度(Tier 3-4),失败时不会阻断发布,但需要记录和跟进:
- Sitemap URL 缺失:手动重新生成 sitemap 并 rsync 到服务器。如果 sitemap 生成脚本有 bug,先修复脚本,然后触发重新部署。
- Sitemap 结构损坏:检查 sitemap 生成脚本的输出日志,定位中断原因(磁盘空间?进程被杀?模板错误?),修复后重新生成。
- 首页卡片缺失:手动更新 index.html 并 rsync,或修复首页生成模板后重新部署。如果是 zh/en 不对称更新(zh 首页有卡片但 en 没有),补充 en 首页的卡片条目。
- Sitemap 存在但首页不存在(或反之):这揭示了部署脚本中的逻辑分支错误——sitemap 生成和首页更新可能走了不同的代码路径。需要审计部署脚本确保两者使用相同的数据源。
八、部署完整性验证:commit 一致性 + 紧急部署路径
前七个验证维度关注的是「部署到线上的内容是否正确」——HTTP 状态、SEO 元数据、安全头、Sitemap、首页卡片。但还有一个更根本的问题尚未回答:部署到服务器上的文件,真的是 GitHub 上合并的那个版本吗?
这不是一个理论问题。在 Agent 发布的管道中,从 GitHub 的 merge commit 到 VPS 上实际运行的 HTML 文件之间,存在多个可能引入偏差的环节:git pull 可能因为网络中断只拉取了部分文件、deploy-xslyl.sh 可能使用了错误的源目录、rsync 可能因为磁盘空间不足而截断文件。如果部署的文件与预期不一致,前面七个维度的检查——无论多么精细——都是在检查「错误的内容」。
第八个验证维度正是为此设计:三方一致性模型——GitHub SHA ↔ VPS git log ↔ 在线内容——确保部署链路上的每一个环节都没有引入偏差。
8.1 三方一致性模型
部署完整性的验证基于一个简单但严格的三方比对:
GitHub SHA (merge_commit)
│
├──► VPS git log (vps_commit)
│ │
│ └──► 在线内容 (curl 后校验)
│
三方必须一致,任一环节不匹配 = 部署不完整
三个锚点的定义:
| 锚点 | 来源 | 含义 |
|---|---|---|
merge_commit |
GitHub PR merge event | 用户或 Agent 在 GitHub 上合并的 commit SHA——这是「应该被部署的内容」的权威标识 |
vps_commit |
VPS git log -1 --format=%H |
VPS 上 git pull 后仓库的 HEAD commit——这是「实际被部署工具使用的内容源」 |
| 在线内容 | HTTP GET 目标 URL | 用户和搜索引擎实际看到的内容——这是「最终交付物」 |
为什么必须三方比对而非两方:
- merge_commit = vps_commit 但在线内容错误:git pull 成功,但 deploy 脚本失败(rsync 未执行、目标路径错误、磁盘满)。这是最常见也是最隐蔽的失败模式——git 操作一切正常,但文件从未到达 Web 目录。
- vps_commit = 在线内容 但 merge_commit ≠ vps_commit:VPS 上的仓库是旧版本(git pull 失败但未报错),deploy 脚本从旧版本部署了旧内容。在线内容「自洽」但「不是最新版本」。
- 基于签名的验证补充:对于静态 HTML 页面,可以进一步比对 GitHub 上源文件的 SHA256 哈希与 VPS 上部署文件的哈希。如果 git pull 在 merge conflict 中产生了 conflict marker(
<<<<<<<),哈希比对可以立即捕获。但这种文件级校验是可选的补充,不是三方一致性模型的核心。
8.2 标准部署路径
标准部署遵循一条确定的命令链:
git pull origin main → deploy-xslyl.sh → rsync to /var/www/html/ → verify
Gate 从这条路径中捕获四个关键信息:
| 字段 | 类型 | 含义 | 失败模式 |
|---|---|---|---|
merge_commit |
string (40-char SHA) | 在 GitHub 上合并的 commit | N/A(来源于 GitHub API,可靠) |
vps_commit |
string (40-char SHA) | VPS 上 git pull 后的 HEAD | ≠ merge_commit → git pull 失败或未执行 |
deploy_script_result |
"success"/"failure" | deploy-xslyl.sh 的退出状态 | failure → rsync 未执行或脚本错误 |
dry_run |
boolean | 是否为演习(dry-run)模式 | true → 未实际部署,必须为 false |
dry_run 检查的重要性:部署脚本通常支持 --dry-run 模式用于测试。Agent 在执行部署时可能错误地使用了 dry-run 模式——脚本输出显示「部署成功」,但实际上没有任何文件被写入 Web 目录。这个错误极其隐蔽:所有输出看起来正常,但线上内容没有任何变化。Gate 必须显式检查 dry_run 字段为 false 才确认部署真正发生了。
8.3 紧急部署路径
当标准部署路径不可用时——SSH 密钥问题、git 服务器宕机、VPS 磁盘 I/O 故障——可能需要使用替代方法将文件传输到生产环境。紧急部署方法不是主路径,但必须被 Gate 识别和正确处理。
三种紧急部署方法:
| 方法 | 命令示例 | 风险 |
|---|---|---|
| emergency_scp | scp file.html user@vps:/var/www/html/zh/posts/ |
只传输单个文件,不更新 git 仓库——导致 VPS 仓库与在线内容不一致,后续 git pull 可能产生冲突 |
| emergency_rsync_no_delete | rsync -av --no-delete ./zh/ user@vps:/var/www/html/zh/ |
不删除目标端多余文件——如果旧版本有额外文件,它们会残留在服务器上,可能导致旧版 URL 仍然可访问 |
| tar pipe | tar czf - zh/ | ssh user@vps "tar xzf - -C /var/www/html/" |
无删除模式 + 无 git 同步 + 管道错误难捕获——如果 tar 在本地失败但管道已开启,VPS 端可能收到空流并解压出空目录 |
紧急部署的 Gate 规则:
- 紧急部署必须获得用户显式批准。Agent 不能在标准部署失败后自动切换到紧急部署——必须先向用户报告标准部署失败的原因,并提出紧急部署方案,等待用户明确同意("同意"、"执行"、"yes")。这是强制性审批操作。
- 紧急部署的结果标记为
VERIFIED_WITH_WARNING,而非VERIFIED。原因:紧急部署绕过了 git 同步,VPS 仓库与 GitHub 仓库从此不一致。后续的标准部署(git pull)可能产生冲突。WARNING 标记提醒运维人员需要在条件恢复后执行一次「同步部署」——即 git pull + deploy 的标准路径——以恢复仓库一致性。 - 紧急部署仍需运行完整 VERIFIED Gate。即使部署方法不同,部署后的内容验证(HTTP 状态、SEO 元数据、安全头、Sitemap、首页卡片)必须全部通过。唯一跳过的只有 commit 一致性检查(因为 VPS git log 已与 GitHub 不一致)。
8.4 check_deploy_integrity 实现
以下是部署完整性检查的简化实现:
def check_deploy_integrity(deploy_report: dict, status: dict) -> dict:
"""
验证部署完整性:commit 一致性 + 部署路径正确性。
Args:
deploy_report: 部署过程报告,包含 deploy_method, result, git_pull_result,
deploy_script_result, dry_run, emergency_* 等字段
status: 任务状态对象,包含 task_id 等标识信息
Returns:
checks: 每个检查项的 PASS/FAIL 结果字典
"""
checks = {}
# 1. 基本身份验证:确认部署报告对应正确的任务
checks["task_id_match"] = deploy_report.get("task_id") == status.get("task_id")
# 2. 部署方法是否已知且合法
checks["method_known"] = deploy_report.get("deploy_method") in [
"vps_git_pull_main_plus_deploy_script",
"emergency_scp", "emergency_rsync_no_delete"
]
# 3. 部署结果是否为 PASS
checks["result_pass"] = deploy_report.get("result") == "PASS"
# 4. 根据部署方法执行不同的检查
method = deploy_report.get("deploy_method", "")
if "emergency" in method:
# 紧急部署路径:必须获得用户批准
emergency = deploy_report.get("emergency_direct_transfer", False)
approval = deploy_report.get("emergency_approval", {})
checks["emergency_approved"] = (
emergency
and approval.get("approved_by_user") is True
)
# 紧急部署不检查 git 一致性
checks["git_consistency"] = "SKIPPED"
else:
# 标准部署路径:git pull 和 deploy 脚本均须成功
checks["git_pull_ok"] = (
deploy_report.get("git_pull_result") == "success"
)
checks["script_ok"] = (
deploy_report.get("deploy_script_result") == "success"
)
# dry_run 必须为 false(确认不是演习)
checks["not_dry_run"] = (
deploy_report.get("dry_run") is False
)
# commit 一致性
checks["commit_match"] = (
deploy_report.get("merge_commit")
== deploy_report.get("vps_commit")
)
return checks
设计要点:
task_id_match是第一道检查:防止部署报告被错误关联到不相关的任务。在 Agent 发布管道中,多个任务可能并行执行(一篇 zh 文章 + 一篇 en 文章),部署报告必须能追溯到其对应的任务。method_known是白名单检查:如果 deploy_method 不在已知列表中(如出现了未知的 "manual_ftp"),Gate 不假设该方法是安全的——它标记为 FAIL,需要人工审查。- 紧急部署跳过 commit 一致性检查是合理的:因为紧急部署不经过 git pull,
vps_commit自然不等于merge_commit。但这是已知的、有记录的不一致——通过VERIFIED_WITH_WARNING状态和部署报告中记录的偏差,运维人员知道需要在后续修复。 dry_run检查是防御性编程:Agent 不应该使用 dry-run 模式进行正式部署。如果 deploy_report 中 dry_run=true,即使其他所有字段正常,部署实际上没有发生。这属于部署脚本的使用错误,Gate 必须捕获。
8.5 commit 一致性的价值:防止「部署了错误的版本」
commit 一致性检查回答了部署完整性中最重要的问题:「部署的版本,就是合并的版本吗?」以下是一个真实场景:
- Agent 完成了一篇文章的修改,提交了 PR,用户审核后合并到 main。
- GitHub Actions 触发部署流程。Agent 在 VPS 上执行
git pull origin main。 - 网络瞬时中断导致 git pull 只拉取了部分对象。git 没有报错(因为已有的对象覆盖了大部分),但 HEAD 停留在旧 commit。
- deploy-xslyl.sh 从旧 commit 部署了旧版本的文章。
- 前七个维度的验证全部通过——因为旧版本的文章 HTML 结构也是完整的,SEO 元数据也存在,安全头也正常。
- 但用户看到的不是最新版本。新的修改(如修正了一个错误的 canonical URL)没有被部署。
在这个场景中,如果没有 commit 一致性检查,VERIFIED Gate 会标记 VERIFIED——但部署的实际上是旧版本。commit 一致性检查通过比对 GitHub SHA 和 VPS HEAD SHA,在部署流程的「最上游」验证了内容源的完整性。这是所有下游检查(HTTP、SEO、安全头等)有效的前提。
九、元检查:Gate 版本一致性 + 策略确认
前面八个维度检查的是「部署到线上的内容是否正确」。但还有一个更深层的问题:执行这些检查的 Gate 脚本本身,是正确的版本吗?
这是一个自指问题(self-referential problem):验证系统需要验证自身。如果 Gate 脚本被意外降级到旧版本——缺少最新检查维度、使用过时的阈值、或跳过了新发现的失败模式——它会返回 PASS,但这个 PASS 是基于不完整的检查得出的。一个过期的 Gate 脚本比没有 Gate 更危险:它给人「已验证」的虚假信心,而实际上验证根本没有发生。
第九个维度——元检查——正是为了解决这个问题而存在。它包含两个独立的子检查:Gate 脚本版本一致性和策略确认。
9.1 Gate 脚本版本一致性
问题定义:VERIFIED Gate 不是一个单一的脚本,而是一套脚本的组合:
scripts/agent/
├── check_ready_gate.py # Phase 7: 部署前准备验证
├── check_verified_gate.py # Phase 8: 部署后在线验证
├── check_vm_evidence.py # VM 执行证据收集
└── check_policy_ack.py # 策略确认验证
这些脚本在逻辑上是协同工作的——check_ready_gate.py 验证部署前的条件,check_verified_gate.py 验证部署后的结果,check_vm_evidence.py 收集执行证据,check_policy_ack.py 验证流程合规性。它们必须协同演化——当 check_verified_gate.py 新增了一个检查维度时,相关的证据收集逻辑可能也需要更新。
版本一致性规则:所有 Gate 脚本必须声明相同的 GATE_SCRIPT_VERSION。这是一个单调递增的版本号(如 "2026-06-07-v3"),当任何一个 Gate 脚本被修改时,版本号必须更新且所有脚本必须同步到相同版本。
为什么版本必须全局一致:
- API 契约:Gate 脚本之间通过 JSON 文件(verify-report.json、status.json、vm-evidence.json)交换数据。如果
check_verified_gate.py的 v3 版本在 verify-report.json 中新增了commit_match字段,但check_policy_ack.py仍在 v2 版本(不识别该字段),策略确认检查会忽略这个新维度——导致部署完整性检查的失效。 - 阈值同步:如果页面大小阈值在 v3 中从 512 字节调整为 1024 字节,但某个 Gate 脚本仍使用 v2 的 512 字节阈值,同一个页面可能在一个脚本中 PASS 而在另一个脚本中 FAIL——产生不可调和的结果冲突。
- 失败模式库同步:当发现一个新的失败模式(如 nginx add_header 继承陷阱),所有 Gate 脚本都需要更新以识别和处理该模式。版本不一致意味着某些脚本不知道这个新模式。
实现:
# 每个 Gate 脚本的顶部声明
GATE_SCRIPT_VERSION = "2026-06-07-v3"
# check_verified_gate.py 在执行检查之前验证所有脚本版本一致
def check_gate_script_versions(gate_dir: str) -> dict:
"""验证所有 Gate 脚本的版本声明一致。"""
import ast, os
gate_scripts = [
"check_ready_gate.py",
"check_verified_gate.py",
"check_vm_evidence.py",
"check_policy_ack.py"
]
versions = {}
for script in gate_scripts:
path = os.path.join(gate_dir, script)
if not os.path.exists(path):
versions[script] = None # 脚本缺失
continue
with open(path) as f:
tree = ast.parse(f.read())
for node in ast.walk(tree):
if (isinstance(node, ast.Assign)
and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id == "GATE_SCRIPT_VERSION"):
versions[script] = node.value.value
break
else:
versions[script] = None # 未声明版本
unique_versions = set(v for v in versions.values() if v is not None)
return {
"consistent": len(unique_versions) <= 1,
"versions": versions,
"missing": [s for s, v in versions.items() if v is None],
"mismatched": len(unique_versions) > 1
}
版本不一致时的 Gate 行为:
- 如果任何脚本缺失 → Gate 返回
FAIL,状态进入NEEDS_HUMAN。Gate 套件不完整意味着无法完成全部验证。 - 如果所有脚本存在但版本不一致 → Gate 返回
FAIL(硬失败),不进入VERIFIED_WITH_WARNING的降级路径。版本不一致的 Gate 套件无法提供可信的验证结果——检查维度可能不完整、输出格式可能不兼容、子检查器可能缺失。运维人员必须手动审查脚本版本差异,更新版本到一致后重新运行 Gate。 - 如果所有脚本存在且版本一致 → 元检查 PASS,继续执行后续验证。
9.2 策略确认
问题定义:check_policy_ack.py 验证任务报告(task report)中是否包含所需的策略确认(policy acknowledgment)字段。这是一个元检查,因为它验证的是「验证过程本身是否遵循了规则」。
策略确认包含的内容:
- 命令审批策略确认:任务报告必须声明该任务中执行的命令是否遵循了命令审批策略(Command Approval Policy)。具体包括:(1) 哪些操作在 Auto-Allowed 范围内、(2) 哪些操作触发了 Mandatory Approval 并已获得用户批准、(3) 是否有 BLOCKED 模式被规避。
- 执行路径声明:如果任务使用了 sub-agent(delegate_task、Codex、Codex CLI),必须声明 sub-agent 的职责边界——哪些操作由 sub-agent 自主执行,哪些被上报并获得了用户批准。
- 写操作审批记录:如果任务涉及文章文件创建/编辑(zh/posts/*.html、en/posts/*.html)、git commit、git push、部署操作,必须记录用户的批准时间和方式。
为什么策略确认是一个元检查:
策略确认不直接验证部署内容——它验证的是内容生产过程的合规性。没有策略确认,你可以得到一个「所有维度都 PASS」的验证结果,但不知道这个结果是否是在遵守安全策略的前提下得出的。例如:
- Agent 在没有用户批准的情况下执行了 git commit 和 git push,将未审查的代码部署到了生产环境。
- 前八个维度的检查全部 PASS——因为 Agent 生成的 HTML 在技术上是正确的。
- 但部署的内容可能包含用户不希望发布的敏感信息、错误的观点表述、或未被授权的内容修改。
策略确认元检查确保「验证结果的正确性」不掩盖「生产过程的违规」。
check_policy_ack 验证的关键字段:
# task_report.json 中必须包含的 policy_ack 字段
{
"policy_ack": {
"command_approval_policy_read": true,
"auto_allowed_operations": [
"git status", "git diff", "git log",
"grep -c pattern file", "read_file(...)", "search_files(...)"
],
"user_approved_operations": [
{
"operation": "edit zh/posts/slug.html",
"approval_time": "2026-06-07T14:30:00Z",
"approval_method": "user replied '同意'"
}
],
"blocked_patterns_avoided": true,
"sub_agent_boundary": {
"used_sub_agent": true,
"sub_agent_role": "Codex — Engineering QA Gate Worker",
"autonomous_operations": ["run check_verified_gate.py", "collect curl outputs"],
"user_approved_operations": []
},
"commit_approved": true,
"push_approved": false,
"deploy_approved": true
}
}
策略确认缺失时的 Gate 行为:
- 如果
policy_ack字段完全缺失 → Gate 返回FAIL。无法确认该任务是按照安全策略执行的。 - 如果
policy_ack存在但command_approval_policy_read为 false → Gate 返回FAIL。Agent 在没有阅读命令审批策略的情况下执行了命令。 - 如果
policy_ack存在但某个必须用户批准的操作(如 git commit)在user_approved_operations中没有记录 → Gate 返回FAIL。这意味着可能有未批准的敏感操作被执行。 - 如果
policy_ack存在且所有审批记录完整 → 元检查 PASS。
9.3 元检查的整体设计哲学
元检查的核心理念可以概括为一句话:一个过期或不完整的 Gate 脚本,比没有 Gate 更危险。
没有 Gate 时,你至少知道「部署没有被验证」——你会更加谨慎,可能会手动检查关键页面。但一个过期或不完整的 Gate 给你 VERIFIED 的绿色标记,让你以为一切正常,而实际上关键的检查维度被跳过了、失败的信号被忽略了、或者检查本身就不完整。
Gate 版本一致性检查确保「验证引擎」是完好的。策略确认检查确保「验证过程」是合规的。两者结合,构成了对 VERIFIED Gate 本身的信任基础——如果元检查不通过,下游的八个验证维度无论结果如何,都不能被视为可靠的。
| 元检查项 | 验证目标 | 失败后果 | 恢复路径 |
|---|---|---|---|
| Gate 版本一致性 | 所有 Gate 脚本声明相同的 GATE_SCRIPT_VERSION | Gate 套件不一致 → 验证结果不可信 → 状态退回到 NEEDS_HUMAN | 同步所有 Gate 脚本到最新版本,重新运行验证 |
| 策略确认 | 任务报告包含完整的 policy_ack 字段,所有敏感操作有审批记录 | 无法确认安全合规性 → 部署虽正确但生产过程违规 → 状态 FAIL | Agent 补充策略确认记录;如果确实存在未批准操作,需要人工审查 |
将元检查纳入 VERIFIED Gate 后,完整的验证维度矩阵从 10 个扩展到 12 个——前八个验证部署内容,第九个验证验证引擎本身,第十个验证验证过程的合规性。这 12 个维度合在一起,构成了从「字节到达服务器」到「验证系统本身可信」的完整验证闭环。
十、验证 Gate 失败时的恢复路径
VERIFIED Gate 的全部价值体现在它失败的时候。一个只有 PASS 没有 FAIL 的 Gate 是安慰剂——它让人感觉安全,但没有提供实际的保护。真正的 Gate 必须明确回答一个问题:当某个维度失败时,下一步应该做什么?
没有恢复路径的验证结果只是半成品——它告诉你「哪里坏了」,但没有告诉你「怎么修」。本节为每个验证维度定义明确的恢复路径,并建立「fix forward」与「rollback」的决策框架。
10.1 逐维度恢复路径
每个验证维度的失败都有特定的根因和对应的修复方法。以下是完整的恢复路径矩阵:
| 失败维度 | 典型根因 | 修复动作 | 验证动作 |
|---|---|---|---|
| HTTP 200 | 文件路径错误、nginx location 不匹配、rsync 目标目录错误 | 修复服务器配置 → 重新部署:确认文件存在于正确的 /var/www/html/zh/posts/ 路径下;检查 nginx try_files 和 root 指令;修正后 rsync 重新传输 |
curl -fsS 确认 200,无重定向 |
| Canonical | Agent 使用了错误域名(staging/localhost)、跨语言指向、HTTP 协议 | 修复文章 HTML → 重新部署:修改 <link rel="canonical"> 的 href 为正确的 https 生产 URL;确认自引用正确 |
grep canonical → 比对期望 URL |
| Hreflang | 单向引用、缺失 x-default、URL 路径不一致 | 修复两个语言版本的 head 区域 → 重新部署:补充缺失的 hreflang 标签;确保 zh↔en 双向互指且 x-default 指向 en | 交叉比对 zh 和 en 页面的 hreflang 标签 |
| JSON-LD | 缺失某个块(如 BreadcrumbList)、JSON 语法错误、URL 协议错误 | 修复结构化数据 → 重新部署:补充缺失的 JSON-LD 块;修复 JSON 语法(逗号、引号);将 URL 协议改为 https | python3 JSON.parse 验证三个块 |
| 安全头 | nginx add_header 继承陷阱、配置遗漏、CDN 剥离 | 修复 nginx 配置 → nginx reload → 重新验证:在受影响 location 块中补充安全头声明;执行 nginx -t && nginx reload |
curl -fsSI 检查 5 个安全头 |
| Sitemap | 生成脚本失败、旧版覆盖、zh/en 不对称 | 修复 sitemap 生成逻辑 → 重新生成 → 重新部署:确保生成脚本包含 zh 和 en 两个目录;验证生成后 sitemap 结构完整 | grep sitemap.xml 确认 URL 存在 |
| 首页卡片 | index.html 未更新、部署了旧版、zh/en 不对称 | 修复 index.html → 重新部署:更新 zh/index.html 和 en/index.html 的卡片列表;重新 rsync 两个首页文件 | grep index.html 确认 slug 出现在链接中 |
核心原则:所有恢复路径都要求 fix → redeploy → re-verify 的完整循环。不允许跳过重新验证直接标记为 PASS——因为修复动作可能引入新的问题(如修复 canonical 时意外破坏了 hreflang)。重新验证确保修复是完整的,且没有副作用。
10.2 Fix Forward vs Rollback:决策框架
当 VERIFIED Gate 失败时,面临两种恢复策略:fix forward(修正问题并重新部署)和 rollback(回退到上一个已知良好的部署)。选择哪种策略取决于失败的性质和影响范围。
| 对比维度 | Fix Forward | Rollback |
|---|---|---|
| 定义 | 在现有部署的基础上修正具体问题,然后重新部署修正后的版本 | 将服务器恢复到上一个通过 VERIFIED Gate 的部署状态,放弃当前部署的全部变更 |
| 适用场景 | 内容级问题:canonical 错误、hreflang 缺失、JSON-LD 语法问题、首页卡片遗漏——这些问题只影响当前部署的文章,不影响站点的其他内容 | 结构级问题:部署本身损坏(文件截断、目录结构破坏、nginx 配置语法错误导致全局 500)、部署了完全错误的版本(wrong repo、wrong branch) |
| 优点 | 保留所有内容变更(包括与失败无关的其他修改);修复范围小、速度快;不丢失任何已部署的正确内容 | 立即恢复站点到已知正确状态;消除所有不确定性;为离线诊断争取时间 |
| 缺点 | 修复过程本身可能引入新问题;需要多次部署+验证循环 | 丢失自上次成功部署以来的所有内容变更;用户在一段时间内看不到已发布的内容更新 |
| 推荐度 | ✅ 首选策略——适用于 90%+ 的 Gate 失败场景 | ⚠️ 仅当部署本身结构性损坏时使用 |
为什么 fix forward 是首选:对于内容级问题(canonical 错误、hreflang 缺失、JSON-LD 问题等),fix forward 是更优的选择,原因是:
- 保留所有其他内容变更:一次部署可能包含多篇文章的发布或更新。如果因为一篇文章的 canonical 错误就回滚整个部署,会同时撤销其他正确文章的发布——而这些文章的用户可能已经通过搜索或分享链接在访问了。回滚意味着这些用户看到的是旧版本或 404。
- 问题隔离:内容级问题的根因通常是特定文件的特定行(如某个 HTML 标签写错了)。修复这一行不影响部署中的其他文件。fix forward 让你精确地修复问题而不影响正确的内容。
- 修复速度:修正一行 HTML 并重新 rsync 通常只需要几十秒。而 rollback 需要:确认上一个正确版本 → git reset → 重新部署 → 验证整站状态(不仅是当前文章,还需确认回滚没有引入其他问题)。
- 避免「回滚引起的回归」:回滚到旧版本可能重新引入旧版本中已修复的问题。例如,当前部署修复了一个安全头配置错误,但因为另一篇文章的 canonical 问题触发了回滚,安全头修复也被回退了——导致站点退回到不安全状态。
Rollback 仅应在以下情况使用:
- 部署文件结构损坏:rsync 传输了错误的目录结构(如
/zh/posts/下出现了/en/posts/的文件),无法通过修复单个文件来恢复。 - nginx 配置全局性错误:部署过程中修改了 nginx 主配置文件,导致整个站点返回 500。此时任何 fix forward 修改 HTML 都无济于事——因为服务器根本不返回任何页面。
- 部署了完全错误的版本:git pull 拉取到了错误的 branch(如
staging而非main),整个仓库内容都不对。 - 安全事件:部署的内容包含安全漏洞(如 XSS payload、敏感信息泄露),需要立即将其从线上移除。
10.3 恢复决策树
以下决策树将上述分析编译为可操作的决策流程。当 VERIFIED Gate 返回 FAIL 时,按照此决策树选择恢复策略:
VERIFIED Gate 返回 FAIL
│
▼
部署结构是否完好?(文件存在、服务器响应、nginx 未报 500)
│
├── YES ──► 内容级问题(canonical/hreflang/JSON-LD/sitemap/首页卡片)
│ │
│ └──► Fix Forward:修复内容 → 重新部署 → 重新验证
│
└── NO ──► 问题可在服务器上直接修复吗?
│
├── YES(如安全头缺失、单文件路径错误)
│ │
│ └──► 就地修复:修正 nginx 配置/移动文件 → nginx reload → 重新验证
│ (注意:就地修复后,需将修复同步回 Git 仓库,防止下次部署覆盖修复)
│
└── NO(如整个部署损坏、目录结构错误)
│
└──► Rollback:回退到上一个 VERIFIED 部署 → 离线修复 → 重新部署
决策树使用的三个关键问题:
- 「部署结构是否完好?」——检查服务器是否在响应 HTTP 请求、nginx 是否正常运行、文件系统结构是否完整。如果连基本的 HTTP 服务都不可用,内容级修复没有意义。
- 「问题可在服务器上直接修复吗?」——有些问题不需要重新部署整个仓库(如 nginx 配置中的安全头遗漏、单个文件放错了目录)。就地修复更快,但必须将修复提交回 Git 仓库,否则下次
git pull会覆盖掉修复。 - 「Rollback 的目标版本是什么?」——回退到上一个通过 VERIFIED Gate 的部署。这要求部署系统维护一个「已知良好版本」的记录(通常通过
verify-report.json中的last_verified_commit字段)。
与 Tier 体系的协作:恢复路径的选择与验证维度的 Tier 分级(见第二章)密切相关:
- Tier 1 维度失败(HTTP/canonical/hreflang):必须修复或回滚,不允许降级发布。
- Tier 2 维度失败(JSON-LD/安全头):优先 fix forward;紧急时可降级为
VERIFIED_WITH_WARNING先上线后修复——但降级必须记录在 verify-report.json 中。 - Tier 3-4 维度失败(FAQ 可见性/Content-Type/页面大小/首页卡片/Sitemap):记录警告,在下一个部署周期中修复,不阻断当前发布。
十一、在 Agent 发布管道中部署 VERIFIED Gate
VERIFIED Gate 不是一个独立的工具——它是 Agent 发布管道的一个有机组成部分。这一节解释 Gate 如何在管道中运行、由谁执行、以及如何触发状态机的转移。
11.1 管道集成:VERIFIED Gate 作为最终阶段
在 xslyl.com 的 Agent 发布管道(8 阶段 Gate 管线)中,VERIFIED Gate 是 Phase 8——最后一个阶段。它的前置阶段是 Phase 7(READY Gate——部署前准备验证),后置是状态终态(VERIFIED 或 FAILED)。
Agent 发布管道(简化视图)
═══════════════════════════════════════════════════════
Phase 1-6: 内容准备阶段(草稿 → 审核 → 修改 → 审批 → 最终化)
│
▼
Phase 7: READY Gate — 部署前验证
│ ├── 验证所有 14 个必需章节存在
│ ├── 验证所有链接可达
│ ├── 验证 HTML 结构有效
│ └── 通过后允许进入部署阶段
│
▼
Phase 8: 部署执行 — git pull + deploy-xslyl.sh + rsync
│
▼
Phase 8: VERIFIED Gate — 部署后在线验证 ◀── 本节焦点
│ ├── HTTP 状态验证
│ ├── SEO 元数据验证(canonical, hreflang, JSON-LD)
│ ├── 安全头验证
│ ├── Sitemap 与首页卡片验证
│ ├── Commit 一致性验证
│ └── 元检查(Gate 版本 + 策略确认)
│
▼
状态终态: VERIFIED ✅ 或 FAILED ❌
VERIFIED Gate 不是 CI/CD 流水线的一部分,而是在 CI/CD 完成部署后、由 Agent 在 repo 本地触发的验证阶段。这个设计选择有深层原因:
- 审计轨迹在 GitHub:Gate 脚本(
scripts/agent/check_verified_gate.py)存放在 Git 仓库中,每次执行的结果(verify-report.json)也提交到仓库。这意味着完整的验证历史——谁在什么时候验证了什么维度、结果如何——可以通过git log追溯。如果 Gate 在 CI/CD 中运行,验证结果通常仅保存在 CI/CD 的日志系统中,与代码仓库解耦。 - Gate 脚本的版本与仓库内容同步:因为 Gate 脚本存放在仓库中,当你 git checkout 到某个历史 commit 时,你得到的是与该 commit 对应的 Gate 脚本版本。这确保了历史验证的可复现性——你可以用当时的 Gate 标准重新验证当时的内容。
- Agent 自主执行闭环:在 Agent 发布管道中,Agent 负责整个发布流程——从内容生成到部署验证。如果 VERIFIED Gate 需要依赖 CI/CD 触发,Agent 就必须等待 CI/CD 的异步回调,增加了流程的复杂性。从 repo 本地运行 Gate 意味着 Agent 可以在部署完成后立即执行验证,形成同步闭环。
11.2 执行者:Codex 作为 Gate Worker
在 xslyl.com 的 Agent 架构中,VERIFIED Gate 的执行者是 Codex(Engineering QA & Gate Worker)。为什么是 Codex 而非 Hermes-Agent?
| 角色 | 职责 | 为什么不适合执行 Gate |
|---|---|---|
| Hermes-Agent | 任务编排、用户交互、内容审核、流程协调 | Hermes 负责内容生成和发布决策——如果它也执行验证,就失去了「独立验证」的意义(自己检查自己的工作) |
| Codex | 工程 QA、Gate 执行、在线验证、确定性脚本运行 | ✅ 适合——Codex 是工程 QA 角色,有 repo 访问权限和在线验证工具;作为独立角色提供「他检」而非「自检」 |
Codex 的能力边界:
- Codex 可以独立执行:运行 Gate 脚本、发起 HTTP 请求、解析响应、比对结果。这些是确定性操作,不涉及内容判断或创造性工作。
- Codex 不能自行判断 Gate 结果:Gate 的结果由脚本的确定性输出决定,Codex 只负责运行脚本并收集输出。Codex 不能「我觉得这个维度可以通过」——它必须依赖脚本的 PASS/FAIL 判定。
- Codex 将结果写入结构化文件:验证完成后,Codex 将每个维度的结果写入
verify-report.json,并更新status.json中的任务状态。Hermes-Agent 读取这些文件后向用户报告最终结论。
11.3 状态机集成:从 deployed 到 verified
VERIFIED Gate 的核心作用是在 Agent 状态机中触发状态转换。在任务状态机中,部署完成后状态为 deployed,但这不是终态——deployed 只表示「字节到达了服务器」,不表示「内容正确」。VERIFIED Gate 的执行结果将状态从 deployed 推进到终态:
状态转换图(Deploy → Verify 段)
═══════════════════════════════════════
[deploying] ──► [deployed] ──► VERIFIED Gate 执行
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
[verified] [failed] [verified_with_warning]
全部 PASS Tier 1 失败 Tier 2 降级通过
终态 ✅ 终态 ❌ 终态 ⚠️
三个终态的含义:
verified:所有验证维度 PASS,部署内容完整且正确。这是唯一「绿色」的终态,代表发布圆满完成。failed:Tier 1 维度(HTTP/canonical/hreflang)失败,或部署结构损坏。发布未完成,内容对用户或搜索引擎不可见/不正确。需要进入恢复流程。verified_with_warning:Tier 2 维度失败但已记录降级(如 JSON-LD 缺失但紧急发布),或使用了紧急部署路径(scp/rsync 导致 commit 一致性跳过)。内容基本可用,但存在已知的不完美之处,需要在后续修复。
11.4 完整管道集成代码
以下 Python 代码展示了 Codex 如何执行 VERIFIED Gate 并触发状态机转换。这不是伪代码——它是 Gate 在管道中实际运行逻辑的精简版。
# Pipeline integration: VERIFIED gate as state machine trigger
import json
from pathlib import Path
def run_verified_gate(task_id: str) -> str:
"""
执行 VERIFIED Gate 的全部验证维度,并触发状态机转换。
这个函数由 Codex(Engineering QA & Gate Worker)调用。
它从 repo 根目录运行,确保审计轨迹留在 GitHub。
Args:
task_id: 任务标识符(如 "2026-06-07-agent-verified-gate-design")
Returns:
str: 状态机的新状态——"VERIFIED", "VERIFIED_WITH_WARNING", 或 "VERIFICATION_FAILED"
"""
task_dir = Path(f".agent-workspace/tasks/{task_id}")
# ======== Step 1: 检查部署完整性(commit 一致性) ========
deploy_report_path = task_dir / "deploy-report.json"
if not deploy_report_path.exists():
return "VERIFICATION_FAILED"
with open(deploy_report_path) as f:
deploy_report = json.load(f)
# 加载当前状态
with open(task_dir / "status.json") as f:
status = json.load(f)
# 检查部署完整性:是否所有关键字段都存在且一致
deploy_ok = all([
deploy_report.get("result") == "PASS",
deploy_report.get("dry_run") is False,
deploy_report.get("deploy_script_result") == "success",
# 标准部署路径:commit 必须一致
deploy_report.get("merge_commit") == deploy_report.get("vps_commit")
if "emergency" not in deploy_report.get("deploy_method", "")
else True # 紧急部署跳过 commit 一致性
])
if not deploy_ok:
return "VERIFICATION_FAILED"
# ======== Step 2: 验证所有在线维度 ========
slug = status.get("slug")
zh_url = f"https://xslyl.com/zh/posts/{slug}.html"
en_url = f"https://xslyl.com/en/posts/{slug}.html"
# 调用各验证模块(每个模块独立运行,互不干扰)
results = {}
# 2a: HTTP 与内容层
from verify_http_content import check_http_and_content
results["http_content_zh"] = check_http_and_content(zh_url, slug)
results["http_content_en"] = check_http_and_content(en_url, slug)
# 2b: SEO 元数据
# 注意:hreflang 验证需要同时获取 zh 和 en 两个页面的 HTML
import httpx
zh_html = httpx.get(zh_url, timeout=10).text
en_html = httpx.get(en_url, timeout=10).text
from verify_seo import check_canonical, check_hreflang_bidirectional, check_jsonld_blocks
results["canonical_zh"] = check_canonical(zh_html, zh_url)
results["canonical_en"] = check_canonical(en_html, en_url)
results["hreflang"] = check_hreflang_bidirectional(
zh_html, "zh", "en", zh_url, en_url, en_url
)
results["jsonld_zh"] = check_jsonld_blocks(zh_html)
results["jsonld_en"] = check_jsonld_blocks(en_html)
# 2c: 安全头
from verify_security_headers import check_security_headers
results["security_zh"] = check_security_headers(zh_url)
results["security_en"] = check_security_headers(en_url)
# 2d: Sitemap 与首页
from verify_sitemap_and_homepage import check_sitemap_and_homepage
results["sitemap_home"] = check_sitemap_and_homepage(slug)
# 汇总所有维度的 PASS/FAIL
all_checks = {}
for module_result in results.values():
all_checks.update(module_result)
online_ok = all(all_checks.values())
if not online_ok:
# 区分 Tier 1 失败(阻断)和 Tier 2 失败(可降级)
# 简化版:检查关键维度
tier1_passed = all([
results.get("http_content_zh", {}).get("status_200", False),
results.get("http_content_en", {}).get("status_200", False),
results.get("canonical_zh", False),
results.get("canonical_en", False),
results.get("hreflang", {}).get("bidirectional", False),
])
if not tier1_passed:
return "VERIFICATION_FAILED"
# Tier 1 通过但其他维度有失败 → 进入降级判断
# ======== Step 3: 元检查 ========
from check_policy_ack import verify_policy_ack
from check_verified_gate import check_gate_script_versions
policy_ok = verify_policy_ack(task_id)
versions_ok = check_gate_script_versions("scripts/agent")["consistent"]
meta_ok = policy_ok and versions_ok
if not meta_ok:
# 元检查失败——验证系统本身不可信
return "VERIFICATION_FAILED"
# ======== Step 4: 写入 verify-report.json ========
# 统一整合所有验证结果、Tier 分类和元检查结果
verify_report = {
"status": "VERIFIED" if online_ok else "VERIFIED_WITH_WARNING",
"overall": "PASS" if online_ok else "PASS_WITH_WARNINGS",
"checks": all_checks,
"tier1_failures": [
k for k, v in all_checks.items()
if not v and k in {"status_200", "canonical_zh", "canonical_en", "bidirectional"}
],
"tier2_failures": [
k for k, v in all_checks.items()
if not v and k not in {"status_200", "canonical_zh", "canonical_en", "bidirectional"}
],
"meta": {
"policy_ack": policy_ok,
"gate_versions_consistent": versions_ok
},
"deploy_method": deploy_report.get("deploy_method", "standard")
}
with open(task_dir / "verify-report.json", "w") as f:
json.dump(verify_report, f, indent=2, ensure_ascii=False)
# ======== Step 5: 更新 final-report.md 中的在线 URL ========
# 最终报告需要包含已部署页面的真实 URL,供用户直接访问验证
final_report_path = task_dir / "final-report.md"
if final_report_path.exists():
final_report = final_report_path.read_text()
# 在报告末尾追加在线 URL 部分
urls_section = f"""
## 在线 URL
- 中文版: [{zh_url}]({zh_url})
- 英文版: [{en_url}]({en_url})
- Sitemap: [https://xslyl.com/sitemap.xml](https://xslyl.com/sitemap.xml)
- 验证报告: 见 `verify-report.json`
**Gate 状态: {verify_report['status']}**
"""
if "## 在线 URL" not in final_report:
final_report_path.write_text(final_report + urls_section)
# ======== Step 6: 返回状态机目标状态 ========
return "VERIFIED" if online_ok else "VERIFIED_WITH_WARNING"
设计要点:
- Step 顺序不可变:部署完整性检查必须在在线维度验证之前——如果部署本身有问题(commit 不一致、dry_run 模式),在线验证没有意义。
- 各验证模块独立运行:一个维度的失败不会阻止其他维度的验证。即使 HTTP 检查失败,仍然尝试运行 SEO 验证——这样 verify-report.json 中会包含所有维度的完整快照,而非只有第一个失败维度。
- Tier 分类决定终态:Tier 1 失败 →
VERIFICATION_FAILED;Tier 2 失败但可降级 →VERIFIED_WITH_WARNING;全部通过 →VERIFIED。 - audit trail 完整:所有验证结果写入
verify-report.json(结构化数据)和final-report.md(人类可读),都存放在任务目录中,随 git commit 保留。 - 紧急部署的识别:通过
deploy_method字段识别部署路径类型,从而决定是否跳过 commit 一致性检查。
11.5 Gate 的执行触发方式
VERIFIED Gate 由 Codex 在部署完成后主动触发,而非被动等待。触发的方式有两种:
- 自动触发(标准流程):部署完成 → deploy-xslyl.sh 返回成功 → Codex 自动运行
check_verified_gate.py→ 产出 verify-report.json。这是最常见的路径,适用于标准部署。 - 手动触发(异常恢复):如果自动触发因任何原因未执行(Codex 超时、网络中断、脚本错误),用户或 Hermes-Agent 可以手动运行
python3 scripts/agent/check_verified_gate.py <task_id>来触发验证。手动触发的结果与自动触发完全等效。
Gate 脚本的运行位置:Gate 脚本始终从 repo 根目录运行。这是因为:
- Gate 脚本需要访问
.agent-workspace/tasks/<task_id>/目录来读取 deploy-report.json 和 status.json - Gate 脚本需要访问
scripts/agent/目录来加载各验证模块 - 从 repo 根目录运行确保所有路径引用一致,避免相对路径的歧义
时间窗口:VERIFIED Gate 应在部署完成后 60 秒内启动。原因:rsync 完成后文件可能尚未被 nginx 的 sendfile 或操作系统缓存刷新(虽然概率极低)。60 秒的缓冲确保文件系统操作完全完成。如果超过了 60 秒仍未启动 Gate(如 Codex 被其他任务阻塞),应在 verify-report.json 中记录延迟时间。
常见问题(FAQ)
Q1: VERIFIED Gate 和 smoke test 有什么区别?
两者有五个本质区别:
- 维度数量:smoke test 通常 1-3 个检查(端口监听、健康检查端点返回 200、基本页面渲染),VERIFIED Gate 覆盖 12+ 个维度(HTTP 状态、canonical、hreflang、JSON-LD、安全头、sitemap、首页卡片、commit 一致性、元检查等)。
- 检查深度:smoke test 检查「服务活着吗?」——VERIFIED Gate 检查「内容对吗?」。前者的回答是布尔值(活着/死了),后者的回答是多维度的结构化数据(哪些维度 PASS、哪些 FAIL、证据是什么)。
- 内容感知:smoke test 不解析 HTML——它只看状态码和响应时间。VERIFIED Gate 解析 canonical、hreflang、JSON-LD、FAQ 结构,进行语义级别的验证。
- 跨页面一致性:smoke test 检查单个端点。VERIFIED Gate 检查页面间的交叉引用——hreflang 的双向性要求同时验证 zh 和 en 两个页面并交叉比对;sitemap URL 需要与在线 URL 一致。
- 结果语义:smoke test 的结果是「可部署」(可以上线了),VERIFIED Gate 的结果是「已发布且正确」(上线后验证通过)。一个是前置条件检查,一个是后置结果验证。
简单类比:smoke test 是「房子建好了,灯能亮吗?」,VERIFIED Gate 是「房子建好了,每个房间的门牌号对吗?厨房在正确的位置吗?消防通道的指示灯亮吗?」
Q2: 如果一个维度失败但其他维度全部通过,Gate 应该给什么结论?部分通过还是全部失败?
结论取决于失败维度的 Tier 级别,而非失败维度的数量:
- Tier 1 维度(HTTP 200 / canonical / hreflang)中任意一个失败 → Gate 结论为 FAIL。即 11 个维度 PASS 但 1 个 Tier 1 维度 FAIL = Gate FAIL。原因:Tier 1 维度定义了「页面是否可以被搜索引擎正确索引」——如果 canonical 指向了错误的域名,即使其他所有维度完美,搜索引擎也不会正确索引该页面。发布等于没发布。
- Tier 2 维度(JSON-LD / 安全头)失败但 Tier 1 全部 PASS → Gate 结论可为 VERIFIED_WITH_WARNING。即页面基本可访问且可索引,但在搜索富文本展示或安全评级上有缺陷。Gate 通过但附带警告,警告记录在 verify-report.json 中并设定修复期限。
- Tier 3-4 维度失败 → Gate 结论为 VERIFIED(附注警告)。如 sitemap 中缺少 URL 或首页卡片未更新,不阻断发布,但在 verify-report.json 中记录,纳入下一轮部署的修复清单。
核心原则:Gate 不是简单多数投票(majority vote)。它采用「瓶颈判定」(bottleneck judgment)——最关键的维度(Tier 1)具有一票否决权。这个设计与航空安全中的「no-go item」概念一致——即使飞机的 99% 系统正常,只要一个关键系统(如发动机)故障,飞机就不能起飞。
Q3: 紧急部署(scp/rsync)在什么场景下会被允许?为什么结果只能是 VERIFIED_WITH_WARNING?
紧急部署的允许场景(需同时满足以下条件):
- 标准部署路径不可用:SSH 密钥问题导致 git pull 失败、GitHub 服务宕机、VPS 磁盘 I/O 故障导致 git 操作超时。
- 内容需要紧急上线:安全漏洞修复、关键信息更正(如错误的联系方式或价格)、时效性内容(如活动公告)。
- 用户显式批准:Agent 必须向用户报告标准部署失败的原因,提出紧急部署方案(具体方法、传输文件列表、风险说明),并等待用户明确说「同意」或「执行」。沉默不等于批准。
为什么结果只能是 VERIFIED_WITH_WARNING:
- Git 仓库不一致:紧急部署绕过 git pull,VPS 上的仓库 HEAD 与 GitHub 上的 merge_commit 从此不同步。下次标准部署(git pull)时可能产生合并冲突,或覆盖掉紧急部署的手动修改。
- Sitemap/首页可能不同步:紧急部署通常只传输单个文章文件,不触发 sitemap 重新生成和首页更新。这意味着新文章在线但搜索引擎发现延迟,且首页没有入口。
- 审计轨迹不完整:标准部署通过 deploy-xslyl.sh 执行,该脚本会记录完整的部署日志(deploy-report.json)。紧急部署是手动操作,可能缺少部分审计信息。
WARNING 标记的含义:VERIFIED_WITH_WARNING 不是一个失败状态——它表示「内容已正确部署且在线可见,但部署过程未遵循标准路径,存在已知的技术债务需要在条件恢复后清偿」。具体地,运维人员需要在标准部署恢复后执行一次「同步部署」(git pull + deploy-full)来恢复仓库一致性。
Q4: 如果我的网站不在 xslyl.com,VERIFIED Gate 的检查项需要调整吗?
VERIFIED Gate 的核心框架(12 维度验证矩阵)是通用设计——不依赖 xslyl.com 的特定配置。但以下参数需要根据你的站点进行调整:
| 参数 | xslyl.com 的值 | 你的站点需要修改为 |
|---|---|---|
| 域名 | xslyl.com |
你的生产域名(用于 canonical、hreflang、sitemap URL 比对) |
| 语言维度 | zh + en(双语) | 单语言站点可移除 hreflang 检查;三语言以上需扩展交叉比对逻辑 |
| JSON-LD 类型 | Article + BreadcrumbList + FAQPage | 如果站点不使用 FAQ,移除 FAQPage 检查并调整 Tier 分类 |
| 安全头期望值 | 5 个特定安全头(HSTS 等) | 根据你的安全策略调整;如果使用 CSP,添加 Content-Security-Policy 检查 |
| 页面大小阈值 | 1KB(静态文章站) | SPA 站点可降到 256 字节;多媒体重型站点可提高到 10KB |
| Web 服务器类型 | nginx | 如果使用 Apache/Caddy,调整错误页签名检测和部署完整性检查(git pull 逻辑) |
架构不变的部分:12 个维度的验证思路(HTTP → SEO → 安全 → 发现性 → 完整性 → 元检查)、Tier 分级体系、fix forward vs rollback 决策框架——这些都是跨站点通用的设计模式,不依赖具体技术栈。
Q5: 每个检查项的执行频率是多少?每次部署都跑所有维度吗?
是的——每次部署都必须运行 VERIFIED Gate 的全部 12 个维度。
原因:
- 部署是「整体快照」的更新:即使你只修改了一篇文章的一个段落,部署过程(git pull + rsync)会刷新服务器上的整个仓库内容。任何部署步骤的失败都可能影响站点的任意部分——不是你修改的那篇文章,而是看似不相关的角落(如 sitemap 生成失败、nginx 配置被意外覆盖)。每次部署都需要全维度验证。
- 维度之间的依赖关系:一个维度的 PASS 不能推断其他维度的 PASS。HTTP 200 不保证 canonical 正确;canonical 正确不保证 hreflang 双向;hreflang 正确不保证安全头存在。每个维度验证的是部署的不同方面,必须独立验证。
- 「增量验证」的误区:理论上可以只验证「可能受影响」的维度——如只修改了文章内容就只验证 HTTP 和内容层,跳过高风险的 SEO 和安全头验证。但这个优化在 Agent 发布场景中是危险的——Agent 在生成内容时可能无意中破坏了 JSON-LD 块、修改了 canonical 标签、或影响了 hreflang 结构。你不知道 Agent 的修改的「爆炸半径」,所以唯一安全的选择是全维度验证。
性能考量:12 个维度的全量验证大约需要 10-15 秒(主要是 HTTP 请求的往返时间——sitemap、zh/en 页面、两个首页共约 5-6 个 HTTP 请求)。对于每次部署,这 15 秒的延迟是值得的——它用 15 秒换来了「内容正确」的确定性。相比之下,发现一个 SEO 错误并修复所需的编排时间通常以小时计。
可选优化(不建议启用):如果你确实需要在性能与覆盖面之间做权衡,可以将 Tier 3-4 维度的验证降级为异步——即先通过 Tier 1-2 维度验证后允许发布,然后在后台异步运行 Tier 3-4 维度(sitemap、首页卡片),如果发现问题则追加警告。但这个优化的收益很小(节省约 2-3 秒),却增加了流程的复杂性和状态管理的难度。对于 xslyl.com 的规模,不推荐做这个优化。
Q6: 如果 GitHub 和 VPS 的 commit SHA 不一致但线上内容是正确的,问题出在哪?
这是一个典型的「正确的假象」场景。虽然线上内容恰好正确,但 commit SHA 不一致意味着部署管道中存在一个尚未暴露的故障点。常见的根因:
- git pull 部分失败:网络中断导致 git pull 只拉取了部分对象。恰好拉取到的部分包含了当前文章的正确文件,但 HEAD 停留在旧 commit。下次部署时,这个故障点可能表现为「部署了旧版本」。
- VPS 上的 git 仓库处于 detached HEAD 状态:某次手动操作(如
git checkout <commit>)使仓库进入了 detached HEAD,但 deploy 脚本仍然从这个状态部署了文件。内容碰巧是正确的(因为 deploy 脚本使用了工作目录中的正确文件),但仓库状态与 GitHub 不同步。 - deploy 脚本从错误的源目录部署:deploy-xslyl.sh 可能从另一个源目录(如
/tmp/staging/)而非 git 工作目录(/var/repo/)复制文件。这个源目录的内容碰巧是正确的(因为之前的某次手动 scp),但 git 仓库本身是旧的。 - 使用了紧急部署但未正确记录:之前的部署使用了 scp 紧急传输文件,绕过了 git pull。内容是正确的,但 deploy_method 标记错误(仍然写着 "standard" 而非 "emergency_scp"),导致 Gate 期望 commit 一致但实际上不一致。
为什么这仍然是一个问题——即使线上内容正确:
- 下一次部署的风险:如果故障点未被修复,下一次标准部署(git pull)可能在尝试同步时产生合并冲突,或覆盖掉手动部署的修复。
- 回滚能力丧失:如果无法确定 VPS 上当前运行的是哪个版本,就无法执行精确回滚——你不知道回退到哪个 commit 才是安全的。
- 审计链断裂:当线上出现问题时,你无法通过
git log追溯变更历史——因为线上的版本不在任何 commit 中。
修复方法:不要满足于「线上内容恰好正确」。必须:
- 定位 commit 不一致的根因(git pull 日志、deploy 脚本输出、手动操作历史)。
- 修复根因(重新配置 git remote、修复 deploy 脚本的源目录引用、清理 detached HEAD 状态)。
- 执行一次强制同步:
git fetch origin && git reset --hard origin/main(在 VPS 上),然后重新部署。这会丢弃 VPS 上的本地差异,与 GitHub 完全对齐。 - 重新运行 VERIFIED Gate 确认 commit 一致性恢复。
下一步阅读
VERIFIED Gate 是 Agent 发布管道 8 层 Gate 体系的最后一层。以下是理解整个体系所需的关联文章: