工程日記・第二十五天: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.
準備好飛行了嗎?幾秒鐘獲取個性化包機報價。
訂閱資訊
空腿航班優惠、新航線與航空洞察,直達您的郵箱。