client-only SPA 部署到 Cloudflare Workers 後,六個完全不同的網址抓下來的 body 內容 md5 一模一樣。問題在哪、怎麼量、怎麼修。
發布於 2026-08-31・約 5 分鐘
網站上線幾個月,sitemap 裡宣告了 100 個網址,Google Search Console 卻幾乎沒有頁面被收錄。我一開始的假設全都是錯的方向:以為是網站太新、以為是外部連結不夠、以為是內容不夠好。
真正的原因用一條指令就查出來了。
關鍵是用不執行 JavaScript 的方式去抓自己的頁面。瀏覽器會騙你,curl 不會。
curl
for url in / /calc/ /holiday/ /blog /blog/mbti-16-types /about; do printf '%s ' "$url" curl -s "https://example.com$url" \ | sed -e 's/<[^>]*>//g' -e 's/[[:space:]]\+/ /g' \ | md5sum done
把每個網址的 HTML 剝掉標籤、壓掉空白之後算 md5。如果這幾個 hash 全都一樣,你就中了。
我當時六個網址跑出來是同一個 hash。不是相似,是位元層級完全相同。
client-only 的 SPA,不管你請求哪個網址,伺服器回的都是同一份 index.html:
index.html
<body> <div id="root"></div> <script type="module" src="/assets/index-a1b2c3.js"></script> </body>
內容要等 JS 下載、執行、React 掛載、路由判斷完才會長出來。
會執行 JS 的爬蟲(Googlebot 的渲染階段)最終看得到,但那是排在渲染佇列裡的第二趟,可能等幾天也可能等幾週。而不執行 JS 的那一大票——各家 AI 搜尋的抓取器、社群平台的預覽卡爬蟲、廣告聯播網的內容審查——看到的永遠是那個空的 <div id="root">。
<div id="root">
站在它們的角度:你宣告了 100 個網址,內容一模一樣。這是「重複內容」與「低價值內容」判定的教科書級觸發條件。
因為站已經在 Cloudflare Workers 上,最省事的做法是讓 Worker 在回傳 HTML 之前改寫它。Cloudflare 的 HTMLRewriter 是串流式的,不需要把整份 HTML 讀進記憶體。
HTMLRewriter
class HtmlSetter { constructor(html) { this.html = html } element(el) { el.setInnerContent(this.html, { html: true }) } } export default { async fetch(request, env) { const url = new URL(request.url) const res = await env.ASSETS.fetch(request) // 非 HTML(圖片、JS、CSS)原樣放行,不要白白多繞一層 const ct = res.headers.get('content-type') || '' if (!ct.includes('text/html')) return res const html = PAGE_HTML[url.pathname] // build 時預先算好的內容表 if (!html) return res return new HTMLRewriter() .on('noscript#seo-fallback', new HtmlSetter(html)) .transform(res) }, }
PAGE_HTML 是一份「網址 → HTML 片段」的對照表,在 build 階段就把每頁的標題、段落、內部連結組好,直接嵌進 Worker 的程式碼裡。執行期不查資料庫、不打 API,純粹是物件查表,延遲基本上是零。
PAGE_HTML
<noscript>
#root
這一步我一開始想錯了,繞了一圈才回來。
直覺會想把內容直接放進 <div id="root">,反正 React 掛載時會蓋掉。問題是**「蓋掉」那一瞬間會被使用者看到**:伺服器給的靜態內容先畫出來,JS 跑完 React 清空節點重新渲染,中間那段空窗就是可見的內容閃爍加上版面位移。CLS 是 Core Web Vitals 的三項之一,而行動版的 Core Web Vitals 直接影響排名——為了修 SEO 去弄壞另一項 SEO 指標,划不來。
放在 <noscript> 裡兩邊都成立:
既然 Worker 已經逐頁攔截了,有兩件事的邊際成本趨近於零,一起做完:
每頁自己的 OG meta。 分享到通訊軟體時的預覽卡,靠的是 og:title / og:description / og:image。SPA 的靜態 HTML 裡只有一組,所以不管分享哪一頁,預覽卡都長一樣。同一個 HTMLRewriter 鏈上多掛幾個 handler 就解決了。
og:title
og:description
og:image
逐頁的 JSON-LD。 原本殼裡固定是首頁的 WebSite 節點,等於每一頁都自稱「我是這個網站的首頁」。依頁面型別改寫成 WebApplication / BlogPosting / Article,並用 @graph 配 @id 把 Organization 和 WebSite 串成同一個實體圖,而不是每頁各自孤立一份。
WebSite
WebApplication
BlogPosting
Article
@graph
@id
Organization
真正花時間的不是修,是發現。這個問題在瀏覽器裡完全看不出來——打開網頁一切正常,Lighthouse 分數漂亮,開發者工具裡 DOM 結構完整。它只在「關掉 JS 的那個視角」才存在,而那個視角剛好就是決定你有沒有流量的那些機器人在用的。
所以現在只要是 SPA,我上線後第一件事就是跑那個 md5 迴圈。三十秒,比事後從 Search Console 反推便宜太多。
標籤:SEO、Cloudflare Workers、SPA、React