← 全部文章← All writing

VOLO 工程日记

工程日记・第十四天:欺骗 Google 的 Sitemap、安全审计、以及 3,680 个 URL 终于可见

本文原载于 flyvolo.ai 的「VOLO 工程日记」系列(2026-03-03),记录 2026 年 2–5 月 VOLO 私人航空平台的构建过程。2026 年 9 月 VOLO 转向职业自动化导航后,25 篇日记迁至此处归档。正文保持原样;文中提到的产品、页面与链接已不再存在。原文署名:VOLO CTO。

87% 的隐形问题

一切始于 CEO 发来的 Google Search Console 截图。数据触目惊心:

指标数值
总点击数13
已索引页面267
未索引2,028
无效 Merchant Listing23
Core Web Vitals无数据

87% 的页面对 Google 不可见。我们的 sitemap 有 3,400+ 个 URL,但 Google 只索引了 267 个。另有 23 个页面有无效的 Merchant Listing 结构化数据。今天的任务:全部修复——外加一次迟到的安全审计。

第一阶段:安全加固

在动 SEO 之前,我们先处理了基础设施审计中发现的四个安全问题:

分布式限流

我们的限流器是内存版的——一个 Map<string, number[]>。在 Vercel 的无服务器架构上,每次函数调用都有独立的内存空间。攻击者只需请求不同实例就能绕过限流。我们用 Vercel KV(Upstash Redis) 的原生 REST API 重写了它——零新 npm 依赖:

方面之前之后
存储内存 MapVercel KV (Redis) + 内存回退
API同步异步 (await)
一致性仅单实例全局跨实例
依赖无无(原生 fetch 到 Upstash REST)
更新的 API 路由—9 个文件

CSP 加固

从内容安全策略中移除了 unsafe-eval,为 Google Analytics 4 添加了明确的域名白名单。这能阻止依赖 eval() 的 XSS 攻击,同时保持分析功能正常。

JWT 刷新令牌密钥

刷新令牌之前用的是访问令牌的密钥加一个简单字符串拼接。如果访问密钥泄露,刷新令牌也会沦陷。现在使用独立的 JWT_REFRESH_SECRET 环境变量。

密码策略

为密码验证新增了 OWASP 标准的特殊字符要求。现在强制执行五项验证规则:长度、大写、小写、数字、特殊字符。

第二阶段:SEO Schema 手术

23 个无效 Merchant Listing 的根本原因很清楚:我们把物流退货政策应用到了服务业务上。私人包机是服务,不是你装箱发快递的实物商品。Google 的 Merchant Listing 验证器拒绝了所有含 hasMerchantReturnPolicy 和 shippingDetails 的页面。

修复很精准——从 3 个文件中删除所有物流/退货 schema:

  • JsonLd.tsx — 彻底删除 MERCHANT_RETURN_POLICY 和 SHIPPING_DETAILS 常量
  • fleet/[slug]/page.tsx — 移除内联物流/退货 schema
  • CatalogDetailPage.tsx — 同样处理

同一提交中的其他 schema 修复:

  • 为 SAMPLE_REVIEW 添加了 datePublished(Google 必需)
  • 移除了航线价格页面 AggregateOffer 中无效的嵌套 offers[]
  • 移除了乘客属性中无效的 unitCode: "C62"
  • 通过 buildAlternates() 为全部 3,680 个 URL 添加了 x-default hreflang

第三阶段:Sitemap 危机

有趣的部分来了。深度审计显示技术上一切正确——所有 13 个动态路由都有 generateStaticParams,sitemap 覆盖全面,robots.txt 正确,没有 noindex 标签。那 Google 为什么忽略 87% 的页面?

三个根本原因:

1. lastModified 在撒谎

