
網頁抓取裡的分頁,難點不只是翻到下一頁。更難的是判斷後面還有沒有資料,以及清單是不是真的結束了。多數網站會用三種模式之一:編號頁、Load More(載入更多),或無限捲動。每一種都需要不同的導覽策略、等待條件與停抓規則。一旦判斷錯了,爬蟲可能提前停住,卻從不丟出錯誤。
如果資料直接寫在 HTML 裡,或能從一個 JSON 端點拿到,通常根本不需要瀏覽器;純 HTTP 更簡單,也更快。只有當清單依賴用戶端轉譯、已登入工作階段、頁面產生的 Token,或真實捲動行為時,瀏覽器才變得有用。而如果這些資料還依賴你已經登入的那只瀏覽器,ego (lite) 可以讓 AI Agent 在既有瀏覽器工作階段裡跑同一套分頁邏輯,而不必重建登入狀態。
以下各節會用同一個問題去測編號頁、載入更多與無限捲動:清單是真的結束了,還是爬蟲只是不再繼續要資料?範例使用一套可重複的本機夾具:編號頁共 135 列,載入更多背後轉譯出 81 列,無限資訊流有 60 筆。讀完之後,爬蟲不僅會翻頁,還會記下停在哪裡、處理重複與漏列,並確認採集到的資料是完整的。
如何判斷目前是哪一種分頁模式
三條觀察就能定分類。改 URL 或點帶頁碼的連結會換掉結果集,這是編號分頁。按鈕文案承諾還有更多內容,點完只追加列、URL 不變,這是載入更多。捲動時列自己冒出來、沒有可點的控制項,這是無限捲動。頁面如果混用幾種模式,就把每一次切換當成獨立模式,不要硬用一個迴圈同時覆蓋。
在瀏覽器裡快速核對,往往比讀程式碼更快:打開 DevTools,盯著網路面板,操作一次。帶 page 參數的導覽請求是編號分頁。點擊觸發的背景 JSON 或 HTML 請求是載入更多。捲動時反覆發出的請求是無限捲動,回應裡通常帶著下一個 offset,這對爬蟲是現成的禮物。

