工程日记・第二十五天:8 个 PR、1 次生产事故,以及 Google 没告诉我们的 URL 片段弃用
第 24 天结尾时我对第 25 天做了三个预测。第 25 天最终一个都没发生。这一周其实是 agent 平台的成年礼——14 工具 Claude 智能助手在所有公开面铺开、218 架飞机 / 11 种语言 / 13 个 MCP 工具的事实在所有 .well-known/* 发现文件中对齐,robots.txt 显式放行 14 个新 AI 爬虫 User-Agent。本周中段 CEO 发来一张 Google Search Console 截图:6 个视频里有 5 个未索引。12 分钟的生产 curl 后,原因明确:Google 在 2023 年弃用了视频 sitemap 的 URL fragment,而我们的 8 个自托管短片都指向 /video-centre#anchor,被 Google 折叠成同一个页面,只能索引 1 个。修复用了 2 小时:独立的 /video-centre/[slug] 落地页、sitemap 重写、IndexNow 新增类别。修复的 hotfix 又花了 90 分钟——一个 DYNAMIC_SERVER_USAGE 崩溃,只有在嵌套 next-intl 布局下 generateStaticParams 省略 locale 段时才出现。8 个 PR 合并、287/287 测试始终全绿、一次 90 分钟的事故、零数据丢失。
五天 8 个 PR — 一周流水账
第 24 天结尾时我对第 25 天做了三个预测。第 25 天最终一个都没发生。下面是真实发生的,按合并顺序:
- PR #20(2026-05-18)— i18n Waves 9.1 至 9.15。约 25,500 个页面渲染组合上完成 11 语言 chrome 覆盖。这是一次 26 commit overnight 推进的最后一波,关掉了 CEO 此前提到的"日语访客看到英文标题"问题。
- PR #21(2026-05-18)— 2 个配对视频落地页(LA WC2026 + Top 5 jets)+
/for-agents全页重写。Agent 平台的成年礼:scale stats strip(13 MCP / 14 Concierge / 16 CLI / 30 REST / 26K+ URLs)、Six Ways to Connect 卡片网格、Live Data Layer 板块、Built for LLM Discovery 板块。 - PR #22(2026-05-19)— 全站陈旧文案清扫。PR #21 自审中发现每个公开 discovery 表面都还在说"10 tools"(智能助手数)——而实际数字已经是 14 几个星期了。Discovery API、OpenAPI、llms.txt、llms-full.txt、媒体资料包、首页 FAQ JSON-LD——18 处文案一次性修订。同时建立"agent stack truth"备忘:每当计数变化时,需要更新的 18 个位置。
- PR #23(2026-05-19)—
.well-known/*agent-discovery 文件同步。mcp.json工具数 10 → 13 + 新增 related_surfaces 块。ai-plugin.jsondescription_for_model 重写。agents.jsonprotocols 数组从 3 个扩到 8 个。robots.txt 显式放行 10 个新 AI 爬虫(Claude-Web、OAI-SearchBot、Applebot-Extended、meta-externalagent、Bytespider、Diffbot、Amazonbot、DuckAssistBot、YouBot、GoogleOther)。再加 WC2026 跨链三角形闭合(多伦多 + 墨西哥城 + 洛杉矶)。 - PR #24(2026-05-19)— 合并后生产环境验证发现
/fleet/falcon-10x是 soft-404。Top 5 jets 博客(PR #21)链了它,但 Falcon 10X 因为 EIS 从 2025 推迟到 2027 一直没添加到aircraft-catalog.ts。30 分钟内补上目录条目,链接修复。 - PR #25(2026-05-21)— 2 个新的配对社交发布:WC2026 主办城市主指南 + 2025 公务航空国家排行 TOP 5。各配自己的 9:16 短片。麻烦从这里开始,但当时没人知道。
- PR #26(2026-05-21)— 诊断驱动 SEO 扫描。5 条工作:所有 NewsArticle + IndustryInsight schema 加 Speakable;每篇博客的 NewsArticle
image字段不再共用同一张 Pexels 图(之前 95 篇博客都指向同一张照片!);NewsArticle 加 wordCount;RSS feed 加视频 enclosure;Organization JSON-LD 地址修复(New York → Singapore)。 - PR #27(2026-05-21)— 独立视频落地页。CEO 提供 GSC 报告:6 个视频中 5 个未索引。根因分析见下。引入了 bug。
- PR #28(2026-05-22)— PR #27 的 hotfix。从生产故障到修复落地用了 90 分钟。
视频索引 bug — 12 分钟生产 curl
CEO 下午发来 Google Search Console 截图。视频索引报告:1 个已索引,5 个未索引,1 reason。截图里看不到那个 reason 具体是什么。
第一步是在生产环境 curl 查看实际的视频 sitemap:
$ curl -s https://www.flyvolo.ai/video-sitemap.xml | grep '<loc>'
<loc>https://www.flyvolo.ai/video-centre#falcon-7x-private-jet-short</loc>
<loc>https://www.flyvolo.ai/video-centre#gulfstream-g550-cabin-short</loc>
<loc>https://www.flyvolo.ai/video-centre#world-cup-2026-canada-by-private-jet</loc>
<loc>https://www.flyvolo.ai/video-centre#world-cup-2026-usa-mexico-by-private-jet</loc>
...
就是它。每个视频条目都指向同一个基础 URL,仅用 URL fragment 区分。Google 2023 年弃用了这个规则:fragment 在视频 sitemap 的 URL 匹配中被剥离,所以 8 个条目全部坍塌到 /video-centre,只有 1 个 VideoObject schema 能和这个 URL 配对。GSC 报的错误是"Video is not the main content of the page"——正因为 Google 看到一个 URL 下 8 个视频在抢主内容位,结论是没有一个是主内容。
从 CEO 截图到根因确认 12 分钟。修复方向明确:每个自托管短片需要自己的独立 URL。2 个半小时的工作量来搭动态路由、生成不带 URL fragment 的 VideoObject JSON-LD、字幕显示、相关视频区、配对博客交叉链接。/video-sitemap.xml 重写让 <loc> 指向 /video-centre/[slug]。IndexNow 加了"videos"分类把 8 个新 URL 推到 Bing/Yandex。
那个 hotfix — 90 分钟
PR #27 合并、Vercel 自动部署、生产环境 sitemap 显示新 URL。常规 curl 检查:
$ curl -s -o /dev/null -w "HTTP %{http_code}
" \
https://www.flyvolo.ai/video-centre/top-5-longest-range-business-jets
HTTP 500
每一个独立视频 URL 都返回 500。本地构建成功、Next build 把路由预渲染为 SSG(输出里的 ●)、测试通过。所以一定是运行时的问题。
本地用 pnpm exec next build && next start 复现:
⨯ [Error: An error occurred in the Server Components render]
digest: 'DYNAMIC_SERVER_USAGE'
DYNAMIC_SERVER_USAGE 是 Next.js 的报错——本应静态渲染的页面里使用了请求作用域的 API(cookies、headers、动态 params)。我的页面没引用这些。那为什么报?
定位真正问题花了 15 分钟。我的 generateStaticParams 返回了 [{slug: 'x'}, {slug: 'y'}, ...]——只有 slug,没有 locale。路由是 /[locale]/video-centre/[slug]。Next.js 给每个 slug 只预渲染了一个 locale 变体(默认是 en),其他 locale 都当动态参数处理。
在请求时刻,[locale] 布局调用 next-intl/server 的 getMessages()——这是请求作用域 API,在"静态"页面里使用就触发 DYNAMIC_SERVER_USAGE → 500。
修复就一行代码,算上注释 4 行:
export function generateStaticParams() {
const shorts = getAllVideos().filter((v) => v.format === "short" && Boolean(v.videoUrl));
return shorts.flatMap((v) => locales.map((locale) => ({ locale, slug: v.slug })));
}
export const dynamicParams = false;
和 /blog/[slug] 用的模式一模一样。新建路由时我们就是没复制对。代价:5 分钟生产 500 影响 5 个 URL(多数还没产生真实流量)。教训:在 [locale] 下建新动态路由时,先把已经在跑的兄弟路由 generateStaticParams 形状复制过来。
Singapore 地址 bug
这周最小但最有后果的 bug 是偶然发现的。第 25 天 SEO 扫描时我 grep 了整个代码库的 PostalAddress,发现 OrganizationJsonLd 里写着:
address: {
"@type": "PostalAddress",
addressLocality: "New York",
addressRegion: "NY",
addressCountry: "US",
},
Organization schema 由根 layout 渲染——每个页面都有它。所有公开面(媒体资料包、页脚、CLAUDE.md、JETBAY 母公司页面)都说新加坡。这个 JSON-LD 静悄悄地在每个页面上说纽约。
这正是污染 LLM 训练数据的那种不一致。当 Claude 或 GPT 爬取我们的内容做训练时,看到两种不同的总部声明。模型对两者都失去置信。我们成为自己事实上一个不太权威的来源。
已修复。顺手扩展了同一 schema:alternateName: ["VOLO", "flyvolo.ai", "VOLO AI"]、sameAs 加 Twitter/X、availableLanguage 从 4 扩到 11(匹配真实覆盖)、加第二个 ContactPoint 给 sales(charter@flyvolo.ai)、areaServed 显式设为 Worldwide。
还没修的 i18n 覆盖缺口
这周 CEO 问了一个出色的诊断问题:"我们有 11 个语言——但很多页面上日本访客看到的还是英文。差距多大?"
答案比我第一次审计时的版本更微妙。更正声明:我最初的审计(仍保留在工程 Slack 串里,本处为了诚实保留事件)因为读错了字段名,把目的地和航线的缺口估计得太大。用真实的 MultilingualText 字段结构做第二轮审计后:
| 内容类型 | 总数 | EN + ZH 覆盖 | 其他 8 语言(ja/ko/fr/es/de/pt/ru/ar) | zh-Hant |
|---|---|---|---|---|
| 博客 | 98 | 98 / 98(PR #33 之后) | 0 / 98 | 0 / 98 |
| 目的地 | 100 | 100 / 100 ✓ | 0 / 100 | 0 / 100 |
| 航线 | 224 | 224 / 224 ✓ | 224 / 224 ✓ | 0 / 224 |
| 行业洞察 | 103 | UI ✓ + 正文 | UI ✓;正文约 90% 英文 | UI ✓;正文英文 |
| 月度报告 | 8 | 通过 monthly-report-i18n 全 11 语言 | ✓ | ✓ |
修正后的画面比第一次审计建议的要好得多。剩下的真实缺口:
- 博客:en + zh 完整,但 98 篇中 0 篇有任何其他 9 种语言(ja、ko、fr、es、de、pt、ru、ar、zh-Hant)。
- 目的地:en + zh 完整,但 100 个中 0 个有任何其他 9 种语言。
- 航线:en + zh + 8 种其他语言完整(224 条航线 × 9 种语言已在前期某次我已经忘了的 pass 中就位)。仅缺 zh-Hant,覆盖全部 224 条——而 zh-Hant 可通过 OpenCC 简体到繁体转换从 zh 机械导出,不需要真翻译。
所以一位法国访客在 /fr/destinations/cannes:UI 法语 ✓、城市名 "Cannes" ✓、描述仍是 100% 英文——但同一位法国访客在 /fr/routes/new-york-aspen,会看到法语的航线描述。航线在我已经记不清的某次 pass 中已经翻译到了 8 种语言。
这对工作计划的改变:目的地 × 9 语言的工作量是真的(~100 × 9 = 900 个翻译单位,约 15 美元,用 Sonnet 4.6 + 缓存)。航线只需要琐碎的 zh-Hant pass(确定性的,不需要 API 调用——OpenCC s2t 转换器离线就能搞定)。博客 × 9 语言是剩下最大的一块。教训:审计一个复杂的数据形状时,永远要去读真实文件而不是相信正则审计。我在第 26 天用正确字段名重跑审计后发了这个更正。
翻译脚本(scripts/translate-blog-content-zh.ts)已就位;运行 Phase 2 扩展已排队。
第 24 天预测哪里说错了
第 24 天对第 25 天预测了三件事:
- 给每个
generateStaticParams路由必须在sitemap.ts中注册加 CI 断言。未发布。但能抓住另一个最近的缺口(Industry Insights)。方向仍然对,只是比这周实际冒出来的事低优先级。 - 给双 slug 系统加 branded type(
OperatorMasterSlugvsOperatorEntitySlug)。未发布。休眠中;应该和下一次 operator/insight schema 变更一起做。 - 把 IndexNow + sitemap 注册做成一个 TypeScript 步骤。未发布。这周给 IndexNow 加 videos 分类时,仍然是三个独立 commit(路由文件、sitemap、indexnow)。摩擦真实但有界。
没发布并不是因为它们错——是因为它们都不是最紧急的事。真正最紧急的是:agent 平台刷新(PR #21 → #23)、GSC 视频索引 bug(PR #27 → #28)、潜伏已久的 Singapore 地址 bug。这些是对的优先级。三个第 24 天目标顺延到第 26 或 27 天。
测试、lint、build — 8 个 PR 全绿
贯穿始终:287/287 测试通过、0 lint 错误、80 个 warning baseline 不变、npx turbo build --filter=web 每个 PR 上都 4/4 成功。唯一一次失败是 PR #27 之后生产 500 在每个独立视频 URL 上——CI 没抓到,因为这个问题在请求时刻于 Vercel serverless 运行时出现,而不是本地 Node 的构建时。那是另一类测试缺口。第 26 天任务。
8 个 PR 合并、1 次事故、90 分钟 hotfix、零数据丢失。Agent 平台现在在所有公开 discovery 表面上以一致计数可见(218 架飞机 / 13 MCP 工具 / 14 Concierge 工具 / 16 CLI 命令 / 30 REST 端点 / 26K+ 索引 URL 候选)。视频中心正常索引。Speakable schema 在每篇文章上。新加坡地址在站点每一处 JSON-LD 上一致。i18n 覆盖缺口已归档并排队。
5 天、8 次发布、灭了一场火。接下来是第 26 天。
常见问题
What does Google's URL fragment deprecation for video sitemaps actually mean?+
Pre-2023 you could put multiple <video> entries in a sitemap, each scoped to /page#anchor1, /page#anchor2, etc. Google would treat each fragment as a unique landing surface. In 2023 they changed this: the <loc> URL is normalized by stripping the fragment for matching purposes. So /video-centre#video-a and /video-centre#video-b both reduce to /video-centre, and only one VideoObject schema can be paired with that URL. The reported error in Google Search Console is 'Video is not the main content of the page'. The fix is to give each video its own URL — in our case, /video-centre/[slug] as a dynamic route prerendered via generateStaticParams. There is no equivalent constraint for the regular (non-video) sitemap.
Why did /video-centre/[slug] crash with DYNAMIC_SERVER_USAGE despite being SSG?+
Next.js 16 with nested next-intl. Our route is /[locale]/video-centre/[slug]/page.tsx. The page declared generateStaticParams returning [{slug: 'x'}, ...] — just the slug, not the locale. At build time Next.js prerendered the /en/video-centre/x variant (because en is the default locale in next-intl's as-needed mode) but the locale param was treated as dynamic at request time for other locales. The nested ServerLocaleProvider layout then tried to read request-scoped state during render of a 'static' page → DYNAMIC_SERVER_USAGE. The fix is to enumerate { locale, slug } pairs in generateStaticParams (locales.flatMap(...) over the slug list) and set 'export const dynamicParams = false'. This is the same pattern /blog/[slug] uses; we just copied it wrong on the new route.
What was the Speakable schema, and why does it matter for an aviation site?+
Speakable is a schema.org annotation that tells voice assistants — Google Assistant, Alexa, and the next generation of LLM-driven voice search — which parts of a page are suitable for text-to-speech rendition. You add a 'speakable' field to a NewsArticle (or Article) JSON-LD pointing at CSS selectors. We added .speakable-headline (the H1) and .speakable-summary (the TL;DR paragraph) selectors and tagged the corresponding DOM elements on every blog post (95), monthly insight report (8), and industry insight page (103). For aviation specifically: 'Hey Siri, longest-range business jet?' becomes a query our pages are explicitly marked-up to answer. Zero implementation cost, opens an entire new surface.
Why was the Organization JSON-LD claiming New York when the press kit says Singapore?+
Legacy. The original Organization schema was written when the founding team was operating out of New York pre-incorporation. The Singapore legal entity stood up some months later, and every customer-facing surface was updated (footer, press kit, CLAUDE.md) — but the JSON-LD wasn't. We found it during a sweep grepping every PostalAddress in the codebase. This is the exact kind of inconsistency that poisons LLM training data: Google + Claude + GPT crawlers see two different HQ locations and lose confidence in both. Fixed in the same pass that added Twitter/X to sameAs, expanded availableLanguage from 4 to 11, added a 'sales' contactPoint pointing at charter@flyvolo.ai, and added alternateName: ['VOLO', 'flyvolo.ai', 'VOLO AI'] to help brand-mention match.
What were the three predictions from Day 24, and how did they pan out?+
Day 24 closed with three engineering targets: (1) Add a CI assertion that every generateStaticParams route appears in sitemap.ts. Status: not shipped. (2) Type the dual slug system with branded types — OperatorMasterSlug vs OperatorEntitySlug — so the compiler refuses to mix them. Status: not shipped. (3) Couple IndexNow + sitemap registration as one TypeScript step. Status: still three commits per route. Reflection: the week's real priorities were elsewhere — agent platform refresh, video indexing bug, i18n coverage audit. The three Day 24 targets are still right, just lower-priority than 'the CEO can see in GSC that we broke video indexing'. They roll over to Day 26.
What's the i18n coverage gap, and what's the plan to close it?+
We have 11-locale UI chrome — every label, button, FAQ heading, breadcrumb on ~25,500 page render combinations. But the content data itself is much thinner: 95 blog posts (43 have full ZH body, 54 only have ZH title+excerpt, 0 in other 9 locales), 91 destinations (only the name/country field is bilingual, descriptions are EN-only), 224 routes (entirely EN). A Japanese or French visitor lands on /ja/blog/world-cup-2026-los-angeles-...: UI renders in Japanese, title renders in Japanese, body is 100% English. The plan is two phases. Phase 1: build a Claude-powered translation pipeline (Sonnet 4.6 with prompt caching), backfill ZH for the 54 missing blog bodies + 91 destination descriptions. Phase 2: extend to ja/ko/fr/es/de/pt/ru/ar/zh-Hant. Documented and committed; not yet executed.
准备好飞行了吗?几秒钟获取个性化包机报价。
订阅资讯
空腿航班优惠、新航线与航空洞察,直达您的邮箱。