ego (lite) 只是一款浏览器;ego 才是你跨设备的个人 Agent。
加入候补名单
网页抓取分页Playwright数据校验浏览器自动化

网页抓取分页:数字页、加载更多和无限滚动

2026年9月16日15 分钟阅读
白色的 ego (lite) 角色用魔杖指向六个正在运行的 Space,标签为 Code、GitHub、Amazon、Browser、Reddit 和 Terminal

网页抓取里的分页,难点不只是翻到下一页。更难的是判断后面还有没有数据,以及列表是不是真的结束了。多数站点会用三种模式之一:数字页、Load More(加载更多),或无限滚动。每一种都需要不同的导航策略、等待条件和停抓规则。一旦判断错了,爬虫可能提前停住,却从不抛错。

如果数据直接写在 HTML 里,或能从一个 JSON 接口拿到,通常根本不需要浏览器;纯 HTTP 更简单,也更快。只有当列表依赖客户端渲染、已登录会话、页面生成的 Token,或真实滚动行为时,浏览器才变得有用。而如果这些数据还依赖你已经登录的那只浏览器,ego (lite) 可以让 AI Agent 在现有浏览器会话里跑同一套分页逻辑,而不必重建登录态。

下面各节会用同一个问题去测数字页、加载更多和无限滚动:列表是真的结束了,还是爬虫只是不再继续要数据?示例用一套可重复的本地夹具:数字页共 135 行,加载更多背后渲染出 81 行,无限信息流有 60 条。读完之后,爬虫不仅会翻页,还会记下停在哪里、处理重复和漏行,并确认采集到的数据是完整的。

如何判断当前是哪一种分页模式

三条观察就能定分类。改 URL 或点带页码的链接会换掉结果集,这是数字分页。按钮文案承诺还有更多内容,点完只追加行、URL 不变,这是加载更多。滚动时行自己冒出来、没有可点的控件,这是无限滚动。页面如果混用几种模式,就把每一次切换当成独立模式,不要硬用一个循环同时覆盖。

在浏览器里快速核对,往往比读代码更快:打开 DevTools,盯着网络面板,操作一次。带 page 参数的导航请求是数字分页。点击触发的后台 JSON 或 HTML 请求是加载更多。滚动时反复发出的请求是无限滚动,响应里通常带着下一个 offset,这对爬虫是现成的礼物。

Grok 4.6 旁是 ego (lite) Space,正在 Reddit 搜索 Claude Code 时点击 Posts 标签,Agent is in control 可见
混杂的 All 标签页并不是帖子列表。点了 Posts 之后,信息流才变成一条条帖子,而这才是爬虫真正在计数的列表。

数字页:推导 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 毫秒收齐 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 毫秒内到达,可见列表是 81 行,数据集本身是 80 行。多出来的那一行是夹具故意埋的重复,用来模拟常见现实:同一条记录跨批次出现两次。页面自己的状态行写着 "Loaded 81 of 80",这正好提醒你:站点渲染出来的计数器不是校验依据。按稳定标识去重,例如行的 record id,而不是可见标题,你就会刚好去掉那一条重复。

无限滚动:触发条件、高度稳定与停抓规则

无限滚动没有按钮,也没有 URL,触发条件和停抓条件都得推断。触发通常是哨兵元素进入视口,或滚动位置越过某个阈值。停抓才需要判断,因为列表不会用一种你可以点击的方式宣布结束。

有一次测到的失败值得单独拿出来,因为多数爬虫出厂就带着它。滚到底一次,立刻看行数有没有增加,不等下一批渲染,就会马上报告 "no growth":夹具停在 15 of 60 行,才进了一批,而网络其实已经被要过下一批。检查对那一瞬间没有说错;错的是把一次安静当成列表结束。

可靠的写法是等待一个可观察条件,再用多次检查确认结束,而不是只看一次。滚动,等到条目数增长,或页面声明信息流已结束;只有连续三次检查两者都没发生,才把列表当成结束。夹具里同样 60 条在四轮滚动后收齐,零次误停,最后的状态行是 "End of feed (60 of 60)"。

Grok 4.6 旁是 reddit.com 上搜索 Claude Code 的 ego (lite) Space,正在滚动帖子列表,Agent is in control 和 Take over 可见
Reddit 搜索没有下一页 URL。Agent 在一个 Space 里滚动帖子列表;气泡标记的是 scroll search results,Take over 始终可点。
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 去重之后,两条重复都消失了。