編號頁:推導 URL 規則,並在正確的位置停住
編號分頁最好相處,因為狀態就寫在 URL 裡。從前三頁推導規則:哪個參數在變、它是頁碼還是 offset,以及每頁筆數是否固定。規則應能寫成一個函式;正確的函式能從第 3 頁直接得到第 4 頁,中間什麼都不用造訪。
// Derive once, then verify against page 2 and page 3 before trusting it.
const pageUrl = (n) => `https://example.com/listings?page=${n}`;
let page = 1;
const rows = [];
while (true) {
await browserPage.goto(pageUrl(page));
const batch = await browserPage.locator("tr.row").evaluateAll((nodes) =>
nodes.map((node) => ({
id: node.dataset.id,
title: node.querySelector(".title").textContent.trim(),
price: Number(node.querySelector(".price").textContent.replace(/[^0-9.]/g, "")),
})),
);
if (batch.length === 0) break;
rows.push(...batch);
const hasNext = (await browserPage.locator("a#next").count()) > 0;
if (!hasNext) break;
page += 1;
}終止條件值得比平時多花一點心思。下一頁連結消失,和這一批是空的,都是真正的結束訊號;寫死的總頁數不是,因為清單會變長,今天寫死的數字下個月就錯。夾具執行裡,爬蟲走完第 1 到第 5 頁,用 80 milliseconds 收齊 135 列,並在下一頁連結消失時停住。五次請求裡有一次首次回傳 503,爬蟲對同一頁重試一次後成功。
要讓迴圈誠實,先守兩條規則。第一,頁面請求之間要停頓,不要能多快就多快,並遵守網站條款以及 RFC 9309 裡描述的 robots 排除協定。第二,清單本應有序時,把「又拿到同一頁」當成停抓訊號,因為忽略自身參數的分頁否則會永遠轉下去。
載入更多:點擊、等待、去重
載入更多把狀態藏在按鈕後面,所以爬蟲得自己製造成長:點擊,等到列數真正增加,收走新增部分,再重複,直到按鈕消失或再點也不改變任何東西。失敗幾乎都出在等待上。固定停頓在快網路上碰巧能用,在慢網路上會靜悄悄截斷清單。
await browserPage.goto("https://example.com/listings");
while (true) {
const button = browserPage.locator("#load-more");
if ((await button.count()) === 0) break;
const before = await browserPage.locator("tr.row").count();
await button.click();
await browserPage.waitForFunction(
(n) => document.querySelectorAll("tr.row").length > n,
before,
{ timeout: 10000 },
);
}
const rows = await browserPage.locator("tr.row").evaluateAll((nodes) =>
nodes.map((node) => ({ id: node.dataset.id, title: node.textContent.trim() })),
);夾具執行裡,四批、每批二十列,在 273 milliseconds 內到達,可見清單是 81 列,資料集本身是 80 列。多出來的那一列是夾具故意埋的重複,用來模擬常見現實:同一筆紀錄跨批次出現兩次。頁面自己的狀態列寫著 "Loaded 81 of 80",這正好提醒你:網站轉譯出來的計數器不是驗證依據。按穩定識別碼去重,例如列的 record id,而不是可見標題,你就會剛好去掉那一筆重複。
無限捲動:觸發條件、高度穩定與停抓規則
無限捲動沒有按鈕,也沒有 URL,觸發條件與停抓條件都得推斷。觸發通常是哨兵元素進入檢視區,或捲動位置越過某個閾值。停抓才需要判斷,因為清單不會用一種你可以點擊的方式宣布結束。
有一次測到的失敗值得單獨拿出來,因為多數爬蟲出廠就帶著它。捲到底一次,立刻看列數有沒有增加,不等下一批轉譯,就會馬上報告 "no growth":夾具停在 15 of 60 列,才進了一批,而網路其實已經被要過下一批。檢查對那一瞬間沒有說錯;錯的是把一次安靜當成清單結束。
可靠的寫法是等待一個可觀察條件,再用多次檢查確認結束,而不是只看一次。捲動,等到項目數成長,或頁面聲明資訊流已結束;只有連續三次檢查兩者都沒發生,才把清單當成結束。夾具裡同樣 60 筆在四輪捲動後收齊,零次誤停,最後的狀態列是 "End of feed (60 of 60)"。

let stableChecks = 0;
while (stableChecks < 3) {
const items = await browserPage.locator("article.post").count();
await browserPage.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const grew = await browserPage
.waitForFunction(
(n) =>
document.querySelectorAll("article.post").length > n ||
/end of feed/i.test(document.body.innerText),
items,
{ timeout: 3000 },
)
.then(() => true)
.catch(() => false);
stableChecks = grew ? 0 : stableChecks + 1;
if (!grew) await browserPage.waitForTimeout(700);
}兩個細節決定迴圈是穩定還是飄忽。高度是否穩定,要看抽出的項目數,而不是文件高度,因為延遲載入圖片與廣告會改高度卻不增加紀錄。捲動還必須真正再次觸發網站的載入器:以 Intersection Observer API 為基礎的實作會在交叉變化時觸發,所以交替捲動位置比把頁面停在底部更可靠。
記錄抓取狀態,當機後從斷點續跑
三種模式共用一個要求:一次執行必須扛得住自己被打斷。每完成一批就寫檢查點,記下模式、位置與目前收到的紀錄,並把續跑當成預設路徑,而不是特殊路徑。夾具裡,編號頁抓取在第 2 頁之後被殺掉,檢查點裡有 54 列。新行程讀入檢查點,從第 3 頁繼續,最終得到與不間斷執行相同的 135 列與 133 個唯一 id。
import { readFileSync, writeFileSync } from "node:fs";
const CHECKPOINT = "./crawl-state.json";
const save = (state) => writeFileSync(CHECKPOINT, JSON.stringify(state));
const load = () => {
try {
return JSON.parse(readFileSync(CHECKPOINT, "utf8"));
} catch {
return { mode: "numbered", lastPage: 0, rows: [] };
}
};
const state = load();
for (let page = state.lastPage + 1; page <= 5; page += 1) {
state.rows.push(...(await crawlPage(page)));
state.lastPage = page;
save(state); // checkpoint after every page
}檢查點也會改變除錯方式。抓取如果真的給出錯誤筆數,你可以先看狀態檔,而不必整份工作重跑;那裡記下的位置會告訴你該重抓哪一批。
重複列、漏列,以及假的最後一頁
三種症狀涵蓋了大多數分頁 bug,而且每一種的簽名都在資料裡,不在程式碼裡。重複出現在一批與相鄰批次重疊時,底層清單在兩次請求之間重新排序,穩定識別碼就會重複。夾具精確重現了這一點:同一個 id 在第 2 頁出現一次,第 3 頁再出現,第 5 頁裡又出現。按 id 去重之後,兩筆重複都消失了。

