標籤
黑色卡片上的白色 Vercel 三角形標誌與字樣

用 Vercel Web Analytics 為熱門文章加上標記 — 在建置時解析,仍以靜態 HTML 出貨

這個 Astro 部落格如何標記最多人讀的文章:建置期間向 Vercel Web Analytics 查詢一次,合計三個語系,再烘進靜態 HTML。

本頁目錄

引言

這個部落格的首頁只依 publishedAt 排序,沒有別的。因此每一篇文章看起來都一樣重要,去年被讀者找到的文章沉在我星期二才發的東西下面,訪客也無從分辨。我想讓真正被讀的文章自己說出來。

但站上沒有任何形式的量測可以作為依據,也看不出該把量測放在哪裡。這是一個只有一條伺服器路由的靜態 Astro 建置。靜態網站完全可以為自己的文章排名,只要在它變成靜態網站之前先排完。astro build 執行期間會向 Vercel Web Analytics 查詢一次,結果被烘進 HTML,再由每週一次的定期重建避免它變舊。

本文涵蓋可行性確認、代價比程式碼還高的隱私權決定、建置期的機制本身,以及一個追蹤腳本擺放位置上的缺陷,這個儲存庫的所有自動檢查都直接放它過關。

首頁說不出口的事

資料層相關的部分只有四行。文章從內容集合取出,依可見性過濾,然後排序。

src/lib/blog.ts
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 上能不能用。補上 sinceuntil 之後:

終端機視窗
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 之所以是空的,只是因為追蹤腳本還沒部署。

啟用的對話框一開始就把同樣的限制講清楚,與其之後才發現,不如在這裡先讀過。

Vercel 的「Enable Web Analytics」對話框,選取的是 Hobby:內含每月 50,000 個事件、可檢視的歷史為 30 天、沒有自訂事件、資料接收有上限。下方的 Pro 選項標價為每月 20 美元。

決定報表期間的正是 Hobby 這 30 天的歷史,而沒有自訂事件也就排除了頁面瀏覽以外的任何追蹤。

這四行裡有兩行左右了設計。30 天的歷史意味著這個標記只能描述近期的流量,而結果證明那本來就是更有用的問題。接收有上限則代表用完額度時停止收集,而不是繼續計費。

我推翻掉的那個反對意見

Claude 列出四個候選訊號,並推薦了我沒有選的那一個:從既有的「喜歡」數推導出標記。這個選項不會新收集任何東西,不需要腳本,也不必動到隱私權頁面。這是保守的建議,背後的理由也站得住腳。

隨之而來的反對意見很具體。本站的隱私權頁面已經發布、有日期、以三種語言寫成,開頭就說這個網站「沒有流量分析、沒有廣告、沒有追蹤腳本」,而且只有「一項功能」會向伺服器送資料。src/data/privacy.ts 用自己的標頭註解把這件事綁住:

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,也不會在讀者的瀏覽器裡寫入任何東西。舊頁面的這個部分原封不動保留了下來。

標記在哪裡被決定

瀏覽次數可以在哪裡解析兩條路線。上方路線中,讀者連到伺服器算繪,而它在每次頁面瀏覽時都會查詢分析 API。下方路線中,一次分析查詢餵給 astro build,產出的靜態 HTML 直接由讀者下載,請求時不需要任何東西運作。每次請求時解析讀者伺服器算繪分析 API每次頁面瀏覽查詢一次,而且必須有東西持續運作。每次建置時解析採用分析 APIastro build靜態 HTML讀者每次部署查詢一次。讀者到訪時沒有任何東西在運作。
兩條路線裡出現的是同一位讀者與同一個 API。差別在於查詢發生的時機:是隨著讀者的請求,還是每次部署一次、遠在他們到訪之前。

在請求時解析瀏覽次數,代表每一次頁面載入都需要一台伺服器,也就是為了顯示一個標記,而把這個靜態網站放到一條會算繪的路由後面。**在建置時解析,執行期什麼都不需要。**計數在 Astro 建置期間取得一次,標記成為輸出 HTML 的一部分,讀者下載的仍然是和以前一樣的靜態檔案。

這個儲存庫本來就有同樣的模式。astro.config.mjs 在載入設定時呼叫 buildLastmodMap(),並把結果注入 sitemap,理由完全相同:外部資料在建置期間解析,烘進產物裡。

建置期解析的代價是新鮮度。星期一寫出來的 HTML,說的仍是星期一為真的事。這件事是用既有工作流程上的定期執行觸發器處理的,而不是新開一個:

.github/workflows/ci.yml
schedule:
# Mondays 03:00 UTC — midday Monday in JST.
- cron: '0 3 * * 1'

定期執行會檢出預設分支,所以 github.refrefs/heads/main,既有部署工作的關卡照樣通過。這件事比看起來重要:**它讓通往正式環境的路徑維持只有一條。**再加一個也會部署的工作流程,等於每次合併都有兩個互相競爭的正式部署,而那正是那個工作上方的註解所警告的事。

三個 URL,一篇文章

三個語系 URL,一份文章統計同一篇文章的三個請求路徑,各屬一個語系,訪客數分別為 10、5、2。三個箭頭匯聚到同一個方框,該方框以文章 slug 為鍵,保存加總後的 17。分析 API 回傳的三列彙整後的一列/blog/<slug>10/ja/blog/<slug>5/zh-tw/blog/<slug>/2slug<slug>17 訪客去掉語系前綴,並將結尾斜線正規化。
若以列為單位排名,這篇文章會散在第 3、第 7 與第 11 名。折疊到 slug 上,它是第 1 名。訪客數為示意值。

這裡的每一篇文章最多存在於三個 URL,一個語系一個。分析 API 會把它們當成三筆各自獨立的 requestPath 回傳,而**直接以列排名,會把同一篇文章的讀者拆成三份。**如此一來,單一語言的文章就會贏過一篇合計其實讀得更多的翻譯文章。

所以在排名之前,這些列會先折疊到 slug 上。這個正規化裡有兩個陷阱,而且兩個都會安靜地失敗:

src/lib/popularity.ts
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

head 內的自訂元素會截斷 head兩欄。左側的檔案寫法中,head 元素依序包含 meta 類、vercel-analytics 自訂元素、Astro 樣式表與 head 插槽內容。右側的解析結果中,head 只剩下 meta 類,自訂元素、樣式表與插槽內容全都被移到了 body。檔案的寫法<head>meta、title、canonical<vercel-analytics>Astro 樣式表slot="head" 的內容解析器建出的 DOM<head>meta、title、canonical<body><vercel-analytics>Astro 樣式表slot="head" 的內容head 在未知元素處結束。其後的一切都會變成 body。
檔案裡什麼都沒有移動。移動的是那條界線,而寫在它下方的一切都跟著進了 body。

Vercel 給 Astro 的程式碼片段把這個元件放在 <head> 裡,所以它就被放在那裡:

src/layouts/BaseLayout.astro
<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.html
document.head.querySelectorAll('style, link[rel=stylesheet]').length // 0
document.body.querySelectorAll('style, link[rel=stylesheet]').length // 2
document.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,而正確的回應是什麼都不要改。

標記本身目前還沒有人拿到,而這是對的。放行標記的下限被設在現有流量之上,所以在某篇文章被讀到足以讓這個標記有意義之前,誰都不會掛上它。

參考連結

分享這篇文章