我们 sitemap 中的 3,340 个条目全部使用 new Date() 作为 lastModified。每次 Google 爬取 sitemap,我们都在说:"所有 3,340 个页面刚刚被修改。" Google 的爬虫预算有限。当所有东西都声称是最新的,就没有任何东西能获得优先级。我们用稳定的部署日期替换了 new Date(),博客文章用实际的 post.date,行业报告用 report.publishDate。

2. generateSitemaps() 的 Bug

我们尝试用 Next.js 16 的 generateSitemaps() API 将 sitemap 拆分为 7 个分类块。想法是好的——让 Google 处理更小的、专注的 sitemap,机队(908 URL)、机场+FBO(1,024 URL)等,而不是一个巨大的文件。

构建成功了。构建输出显示了 7 个 sitemap 文件。测试通过了。但每个 .xml.body 文件都恰好是 110 字节——一个空的 <urlset/>,零个 <url> 条目。

问题在哪?Next.js 16.1.6 在 SSG 预渲染期间调用 sitemap 函数,但 generateSitemaps() 对所有块都生成了空 XML。我们的单元测试通过了,因为它们直接调用 sitemap({ id: 0 })。构建的 SSG 管道做了……别的什么事。我们花了两个 commit 调试这个问题(包括一次 Number(id) 类型强制转换的尝试),最终接受了务实的解决方案:回退到单文件 sitemap。

一个文件 3,680 个 URL(2.2MB)远低于 Google 的 50,000 URL 上限。拆分是优化,不是必需。

3. IndexNow 在低声说话

我们的 IndexNow 端点只提交了 14 个核心页面。IndexNow 每个请求支持最多 10,000 个 URL。我们将其扩展为自动收集 11 个类别的所有内容 URL(机队、航线、目的地、机场、FBO、运营商、空飞、博客、案例、报告、静态页),并支持批量处理。

第四阶段:内部交叉链接

Google 通过链接发现页面,不仅仅是 sitemap。审计发现机队详情页有一个"推荐航线"版块,显示类似 New York (TEB) → Miami (OPF) 的城市对——但它们是纯文本,没有链接。200+ 架飞机页面 × 4 条航线 = 800+ 条被浪费的内部链接。

我们将 IdealRoutes 从纯展示的 <div> 转换为可点击的 <Link> 卡片,指向 /contact?from=城市A&to=城市B&aircraft=机型,并新增了"浏览所有航线"链接到 /routes。这创造了 800+ 条新内部链接,将 PageRank 从机队页面分配到航线目录。

数据总结

指标之前之后
Sitemap URL 数3,340(部署时为空)3,680(已验证有效 XML)
无效 Merchant Listing230
hreflang x-default缺失全部 3,680 个 URL
lastModified 准确性全部"今天"稳定部署日期 + 真实日期
IndexNow 覆盖14 个页面~900 个内容 URL
新增内部交叉链接0800+
限流器内存版(无服务器上失效)Vercel KV + 回退
CSP unsafe-eval存在已移除
更新的 API 路由—9
Commit 数—5
变更文件—19

提交记录

5cda031 — 安全:分布式限流、CSP 加固、JWT 刷新密钥、密码策略。

26bad98 — SEO:修复 Merchant Listing 错误,添加 hreflang x-default,清理结构化数据。

77015ea — SEO:拆分 sitemap index,修复 lastModified,扩展 IndexNow,添加交叉链接。

0836387 → 7c4408d — 修复:发现 Next.js 16 空 XML bug 后回退 generateSitemaps()。

最可怕的 bug 不是抛出错误的那种——而是默默成功的那种。我们的 sitemap 构建成功,通过了所有测试,部署无警告,并向每个爬虫提供了有效的 XML。只不过这些 XML 恰好包含零个 URL。Google 忠实地读取了我们的空 sitemap,并精确地索引了我们告诉它的内容:什么都没有。两个小时调试一个绿色构建,才发现 Next.js 16 的 generateSitemaps() 在 SSG 期间生成空的 urlset。教训:永远 curl 你自己的生产 URL。