漏列通常意味著等待太短,或某一頁被跳過。如果總數剛好少一批,去看出問題前那一次互動;如果少的是一整頁,去看迴圈的增量。夾具裡短暫的 503 是另一種味道:沒有重試的話,第 3 頁會貢獻零列,整次執行會回報 106 列,卻沒有任何錯誤。
假的最後一頁最危險,因為抓取會帶著更少的資料回報成功。它出現在某一頁因工作階段或轉譯問題回傳零列,而不是清單真的結束,迴圈卻把兩者一視同仁。區分辦法是再探一次:稍等之後再請求下一頁,必須看到真正的結束標記,或連續兩次空回應,才接受結束。
用筆數與欄位完整度做驗證
驗證就是兩項只需幾秒、卻能抓住多數靜默失敗的檢查。筆數檢查把採到的數量,和你能找到的每一個獨立數字對照:最後一頁自己的 "page 5 of 5" 聲明、頁首裡的總數,或沿途每頁筆數之和。欄位檢查確認每筆紀錄都帶著工作承諾的欄位,並把例外計下來,而不是忽略它們。
const unique = new Map(rows.map((row) => [row.id, row]));
const missing = rows.filter((row) => !row.title || !Number.isFinite(row.price));
console.log({
raw: rows.length,
unique: unique.size,
removedDuplicates: rows.length - unique.size,
missingFields: missing.length,
});夾具的 135 筆編號頁上,檢查回報零個缺失欄位、去掉兩筆重複、133 筆唯一紀錄,與資料集完全吻合。載入更多清單則回報 81 筆裡有 1 筆重複。這兩個數字單獨看都不稀奇;要緊的是其中任何一個變化都會立刻可見,而不是在下游報表裡變成一段說不清的缺口。
這次 Reddit 執行把同一套檢查用在即時去重清單上,不是 135 列夾具。每列都要有標題、subreddit、票數和 URL。捲動之後要核對的是這份編號輸出。

什麼時候純 HTTP 就夠,什麼時候不夠
同一套夾具資料不用瀏覽器也能拿到。兩條路徑都測一遍,才是判斷工作到底需要什麼的老實辦法。一個純 HTTP 用戶端按 URL 走編號頁,從 JSON 端點拉載入更多的批次,再按 offset 拉無限資訊流,135、81 與 60 列合計用了 24 milliseconds。沒有轉譯,沒有等待,沒有捲動。
這就是預設建議,而且應該說清楚:有官方 API 就用官方 API。編號清單如果是伺服器轉譯,直接請求這些頁面。載入更多或無限模式如果背後是不需要簽章的 JSON 端點,就請求那個端點,按 offset 分頁。這些都不需要 Playwright 這類框架,HTTP 路徑跑起來更快、也更便宜。可參考 Apify 分頁實作說明、Web Scraper 分頁選擇器文件,以及 Playwright 網路指南 裡的網路面板流程,它們從不同起點涵蓋同一片內容。
只有在這些情況下才有必要上瀏覽器:資料必須等用戶端轉譯之後才存在;請求帶著頁面裡產生的簽章或 Token;清單需要登入;或者沒有真實工作階段與真實 user agent 時,端點的回應就不一樣。到了這一步,瀏覽器不是抓取偏好,而是唯一老實的路徑,前面各節的邏輯原樣適用。
ego (lite) 只在這條 HTTP 路徑失敗之後才進場。如果清單需要已登入 cookie、頁面產生的 Token,或沒有真實檢視區就永遠不會發生的捲動,就在已經持有工作階段的瀏覽器裡跑同一套編號頁 / 載入更多 / 無限迴圈。不要從那裡起步。夾具已經給出更便宜的答案:純 HTTP 用 24 ms 拿到 135、81 與 60 列。
真的需要瀏覽器時,仍沿用前面各節的抓取邏輯。ego (lite) 提供已登入的頁面和一個你可以盯著看的 Space;它並不會發明一套新的分頁演算法。某一步需要人來做,就停。如果 curl 或官方 API 已經能回傳這些列,就留在 HTTP 上。
編號頁的隔離規則仍然適用。共用一個視窗的兩個爬蟲會搶捲動位置,互相覆蓋對方最後看到的一列。同一瀏覽器裡,兩個 Space 相當於兩個 Playwright context:一個可以停在 Google 空轉,另一個繼續捲 Reddit。隔離才是重點。總覽只是讓你同時看見兩者。