Grok 4.6 列出 Claude Code 搜索中的去重 Reddit 帖子 1 到 6;右侧是 Google 首页
左栏:Reddit 滚动后的去重帖子 1 到 6。右栏是 Google 首页,不是信息流。去重看编号列表,不看页面高度。

漏行通常意味着等待太短,或某一页被跳过。如果总数刚好少一批,去看出问题前那一次交互;如果少的是一整页,去看循环的增量。夹具里短暂的 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。滚动之后要核对的是这份编号输出。

Grok 4.6 把去重 Reddit 帖子列表续到第 10 条;右侧是 Google 首页
同一份去重列表续到第 10 条。按 URL 去重,才能避免后面一轮滚动把同一帖再数一次。

什么时候纯 HTTP 就够,什么时候不够

同一套夹具数据不用浏览器也能拿到。两条路径都测一遍,才是判断任务到底需要什么的老实办法。一个纯 HTTP 客户端按 URL 走数字页,从 JSON 接口拉加载更多的批次,再按 offset 拉无限信息流,135、81 和 60 行合计用了 24 毫秒。没有渲染,没有等待,没有滚动。

这就是默认建议,而且应该说清楚:有官方 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。隔离才是重点。总览只是让你同时看见两者。

Grok 4.6 旁是 ego (lite) 的 Spaces 总览:一个空转的 Google Space,一个正在运行的 Reddit 搜索 Space
同一浏览器里两个 Space:空转的 Google 标签旁是正在跑的 Reddit 抓取。重点是隔离,不是同一信息流上的两个滚动位置。

版本 0.5.0.32 记在 更新日志(2026-09-12)。引用更新的构建前,请再核对该页、快速开始GitHub 仓库。相邻指南 用 JavaScript 做网页抓取 从另一侧讲静态与渲染的判断。

FAQ

数字列表到底有多少页,怎么知道?

看最后一页控件,用页头总数除以每页条数,再真正走到末尾一次,就能知道数字列表有多少页。把这个估计当核对,而不是循环的停抓条件。

为什么爬虫会在页与页之间收到重复?

底层列表重新排序时,一条记录会从这一批滑到下一批,爬虫就会在页与页之间收到重复。按稳定的 record id 去重,不要按可见标题,并记下去掉了多少条重复。

点击加载更多之后该等多久?

等到一个可观察条件成立:行数增加了,按钮的 disabled 状态解除了,或下一批里已知的某一行出现了。固定延迟只是猜测,网络一慢它就失效,而这正是它会失败的时刻。

无限信息流的尽头怎么判断?

站点如果提供明确信号,就优先用它,例如渲染出来的结束文案,或响应里的 has-more 标志。两者都没有时,要连续多次检查都没有新条目、已加载内容的高度也不再变化,再停,而不是只看一次安静。

分页抓取该用无头浏览器吗?

只有数据需要渲染、会话或页内 Token 时才用。服务端渲染的列表和 JSON 接口更适合纯 HTTP,跑起来更快、也更简单。两条路径都测一次,对比通常会让决定变得显而易见。

分页抓取为什么会提前停住?

三个常见嫌疑人:等待在这一批渲染完之前就结束了;把短暂错误当成了空页;运行中途会话过期,下一页渲染的是登录表单而不是行。每一种修法都不同,所以停抓原因要和条数一起记下来。

崩溃之后怎么续跑抓取?

每完成一批就写检查点,记下模式、位置和目前收到的行。续跑读取该文件,从记录的位置重新进入该模式。在检查点位置之前再抓一批,确认源数据没有在已保存状态底下发生偏移。

怎样确认一次抓取是完整的?

把唯一记录数至少和两个独立数字对照,例如最后一页自己的总数,以及各页条数之和,再检查每一行的字段完整度并报告例外。条数对得上、字段又都在,这是强信号;只满足其中一项则很弱。

要慢慢滚才能触发懒加载吗?

通常不必,但要再次触发加载器,而不是停在底部不动。站点用的 intersection observer 在变化时才会触发,所以交替位置或分步滚动,比一次大跳更可靠,增长检查也才有意义。

登录墙后面的分页列表能抓吗?

可以,前提是真实会话,规则也一样:礼貌分页,保持会话活着,并把跳转到登录页当成会话问题,而不是列表结束。像 ego (lite) 这种已经带着你登录态的浏览器,会把重建会话这一步整个省掉。可参考 持久会话指南 讲状态处理,价格抓取用例 则展示一次完整的登录后采集长什么样。