ego (lite) 只是一款浏览器;ego 才是你跨设备的个人 Agent。
加入候补名单
网页抓取JavaScriptNode.jsPlaywrightCheerioego (lite)

用 JavaScript 做网页抓取:静态 HTML、渲染页面与 Playwright

2026年9月15日13 分钟阅读
一块 JS 积木与喜剧、悲剧两张面具,稳稳停在一只蓝色浏览器之眼上,下方是像素风的山脉与花田

JavaScript 网页抓取常常失败,问题不在代码写错,而在于一开始就选错了访问路线。如果数据本来就在初始 HTML 里,用 Node.js 的 fetch 加一个 HTML 解析器就够了。只有当页面必须先执行 JavaScript、翻页、点击或以其他方式交互,数据才会出现时,浏览器才成为必需品,而这正是静态 HTML 与渲染页面之间的分界线。

对于提前就已知、且会反复执行的工作流,Playwright 非常合适:打开页面、等待某个元素、点击控件、提取字段,然后一遍遍重跑同一条路径。问题出现在页面结构、翻页方式或交互流程发生变化,而那一串写死的选择器和操作开始失效的时候。

这时候,像 ego (lite) 这种由 Agent 驱动的浏览器就更合适。它不会假定原来的路径依然存在,Agent 可以检查当前渲染出来的页面,判断下一步该做什么,并在发生跳转或动态变化后继续稳定的执行下去。

JavaScript 爬虫能否跑通,真正取决于什么?

访问路线决定结果。同一个目标站点,可能用 HTTP 就能轻松抓下来,也可能对 HTTP 完全不可见,区别只在于数据是随初始 HTML 响应一起到达,还是之后由浏览器里的 JavaScript 拉取并渲染出来。

所以第一件事不是动手写爬虫,而是打开目标页面,查看源代码而不是渲染后的 DOM,搞清楚你要的字段到底存在哪里。本指南后面的所有内容,都从这个答案推导而来。

你的目标站点适合三条访问路线中的哪一条?

三条路线几乎覆盖了所有抓取任务,而且按成本排序。静态 HTML 最省、最快;页面本身就在调用的 JSON 接口,往往是你拿到的最干净的数据;真实浏览器能力最强,但在 CPU、内存和脆弱性上的代价也最高。

路线能做到什么做不到什么
HTTP 请求 + HTML 解析器直接抓取任意 URL,读取响应正文,再对返回的标记做查询。单进程每分钟可处理数千个页面,且不需要浏览器二进制文件。无法运行页面脚本、点击、滚动或填写表单。在客户端渲染的页面上,它只会返回空壳,因为数据从来就不在响应里。
直接的 JSON 接口直接返回结构化数据,无需解析标记,因此即使页面版式改版,字段名依然稳定。载荷最小,解析最快。无法在站点更新后保持稳定。这类接口属于内部接口,没有文档,可能在毫无通知的情况下变更,或开始拒绝请求。
真实浏览器自动化执行页面的 JavaScript,等待内容出现,并像真人一样与渲染后的结果交互。无法低成本扩展。每个浏览器上下文都要占用实打实的内存,而要跑一大批浏览器,所需的基础设施远超一个 HTTP 循环。
一个名为 laptop-research 的 ego (lite) Space,就开在本指南要抓取的 webscraper.io 笔记本电脑目录旁边
第三条路线,已经在运行:一个 Agent 在真实的浏览器 Space 里工作,右侧是同一份目标目录。这条路线是主动选择,而非默认选项,也是运行成本最高的一条。

在 Node.js 里如何抓取并解析静态 HTML?

先从平台本身入手。Node.js 把 Fetch API 作为全局对象提供,所以发请求完全不需要额外依赖。下面这段就是整条第一条路线:发请求、检查状态、读取文本,然后把标记交给解析器。

请求本身除了平台之外什么都不需要,因为 Fetch API 是 Node.js 的全局能力,所以一次普通的 GET 不需要安装任何 HTTP 库。

状态检查是最先被人删掉、也是事后最容易被忽略的一步。404 或反爬拦截页同样会返回正文,而这段正文会顺利解析出零个匹配元素,看上去和选择器写错了一模一样。

const res = await fetch(url, {
  headers: { "user-agent": "my-scraper/1.0 (+contact@example.com)" },
});

if (!res.ok) {
  throw new Error(`${res.status} ${res.statusText} for ${url}`);
}

const html = await res.text();

