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

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

- Source: https://oharu121.com/zh-tw/blog/astro-build-time-vercel-analytics-popular-article-badges/
- Published: 2026-08-17T18:00:50+09:00
- Tags: Astro, Vercel

---
## 引言

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

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

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

## 首頁說不出口的事

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

```ts title="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 在動筆之前先確認了這件事，順序是對的。第一次呼叫失敗了。

```json
{"error":{"code":"bad_request","message":"Invalid request: missing required property `since`."}}
```

這是個有用的失敗。**因為缺少參數而得到 `400`，代表端點是連得上的，而拒絕的並不是方案本身。**被方案擋下才是真正可能讓這個構想胎死腹中的情況。Vercel 有把各方案的報表期間寫進文件，卻從未說明 API 本身在 Hobby 上能不能用。補上 `since` 與 `until` 之後：

```bash
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"
```

```json
{"version":1,"query":{"groupBy":["requestPath"],"limit":100},"data":[]}
```

**一個帶著空陣列的 `200`，就回答了可行性的問題。**Hobby 有 API 存取權，報表期間是 30 天，而 `data` 之所以是空的，只是因為追蹤腳本還沒部署。

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

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

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

## 我推翻掉的那個反對意見

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

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

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

## 標記在哪裡被決定

*Figure — WhereDecided: 兩條路線裡出現的是同一位讀者與同一個 API。差別在於查詢發生的時機：是隨著讀者的請求，還是每次部署一次、遠在他們到訪之前。*

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

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

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

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

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

## 三個 URL，一篇文章

*Figure — OneTally: 若以列為單位排名，這篇文章會散在第 3、第 7 與第 11 名。折疊到 slug 上，它是第 1 名。訪客數為示意值。*

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

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

```ts title="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

*Figure — HeadTruncation: 檔案裡什麼都沒有移動。移動的是那條界線，而寫在它下方的一切都跟著進了 body。*

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

```astro title="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 個字元，所有樣式表都在它前面。唯一有異議的是解析器：

```js
// 在 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`，而正確的回應是什麼都不要改。

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

## 參考連結

- [用 API 查詢 Web Analytics，包含 aggregate 端點與它必填的 `since` 和 `until` 參數](https://vercel.com/docs/analytics/web-analytics-api)
- [Vercel Web Analytics 的隱私與合規，列出每個資料點會保存的所有欄位](https://vercel.com/docs/analytics/privacy-policy)
- [Web Analytics 的定價，包含 Hobby 方案 30 天的報表期間](https://vercel.com/docs/analytics/limits-and-pricing)
- [Web Analytics 的進階設定，說明第 2 版新增了什麼以及用戶端設定變數](https://vercel.com/docs/analytics/package)
- [HTML Standard："in head" 插入模式，其 anything-else 分支會關閉 head 元素](https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inhead)
