
用 Vercel Web Analytics 為熱門文章加上標記 — 在建置時解析,仍以靜態 HTML 出貨
這個 Astro 部落格如何標記最多人讀的文章:建置期間向 Vercel Web Analytics 查詢一次,合計三個語系,再烘進靜態 HTML。
本頁目錄
引言
這個部落格的首頁只依 publishedAt 排序,沒有別的。因此每一篇文章看起來都一樣重要,去年被讀者找到的文章沉在我星期二才發的東西下面,訪客也無從分辨。我想讓真正被讀的文章自己說出來。
但站上沒有任何形式的量測可以作為依據,也看不出該把量測放在哪裡。這是一個只有一條伺服器路由的靜態 Astro 建置。靜態網站完全可以為自己的文章排名,只要在它變成靜態網站之前先排完。astro build 執行期間會向 Vercel Web Analytics 查詢一次,結果被烘進 HTML,再由每週一次的定期重建避免它變舊。
本文涵蓋可行性確認、代價比程式碼還高的隱私權決定、建置期的機制本身,以及一個追蹤腳本擺放位置上的缺陷,這個儲存庫的所有自動檢查都直接放它過關。
首頁說不出口的事
資料層相關的部分只有四行。文章從內容集合取出,依可見性過濾,然後排序。
function byNewestFirst(a: BlogEntry, b: BlogEntry): number { return b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf() || a.id.localeCompare(b.id);}排名的輸入就只有這些,而作為預設,這是對的。**我並不想取代它。**部落格索引本來就該是倒時序。我想要的是疊在上面的第二個訊號:在少數值得指出來的文章上加一個小標記,排序維持不變。
站上其實已經有一個讀者訊號。幾個版本之前上線的「喜歡」按鈕,以文章 slug 為鍵,背後是 Upstash Redis。它是個好功能,但不是人氣訊號,因為幾乎沒有人會按「喜歡」。這麼稀疏的統計,分不出一篇被大量閱讀的文章和一篇乏人問津的文章。
靜態網站到底能不能算瀏覽次數
天真版本的問題是:這需要資料庫嗎?不需要,而理由很新:Vercel 在 2026 年 5 月把 Web Analytics API 公開了。由於本站本來就從 GitHub Actions 帶著 token 部署到 Vercel,這些數字可以在建置期間取得,而不是在請求時。
Claude 在動筆之前先確認了這件事,順序是對的。第一次呼叫失敗了。
{"error":{"code":"bad_request","message":"Invalid request: missing required property `since`."}}這是個有用的失敗。**因為缺少參數而得到 400,代表端點是連得上的,而拒絕的並不是方案本身。**被方案擋下才是真正可能讓這個構想胎死腹中的情況。Vercel 有把各方案的報表期間寫進文件,卻從未說明 API 本身在 Hobby 上能不能用。補上 since 與 until 之後:
curl --get "https://api.vercel.com/v1/query/web-analytics/visits/aggregate" \ -H "Authorization: Bearer $VERCEL_TOKEN" \ --data-urlencode "projectId=$VERCEL_PROJECT_ID" \ --data-urlencode "since=2026-07-19" --data-urlencode "until=2026-08-17" \ --data-urlencode "by=requestPath" --data-urlencode "limit=100"{"version":1,"query":{"groupBy":["requestPath"],"limit":100},"data":[]}**一個帶著空陣列的 200,就回答了可行性的問題。**Hobby 有 API 存取權,報表期間是 30 天,而 data 之所以是空的,只是因為追蹤腳本還沒部署。
啟用的對話框一開始就把同樣的限制講清楚,與其之後才發現,不如在這裡先讀過。