频率限制应该就写在这个循环里,而不是事后再补。请求之间加一个被 await 的延迟,既能让你这个小任务显得有礼貌,也能让 IP 地址免进黑名单:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

for (const url of urls) {
  const html = await getHtml(url);
  await parse(html);
  await sleep(1000);
}

下面是第一条路线实际拿到的东西。下表是针对一个公开的笔记本电脑测试目录,用普通 HTTP 请求加选择器库得到的真实输出:没有浏览器,没有渲染步骤,三个页面就是三次静态响应。

一个终端正在运行 Node.js 脚本,用内置的 fetch API 加上 Cheerio 处理这份笔记本电脑目录,画面上能看到 base URL、$500 的价格上限、在 ?page=N 上的分页检查,以及以产品 URL 为键的去重判断
第一条路线的完整过程:Node 内置的 fetch 加 Cheerio,没有浏览器自动化,尽管装了 axios 也没用它。分页就是一个普通的 ?page= 参数,去重以 URL 为键,而不是显示名称。
ID名称价格规格评论数
32Aspire E1-510$306.9915.6", Pentium N3520 2.16GHz, 4GB, 500GB, Linux2
45Asus VivoBook Max$39915.6" HD, Pentium N4200 1.1GHz, 4GB, 500GB, Windows 10 Home4
31Packard 255 G2$416.9915.6", AMD E2-3800 1.3GHz, 4GB, 500GB, Windows 8.12
46Dell Vostro 15$488.7815.6" FHD, Core i5-7200U, 4GB, 128GB SSD, Radeon R5 M420 2GB, Linux14

十八款产品里有四款低于 $500。这就是第一条路线在这份目录上的全部结果:三次请求,没有渲染,没有浏览器进程。而诚实的一面也从这里开始,因为这张表里少了一样读者本以为会看到的东西。

Cheerio、DOMParser、jsdom:该选哪个解析器?

这三者常被拿来比较,仿佛是可以互换的库。其实不是,因为它们承诺的东西不同:其中两个只是解析标记供查询,另一个则实现了带脚本执行的 DOM。

本指南所依赖的解析器,文档在 cheerio.js.org,其中明确指出 Cheerio 只解析并查询标记,不会执行页面脚本。

一份覆盖 117 个产品、20 个页面的目录终端报告,随后标出两个解析陷阱,并附上关于语义化 microdata 选择器和 ?page= 分页方案的实现说明
真实输出里能看到两个陷阱:显示名 ThinkPad Yoga 被两台不同的机器复用,所以去重必须以产品 ID 为键;另外两个产品名被网站自己的 CSS 截断,完整值只存在于链接的 title 属性里。
选项能做到什么做不到什么
Cheerio快速解析 HTML 字符串,并用 jQuery 风格的选择器查询。依赖小、不需要浏览器,非常适合从响应正文里抽取几百个字段。无法运行页面脚本、渲染组件或处理布局。它只解析标记,行为并不像浏览器。
jsdom在 Node.js 里提供一套 DOM 实现,带有 document、window 和脚本执行能力,让针对浏览器 API 写的代码可以原样运行。无法在渲染或还原度上媲美真实浏览器,而且每个页面的负担重得多。它是 DOM 的替代品,不是 Chrome。
DOMParser用浏览器内置 API 把字符串变成一个可查询的 document,项目完全不需要新增依赖。不能被 await:它是同步且阻塞的,而且在 Node.js 里直到较新的版本才成为全局对象。

用 Cheerio 读取数据走的是熟悉的选择器 API。注意提取文本时要在两端做 trim,因为抓来的标记带着缩进和换行,不处理就会混进数据集里:

import * as cheerio from "cheerio";

const $ = cheerio.load(html);
const items = $(".product-card").map((i, el) => ({
  name: $(el).find(".name").text().trim(),
  price: $(el).find(".price").text().trim(),
})).get();

用 DOMParser 做同样的提取则是这样,而且只有在该全局对象存在的地方才能运行:

const doc = new DOMParser().parseFromString(html, "text/html");
const rows = [...doc.querySelectorAll("table tbody tr")].map((tr) => ({
  cells: [...tr.querySelectorAll("td")].map((td) => td.textContent.trim()),
}));

如何找到页面本身已经在用的 JSON 接口?

在写第一个选择器之前,先打开浏览器的网络面板,筛选 fetch 和 XHR,重新加载页面,看看返回了什么。很多数据驱动的站点只是用少量 JSON 响应拼出可见页面,而这些 JSON 比标记好消费得多。