版本 0.5.0.32 記在 更新紀錄(2026-09-12)。引用更新的建置前,請再核對該頁、快速開始 與 GitHub 存放庫。相鄰指南 用 JavaScript 做網頁抓取 從另一側講靜態與轉譯的判斷。
FAQ
編號清單到底有多少頁,怎麼知道?
看最後一頁控制項,用頁首總數除以每頁筆數,再真正走到末尾一次,就能知道編號清單有多少頁。把這個估計當核對,而不是迴圈的停抓條件。
為什麼爬蟲會在頁與頁之間收到重複?
底層清單重新排序時,一筆紀錄會從這一批滑到下一批,爬蟲就會在頁與頁之間收到重複。按穩定的 record id 去重,不要按可見標題,並記下去掉了多少筆重複。
點擊載入更多之後該等多久?
等到一個可觀察條件成立:列數增加了,按鈕的 disabled 狀態解除了,或下一批裡已知的某一列出現了。固定延遲只是猜測,網路一慢它就失效,而這正是它會失敗的時刻。
無限資訊流的盡頭怎麼判斷?
網站如果提供明確訊號,就優先用它,例如轉譯出來的結束文案,或回應裡的 has-more 旗標。兩者都沒有時,要連續多次檢查都沒有新項目、已載入內容的高度也不再變化,再停,而不是只看一次安靜。
分頁抓取該用無頭瀏覽器嗎?
只有資料需要轉譯、工作階段或頁內 Token 時才用。伺服器轉譯的清單與 JSON 端點更適合純 HTTP,跑起來更快、也更簡單。兩條路徑都測一次,對比通常會讓決定變得顯而易見。
分頁抓取為什麼會提前停住?
三個常見嫌疑人:等待在這一批轉譯完之前就結束了;把短暫錯誤當成了空頁;執行中途工作階段過期,下一頁轉譯的是登入表單而不是列。每一種修法都不同,所以停抓原因要和筆數一起記下來。
當機之後怎麼續跑抓取?
每完成一批就寫檢查點,記下模式、位置與目前收到的列。續跑讀取該檔,從記錄的位置重新進入該模式。在檢查點位置之前再抓一批,確認來源資料沒有在已儲存狀態底下發生偏移。
怎樣確認一次抓取是完整的?
把唯一紀錄數至少和兩個獨立數字對照,例如最後一頁自己的總數,以及各頁筆數之和,再檢查每一列的欄位完整度並回報例外。筆數對得上、欄位又都在,這是強訊號;只滿足其中一項則很弱。
要慢慢捲才能觸發延遲載入嗎?
通常不必,但要再次觸發載入器,而不是停在底部不動。網站用的 intersection observer 在變化時才會觸發,所以交替位置或分步捲動,比一次大跳更可靠,成長檢查也才有意義。
登入牆後面的分頁清單能抓嗎?
可以,前提是真實工作階段,規則也一樣:有禮貌地分頁,保持工作階段活著,並把重新導向到登入頁當成工作階段問題,而不是清單結束。像 ego (lite) 這種已經帶著你登入狀態的瀏覽器,會把重建工作階段這一步整個省掉。可參考 持久工作階段指南 講狀態處理,價格抓取使用情境 則展示一次完整的登入後採集長什麼樣。