這四行裡有兩行左右了設計。30 天的歷史意味著這個標記只能描述近期的流量,而結果證明那本來就是更有用的問題。接收有上限則代表用完額度時停止收集,而不是繼續計費。
我推翻掉的那個反對意見
Claude 列出四個候選訊號,並推薦了我沒有選的那一個:從既有的「喜歡」數推導出標記。這個選項不會新收集任何東西,不需要腳本,也不必動到隱私權頁面。這是保守的建議,背後的理由也站得住腳。
隨之而來的反對意見很具體。本站的隱私權頁面已經發布、有日期、以三種語言寫成,開頭就說這個網站「沒有流量分析、沒有廣告、沒有追蹤腳本」,而且只有「一項功能」會向伺服器送資料。src/data/privacy.ts 用自己的標頭註解把這件事綁住:
/** * What this page says has to stay true. It describes exactly one server-side * behaviour, in `src/lib/likes.ts`, and if that file's storage or retention * changes then these paragraphs are wrong until they are changed too. */我還是選了 Vercel Web Analytics,理由有兩個。第一如前所述,「喜歡」太稀疏,無法拿來排名。第二,我真正想要的其實是後設資料的厚度:參照來源、大致的地理位置、瀏覽器與裝置。點擊數的統計只告訴我一個數字。流量分析則告訴我讀者從哪裡來,而那正是「哪些文章值得多寫一些」這個問題背後的東西。
**於是改寫隱私權頁面成了這個功能的一部分,而不是後續才處理的事。**三個已經發布的主張必須撤回,換成準確的敘述,而且要用英文、日文與繁體中文各寫一次。更重要的是順序上的規則:**隱私權的文案要和腳本在同一個 commit 出貨,絕不能晚於它。**帶著追蹤腳本的部署上線、頁面卻還寫著沒有追蹤,是唯一比不做這個功能還糟的結果。
改寫後的頁面寫明了 Vercel 自家文件所說會收集的內容,包含比「喜歡」更廣的那些部分:
| 收集的內容 | 保存方式 |
|---|---|
| 時間、URL、參照來源 | 以彙總值保存 |
| 精確到城市的位置資訊 | 以彙總值保存 |
| 瀏覽器、作業系統、裝置類型 | 以彙總值保存 |
| 由請求推導出的雜湊 | 24 小時後丟棄 |
沒有 Cookie,也不會在讀者的瀏覽器裡寫入任何東西。舊頁面的這個部分原封不動保留了下來。
標記在哪裡被決定
在請求時解析瀏覽次數,代表每一次頁面載入都需要一台伺服器,也就是為了顯示一個標記,而把這個靜態網站放到一條會算繪的路由後面。**在建置時解析,執行期什麼都不需要。**計數在 Astro 建置期間取得一次,標記成為輸出 HTML 的一部分,讀者下載的仍然是和以前一樣的靜態檔案。
這個儲存庫本來就有同樣的模式。astro.config.mjs 在載入設定時呼叫 buildLastmodMap(),並把結果注入 sitemap,理由完全相同:外部資料在建置期間解析,烘進產物裡。
建置期解析的代價是新鮮度。星期一寫出來的 HTML,說的仍是星期一為真的事。這件事是用既有工作流程上的定期執行觸發器處理的,而不是新開一個:
schedule: # Mondays 03:00 UTC — midday Monday in JST. - cron: '0 3 * * 1'定期執行會檢出預設分支,所以 github.ref 是 refs/heads/main,既有部署工作的關卡照樣通過。這件事比看起來重要:**它讓通往正式環境的路徑維持只有一條。**再加一個也會部署的工作流程,等於每次合併都有兩個互相競爭的正式部署,而那正是那個工作上方的註解所警告的事。
三個 URL,一篇文章
這裡的每一篇文章最多存在於三個 URL,一個語系一個。分析 API 會把它們當成三筆各自獨立的 requestPath 回傳,而**直接以列排名,會把同一篇文章的讀者拆成三份。**如此一來,單一語言的文章就會贏過一篇合計其實讀得更多的翻譯文章。
所以在排名之前,這些列會先折疊到 slug 上。這個正規化裡有兩個陷阱,而且兩個都會安靜地失敗:
export function slugFromRequestPath(requestPath: string): string | null { if (requestPath.includes('[') || requestPath.includes(']')) return null;
let path = requestPath.split('?')[0] ?? ''; if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
for (const locale of LOCALES) { const prefix = localePrefix(locale); if (prefix && path.startsWith(`${prefix}/`)) { path = path.slice(prefix.length); break; } }
return ARTICLE_PATH.exec(path)?.[1] ?? null;}第一個是結尾的斜線。Astro 輸出的 <loc> 帶著斜線,而瀏覽器兩種形式都會請求,因此**一個省略正規化的比較永遠不會命中,並且會永遠回報沒有任何熱門文章。**第二個是語系前綴,它是從 localePrefix 推導出來的,而不是直接寫死 /ja 與 /zh-tw,這樣第四種語言加進來時就不會在這裡被漏掉。
「喜歡」的端點在幾個月前就從相反的方向得出了同樣的結論,並且寫在它自己的標頭裡:對日文翻譯按下的喜歡,就是對這篇文章按下的喜歡。兩個功能各自獨立地走到「身分在 slug,不在 URL」,是這個答案正確的合理跡象。
缺席與矛盾是不同的失敗
在本機開發、預覽部署、fork,以及 CI 的檢查工作中都沒有 token。這些情況都不該為了一個標記而讓建置失敗,所以它們發出警告,並且不輸出任何標記。
**問題在於,解析的錯誤會產生一模一樣的結果。**如果 slugFromRequestPath 不再命中,標記的集合就會是空的,建置維持綠燈,而在有人仔細看之前,網站會一直回報沒有熱門文章。因此這兩種情況被刻意分開:
| 狀況 | 意義 | 行為 |
|---|---|---|
| 沒有 token 或專案 ID | 未設定 | 警告,不加標記 |
| 取得失敗,或非 2xx | 連不上 Vercel | 警告,不加標記 |
| 文章的列數為 0 | 真的還沒有流量 | 警告,不加標記 |
| 有文章的列,卻沒有一個對得上已知 slug | 解析壞掉了 | 拋出例外 |
**最後一列是刻意讓建置失敗的。**這個儲存庫在 src/i18n/article-locale.ts 裡本來就有同樣的模式:用兩種各自獨立的方式推導出目前的語系,兩者不一致就拋出例外,因為它所防的失敗是安靜的,放著不管就會在日文頁面上出現英文的圖表。
追蹤腳本截斷了每一頁的 head
Vercel 給 Astro 的程式碼片段把這個元件放在 <head> 裡,所以它就被放在那裡:
<head> <!-- ... --> <Analytics /> <slot name="head" /></head>pnpm check 回報 0 個錯誤、0 個警告、0 個提示。pnpm build 也成功。**CI 是綠的。**抓到它的是針對這份差異的程式碼審查,而確認它的是瀏覽器上的量測。
這個元件算繪出來的不是 <script> 標籤,而是一個叫 <vercel-analytics> 的自訂元素。依照 HTML 的解析規格,在 “in head” 插入模式下遇到未知元素時,解析器會關閉 head、切換到 “after head”,然後重新處理該元素。head 就在那個元素的位置結束,寫在它之後的每一個節點都會被放進 body。
**讀建置產物的檔案完全看不出這件事。**標記是良構的,</head> 位在第 6276 個字元,所有樣式表都在它前面。唯一有異議的是解析器:
// 在 Chromium 中載入 .vercel/output/static/index.htmldocument.head.querySelectorAll('style, link[rel=stylesheet]').length // 0document.body.querySelectorAll('style, link[rel=stylesheet]').length // 2document.querySelector('vercel-analytics').parentElement.tagName // "BODY"Astro 會把樣式表注入在 head 的最後,也就是在這個元素之後,所以它們都落進了 body。之所以看起來沒壞,是因為樣式表放在 body 裡一樣會生效。真正付出代價的是寫在下一行的 <slot name="head" />:搜尋頁的 Pagefind 樣式表也一起被擠了出去,而日後若有 <meta> 或 canonical 連結經由那個插槽送出,也會安靜地失效。
修法是改在 body 裡算繪。搬過去之後,同樣的量測在 head 讀到 2、在 body 讀到 0,搜尋頁的兩個樣式表也都回到了該在的位置。
第 2 版最後沒有給的東西
@vercel/analytics 選 v2 而不是 v1,是為了一項功能。resilient intake 會把固定的 /_vercel/insights/script.js 路徑換成每個專案隨機的路徑,如此一來就不會有單一條全域封鎖規則能一次命中所有 Vercel 網站。這裡的論據不是量,而是偏差:技術部落格的讀者封鎖追蹤腳本的比例並不平均,文章越技術,缺漏的讀者就越多。既然標記是依訪客數排名,輸入一旦偏斜,結果就不只是數字變小,而是標記加到了別的文章上。
它在本站並沒有生效。線上的頁面載入的是那個可預測的路徑:
https://oharu-tech-blog.vercel.app/_vercel/insights/script.js一開始的猜測是,因為這個儲存庫在 GitHub Actions 而不是在 Vercel 上建置,所以 vercel deploy --prebuilt 把建置期的設定弄丟了。**這個猜測是錯的。**由 Vercel 自己建置的預覽部署,輸出是逐位元組相同的。執行 vercel pull 就會發現,這個變數根本沒有被發給這個專案;下載下來的 28 個環境變數裡,沒有 VERCEL_OBSERVABILITY_CLIENT_CONFIG。
**用手動設定去修是錯的處置。**這個值必須包含 Vercel 在自家邊緣節點上配置的專屬路徑,那條路徑無從得知,而且這份設定是取代預設值,而不是補充它。猜出來的路徑會回傳 404,收集會完全停止,儀表板上只剩一排零,沒有任何東西可以解釋。元件本身已經會讀這個變數,所以 Vercel 哪天開始發送,這項功能就會自己啟用。在這裡 v2 的行為和 v1 完全一樣,而正確的動作是不要去碰它。
驗證
pnpm check 在這個儲存庫上會跑五個工具,而且把提示和警告分成不同類別回報,所以值得把數字原樣引出來,而不是做摘要:
Result (179 files):- 0 errors- 0 warnings- 0 hints解析與折疊的邏輯由 22 個斷言驗證,涵蓋結尾斜線、兩種語系前綴、查詢字串、路由樣式、三語合計、決定性的平手處理,以及拋出例外與不拋出例外的兩個方向。其中最重要的是合計那一個測試:跨三個語系 URL 的 10 + 5 + 2,必須在同一個 slug 上得出 17。
建置的路徑確認了兩條。沒有憑證時,建置成功、標記為 0 個,並印出一則指名缺少哪些變數的警告。有憑證時建置成功,而且能分辨空的期間與設定錯誤。一個計算對外請求次數的探針確認了整個建置的 158 個頁面總共只發出 1 次 API 呼叫,模組層級的記憶化正是為此而存在。
還有一則既有的警告與這一切無關,但與其留在輸出裡不提,不如寫出來:@astrojs/vercel 回報本機的 Node 26 不受支援,執行環境會改用 Node 24。CI 固定在 24,所以這只是本機的事。
總結
技術上的問題,用一次 curl 就自己回答了。靜態網站可以為自己的文章排名,因為排名發生在它還是一次建置的時候,而在那之後的一切,只是一個截止時間比較特別的普通取資料問題。
真正付出代價的,是圍繞在它周邊的一切:
- **已發布的隱私權頁面是一份規格。**在一個承諾自己沒有流量分析的網站上加入分析,意味著要用三種語言撤回三個主張,而且要和腳本在同一個 commit 出貨。
- **缺席與矛盾從外面看起來一模一樣。**缺少 token 和壞掉的解析器都會產生空的標記集合,所以刻意讓它們有不同的行為。
- **綠燈的建置不等於被解析過的建置。**head 被截斷這件事,五個檢查工具看不到,磁碟上的檔案也看不到,瀏覽器卻立刻就看到了。
- **升版所為的那項功能,未必真的啟用了。**確認這件事只花了一次
vercel pull,而正確的回應是什麼都不要改。
標記本身目前還沒有人拿到,而這是對的。放行標記的下限被設在現有流量之上,所以在某篇文章被讀到足以讓這個標記有意義之前,誰都不會掛上它。