找到之后,这个请求通常需要页面当时发送的同一批请求头,有时还需要会话 Cookie。直接用网络面板的 copy-as-fetch 输出重放它,不要手动拼装。

const res = await fetch("https://example.com/api/listings?page=1", {
  headers: { accept: "application/json" },
});

if (!res.ok) throw new Error(`${res.status} for listings page 1`);

const { items } = await res.json();

客户端渲染的页面为什么会返回空壳?

客户端渲染的页面发回来的标记里几乎没有内容。初始响应里只有一个根元素、一包 script,也许还有一个加载状态。用户在屏幕上看到的文字,是响应到达之后由 JavaScript 生成的,所以只读响应的 HTTP 请求根本无从读起。

这并不神秘,是可以诊断的。两个检查就能覆盖大多数情况:响应里的可见文本只是浏览器所显示内容的一小部分,而且标记中绝大多数是 script 标签:

const text = html.replace(/<script[\s\S]*?<\/script>/g, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();

console.log({
  textLength: text.length,
  scriptCount: (html.match(/<script\b/g) ?? []).length,
  hasRoot: /id="(root|app|__next)"/.test(html),
});

文本很短、script 很多、根 div 又是空的,这三者同时出现,说明老实该走第三条路线了。而只有几十个字符、完全没有 script 标签,通常意味着更简单的问题:请求被拦截了,或者你请求了错误的 URL。

实际差别大致如下:

HTTP 响应中的信号通常意味着下一步
内容完整、标记真实服务端已经渲染好了页面,不需要再做别的。用选择器库解析即可。
根 div 加大量 script客户端渲染,内容在响应之后才到达。去找 JSON 接口,或者渲染这个页面。
文本极短,没有 script被拦截、被重定向,或者干脆是 URL 错了。解析之前,先记录状态码、最终 URL 和请求头。

Playwright 如何抓取真实浏览器里的页面?

Playwright 驱动的是真实浏览器,页面会像面对普通访客那样完整执行。抓取的大致形态比多数人想象的更简单:打开一个上下文、访问 URL、等待你真正需要的那个东西出现,然后把它读出来。

本文用到的 API 文档在 playwright.dev,这是启动浏览器、上下文和 locator 的权威参考。

一次 Playwright 运行,在真实的 Chromium 窗口里打开目标页面,左侧终端里显示驱动它的指令
第三条路线,瞄准同样的目标:由 Playwright 打开的真实浏览器。多出来的成本换来脚本执行和渲染后的 DOM,这正是一个客户端渲染页面所需要的,也是单靠 fetch 做不到的。

下面这个读取模式先等待一个选择器,然后通过 page.evaluate 提取,后者会在页面内部执行你的函数,并返回可序列化的结果:

import { chromium } from "playwright";

const browser = await chromium.launch();
const context = await browser.newContext();

try {
  const page = await context.newPage();
  await page.goto(url, { waitUntil: "domcontentloaded" });
  await page.waitForSelector(".product-card");

  const rows = await page.evaluate(() =>
    [...document.querySelectorAll(".product-card")].map((el) => ({
      name: el.querySelector(".name")?.textContent?.trim() ?? null,
      price: el.querySelector(".price")?.textContent?.trim() ?? null,
    })),
  );

  console.log(rows);
} finally {
  // contexts hold the memory; close it even when the scrape throws
  await context.close();
  await browser.close();
}

有两个生命周期细节比选择器更值得注意。第一,上下文是廉价、可一次性丢弃的单位,所以一个浏览器可以服务多次相互隔离的运行,每次结束时都要 close。第二,在页面上提取文本拿到的是文本节点,而不是渲染后的值:被 CSS 隐藏的内容依然在 DOM 里,也依然会出现在你的输出中。

当目标只是某个特定元素而不是整个列表时,locator 是更干净的读取路径。Playwright 自己的 locator 文档就指出,按钮、链接和输入框并不需要先等待再点击,这一点值得记住,免得把每一次交互都套上一层显式等待:

const title = await page.getByRole("heading", { level: 1 }).innerText();
const price = await page.locator("[data-testid=price]").innerText();

会话、封禁风险与验证码(CAPTCHA)会如何改变方案?

目标一旦需要登录,抓取就不再是单纯的数据活,而变成了会话管理问题。Playwright 直接支持这一点:认证一次,把浏览器的存储状态保存到文件,之后的运行复用即可,不必每次都写一遍凭据输入流程。

// once, interactively
await context.storageState({ path: "auth.json" });

// later runs
const context = await browser.newContext({ storageState: "auth.json" });

由此引出两个运维事实。保存下来的会话状态本身就是凭据,应该放进密钥仓库,而不是提交到代码仓库。而且保存的会话会过期,所以某次运行突然开始返回登录页,那是会话问题,不是选择器问题。

让这个状态在多次启动之间保持存活是另一个话题: 跨 Agent 运行复用持久浏览器会话 有详细讲解。

就自动化流量而言,三个约束决定了你做的是一件抓取工作,还是一场注定要输的仗:站点的 robots 指令和服务条款允许什么,站点公布或能容忍多高的请求频率,以及你拿到的响应是内容本身还是一张挑战页。这些都是在写代码之前就要定的策略问题,我们的另一篇指南有更深入的讨论:登录墙后的抓取

JavaScript 爬虫在生产环境里会栽在哪里?

爬虫很少是因为选择器写错而失败。它们往往死在第二次运行、第一百个 URL 上:页面变慢了、响应变成重定向了,或者站点开始限流了。修复手段都不光鲜,而且很具体。

async function getWithRetry(url, attempt = 0) {
  const res = await fetch(url);
  if (res.status === 429 || res.status >= 500) {
    if (attempt >= 3) throw new Error(`giving up on ${url}`);
    const retryAfter = Number(res.headers.get("retry-after"));
    const waitMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 2 ** attempt * 1000;
    await new Promise((r) => setTimeout(r, waitMs));
    return getWithRetry(url, attempt + 1);
  }
  if (!res.ok) throw new Error(`${res.status} for ${url}`);
  return res.text();
}

第二个陷阱是并发。同一台机器上十个并行浏览器上下文,大多只是在抢同一批 CPU,所以吞吐量的提升小于内存代价,而站点看到的是一阵突发流量,而不是细水长流。先串行跑,测出数据,只有在目标站点受得了的情况下才把数字往上调。

第三点,任何读取无承诺接口的爬虫都需要兜底。把基于选择器的可见页面解析保留在代码里,让失败的 JSON 调用回落到它上面,这样上游悄无声息的变更只会让这次运行降级,而不是结果全空。

一个终端显示第 2 页和第 3 页返回了与第 1 页相同的陈旧记录,因为等待条件绑在了表示激活状态的类名上,而不是绑在内容上
只在第二页才会暴露的失败:等待以类名的变化为准,而不是以内容为准,于是第 2 页和第 3 页交回了第 1 页的行。没有任何异常抛出,数据看上去也完全正常。

什么情况下才真的必须用真实浏览器?

当任务需要只有浏览器才能提供的条件时,真实浏览器才是正确答案:你机器上已经存在的已认证会话、只有交互后才会出现的内容、中途必须由人介入的流程,或者需要实时盯着看而不是一次性抓取的结果。除此之外,用 HTTP 加解析器都更快、更省,也更容易长期稳定运行。

无头浏览器和真实浏览器之间的取舍,见 面向 AI Agent 的无头浏览器与真实浏览器对比

这种情况下,由任务驱动的浏览器 Agent 才值得引入。 ego (lite) 是为 Agent 驱动的工作而打造的浏览器:你描述目标,它在真实浏览器里执行,包括你本来就开着的已登录会话,并提供一个可见的界面,在某个步骤需要人来判断时由你接手。对于需要真实登录态、动态页面、可见执行过程或人工交接的抓取任务,它省掉了上文说的那一整套会话接线工作。它不是 Playwright 的替代品,也不对每个站点做出承诺:那些用挑战页回应的页面,或禁止自动化访问的页面,对它来说和其他任何路线一样不可触碰。当普通 HTTP 请求或官方 API 已经能返回你需要的东西时,再引入浏览器 Agent 只会让任务更慢。

当浏览器确实成为唯一答案之后,若想更深入地比较浏览器工具链,可以看我们的 Playwright 与 Puppeteer 抓取对比详解 讲的是库本身怎么选,而 AI 网页抓取工作流 则讲 Agent 在抓取流水线中的位置。

主要有哪些挑战与限制?

这套框架里的每条路线都有无法靠工程手段消除的失效模式,提前了解它们,正是一个能跑一年的爬虫和一个只跑一周的爬虫之间的分水岭。

静态解析会在站点改版标记时失效。它没有能力察觉这种变化,所以某个字段悄悄变成 null,比直接崩溃更常见。每次运行都要校验样本,不要盲信流水线。

内部 JSON 接口会毫无预警地失效,它们是任何站点里承诺最少的部分。今天运行成功,并不能证明下个月还行。

浏览器自动化最贴近真实用户,但在规模上最脆弱。内存随并发增长,会话会过期,反爬系统针对的是行为模式而不是你的意图,所以笔记本上能用的手法,放到数据中心未必管用。

而最大的限制并不在技术层面。你被允许收集什么、多久收一次、拿到之后又能做什么,是由站点条款、robots 指令和你所在司法辖区的法律决定的,这些都不会因为代码跑通了而有任何改变。

由此得出的排查顺序很短。抓取返回空结果时,先看状态码,再看内容到底有没有出现在响应里,最后看你的选择器匹配的是渲染后的 DOM 还是源代码。这三步都走完,才应该考虑动用浏览器。

常见问题

在 Node.js 里发 HTTP 请求需要装库吗?

不需要。Node.js 把 Fetch API 作为全局对象提供,所以不安装任何东西就能用 fetch,连同 response 对象及其 ok、status、text() 成员。你真正需要的是一个解析器,因为 fetch 返回的是字符串,而用字符串匹配去处理 HTML,标记一改就会失效。

页面在浏览器里看着正常,为什么爬虫却返回空列表?

最可能的原因是页面为客户端渲染,数据从来就不在 HTTP 响应里。确认方法是对比原始响应中的可见文本与浏览器显示的内容,并统计 script 标签相对于内容的比例。如果响应就是个空壳,那就改用站点的 JSON 接口,或者用真实浏览器渲染页面。

Cheerio 能替代无头浏览器吗?

不能。Cheerio 解析 HTML,让你用选择器查询。它不执行 JavaScript,因此无法产出页面加载之后才生成的内容。它是第一条路线的正确工具,也是第三条路线的错误工具,而明明 Cheerio 够用却去上浏览器,是抓取代码里最常见的不必要开销。

怎么判断一个页面是服务端渲染还是客户端渲染?

请求这个 URL,看原始响应而不是渲染后的 DOM。如果你要的值就出现在响应正文里,说明是服务端渲染,走便宜的那条路线即可。如果正文里只有一个根元素、几包 script 和很少的文本,那内容是在浏览器里拼装出来的。

需要登录的页面怎么抓?

在浏览器上下文里登录一次,把存储状态保存到文件,之后的运行加载该状态。这个文件要当作密钥保管,并预期它会过期,同时只使用你有权使用的会话。如果目标要求绕过验证码或所有权校验,请停下来改用官方渠道。

抓取用 Playwright 还是 Puppeteer 更好?

两者都驱动真实浏览器,也能抓取同样的页面。这个决定取决于 locator 支持、等待行为、语言绑定等库层面的细节,而不取决于访问路线,我们的 Playwright 与 Puppeteer 对比。无论你选哪个,本文讲的路线决策都要先做。

一次能抓多少个页面?

先串行跑并测量,再考虑提高并发。HTTP 请求的扩展成本远低于浏览器上下文,而同一台机器上并行的浏览器大多只是在抢同一批 CPU,同时给目标站点送去一阵突发流量。做 HTTP 抓取时,同时在途四到八个、并带一个延迟,比一上来就几十个更稳妥。

收到 429 响应该怎么办?

读取 Retry-After 响应头,至少等待那么久,然后以指数退避重试,重试次数设一个较小的上限,超过就明确报错。429 是一个频率信号,不是在紧凑重试循环里硬砸过去的错误,忽视它正是 IP 地址最后进黑名单的原因。

抓取合法吗?

这取决于站点、数据、司法辖区以及你拿结果做什么。robots 指令和服务条款说明了站点的许可范围,而关于个人数据和数据库权利的规定因国家而异。本文不构成法律建议;在规模化收集任何数据之前,请先核对条款和适用法律。

什么时候该用官方 API 而不是抓取?

只要存在、且覆盖你需要的字段,就该用它。有文档的 API 是稳定的契约,通常有明确的频率限制,也不会因为页面版式变化而失效。抓取只是当数据发布在浏览器里、又没有受支持的访问途径时的兜底方案,不是默认选项。

做抓取需要浏览器 Agent 吗?

只有当任务需要真实登录态、交互后才出现的内容、可见的执行过程,或流程中途由人接管时才需要。对于能通过 HTTP 直接返回内容的页面,或已有官方 API 的站点,浏览器 Agent 只会增加成本,不会增加能力。

如果你想在一个确实需要的抓取任务上试试浏览器 Agent 路线,ego (lite) 可以免费下载,而 价格抓取SERP 抓取 两个页面则完整拆解了两个具体场景。