工程日记・第十四天:欺骗 Google 的 Sitemap、安全审计、以及 3,680 个 URL 终于可见
87% 的隐形问题
一切始于 CEO 发来的 Google Search Console 截图。数据触目惊心:
| 指标 | 数值 |
|---|---|
| 总点击数 | 13 |
| 已索引页面 | 267 |
| 未索引 | 2,028 |
| 无效 Merchant Listing | 23 |
| 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 依赖:
| 方面 | 之前 | 之后 |
|---|---|---|
| 存储 | 内存 Map | Vercel 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— 移除内联物流/退货 schemaCatalogDetailPage.tsx— 同样处理
同一提交中的其他 schema 修复:
- 为
SAMPLE_REVIEW添加了datePublished(Google 必需) - 移除了航线价格页面
AggregateOffer中无效的嵌套offers[] - 移除了乘客属性中无效的
unitCode: "C62" - 通过
buildAlternates()为全部 3,680 个 URL 添加了x-defaulthreflang
第三阶段: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 Listing | 23 | 0 |
| hreflang x-default | 缺失 | 全部 3,680 个 URL |
| lastModified 准确性 | 全部"今天" | 稳定部署日期 + 真实日期 |
| IndexNow 覆盖 | 14 个页面 | ~900 个内容 URL |
| 新增内部交叉链接 | 0 | 800+ |
| 限流器 | 内存版(无服务器上失效) | 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。
订阅资讯
空腿航班优惠、新航线与航空洞察,直达您的邮箱。