做过几年爬虫的人都有同感:真正让人头疼的往往不是请求代码本身,而是目标页面压根不把数据放在静态HTML里。你打开浏览器,商品价格、评论内容、列表分页一样不少,用常规HTTP库拉回来却只剩下一个空壳,关键字段全丢在一大段压缩过的JavaScript里。这种场景遇到一次两次还能通过找接口硬解,遇到多了就明白,与其逆向对方的前端逻辑,不如直接换一个能“打开浏览器执行页面”的方案。Crawlee里的PlaywrightCrawler,就是专门解决这类动态渲染抓取问题的一个开箱即用组件。
Crawlee是Apify团队开源的一个Node.js爬虫框架,PlaywrightCrawler是其中基于Playwright浏览器自动化能力的一类爬虫实现。它把Playwright的完整页面渲染能力,和Crawlee自带的请求队列、并发控制、结果存储、失败重试这些调度能力整合在一起,你只需要专注写每一个页面的业务逻辑,至于哪些URL已经抓过、失败了要不要重试、并发开多少合适,框架都会替你管理。这篇文章适合两类人:一类是必须抓取JS渲染页面、又不想从零搭一套浏览器控制体系的开发者;另一类是已经有爬虫基础、想把自己的脚本升级成带队列和断点续爬能力的人。跟着文章走一遍,十分钟内应该能跑起来第一个可用的爬虫。
1. PlaywrightCrawler到底解决什么问题
1.1 静态请求与浏览器渲染之间的差距
很多没有接触过浏览器自动化的人会想:我直接用requests请求目标URL,拿回来解析HTML不就行了?这个思路在十年前很成立,现在越来越不成立了。如今的页面,尤其是电商、内容平台、数据中台这类应用,普遍采用前后端分离架构。页面上看到的内容,很多都是JavaScript在浏览器端动态请求后端接口之后渲染出来的。普通HTTP库执行不了JavaScript,自然拿不到最终DOM。
用一个生活化的类比:静态请求像是你站在餐厅门口看菜单牌子,菜单上写着“本店特色菜请进店咨询”;浏览器自动化则是你真的进店坐下来,把服务员叫过来问清楚今天有什么菜、多少钱。PlaywrightCrawler就是那个替你“进店坐下”的工具,它会启动一个真实的Chromium浏览器,加载页面、执行脚本、等异步接口返回,然后把你需要的数据从渲染完成后的DOM里提取出来。
这种做法的好处是省去了逆向前端接口的功夫。有些SPA应用每次渲染要发十几个接口,参数经过多层加密签名,纯靠静态分析接口费时费力。用浏览器方案,相当于把一个黑箱问题变成一个白箱问题:页面长什么样,你就能抓到什么。
1.2 哪些项目适合用PlaywrightCrawler
不是所有爬虫场景都需要浏览器自动化,选错方案会让资源消耗变得很夸张。根据我的经验,下面这些场景优先考虑PlaywrightCrawler:
- 页面内容依赖JavaScript异步加载,需要等待接口返回后DOM才完整
- 需要模拟真实的用户操作,比如点击按钮、滚动加载、鼠标悬浮后出现的内容
- 需要保持登录态访问多个页面
- 目标框架(如React、Vue、Angular)渲染了大量嵌套组件,纯HTML解析无法拿到结构化数据
反过来,如果目标页面本身是服务端渲染,数据直接内嵌在HTML里,那用Crawlee里的CheerioCrawler反而更合适,它不需要启动浏览器,速度快、内存占用小。Crawlee把这几种爬虫放在同一个框架下,切换成本不高,这也是我推荐从PlaywrightCrawler上手的原因:以后遇到简单页面,改个类名和提取逻辑就能切换过去。
| 对比维度 | 普通HTTP静态爬虫 | PlaywrightCrawler |
|---|---|---|
| 页面渲染 | 不执行JavaScript,拿静态HTML | 启动真实浏览器,执行全部脚本 |
| 数据完整度 | 低频数据容易缺失 | 完整渲染后提取,信息量大 |
| 资源开销 | 低,几百并发轻轻松松 | 高,一个浏览器实例占几百MB内存 |
| 开发速度 | 需要接口逆向或预处理 | 直接按页面写选择器,速度快 |
| 适用页面 | 服务端渲染为主 | 各种复杂前端项目 |
2. 环境准备与项目初始化
2.1 需要提前准备什么
PlaywrightCrawler基于Node.js生态,所以首要条件是本地有Node.js环境。我建议直接上Node.js 20 LTS版本,太老的版本会在模块和异步特性上给你添麻烦。安装完Node.js之后,还要确保npm可用,这个通常跟着Node一起装好了。
然后是浏览器内核。Playwright本身不自带Chromium下载结果,你需要执行一次安装命令,把Chromium下载到本地缓存目录。这一步比较看网络环境,如果下载慢或者中断,可以换用国内镜像变量,设置完再重新执行安装。打开终端输入:
npm init -y npm install crawlee playwright npx playwright install chromium第三条命令会下载Chromium浏览器内核。如果你本机已经有Chrome浏览器,也可以不下载Chromium,直接在启动参数里配置launchOptions: { channel: 'chrome' },让Playwright用你本机的Chrome来驱动,省去下载步骤。不过条件允许的话还是推荐下载专用的Chromium,版本匹配度更高,调试时也更可控。
2.2 用官方脚手架快速建项目
手动初始化项目虽然也能跑,但Crawlee官方提供了一套脚手架工具,会直接生成一个包含示例爬虫、基础目录结构和调试脚本的完整工程,省掉很多重复劳动。执行:
npx crawlee create my-playwright-demo命令运行后会进入交互式选择界面,列出几种项目模板。这里选择Playwright模板。创建完成后进入目录,安装依赖并启动开发模式:
cd my-playwright-demo npm install npm run start:devstart:dev脚本内部会调用Node配合调试模式运行主入口文件。如果一切正常,你会看到终端里输出了爬虫的运行日志,示例爬虫会访问几个预设URL,把抓到的标题写入本地存储。看到这个输出,说明环境已经畅通了。
2.3 项目结构和关键文件说明
脚手架生成的目录结构很规整,核心部分是这些:
src/main.js:爬虫主入口,所有的爬取逻辑都写在里面storage/datasets/default:抓取结果默认写入的本地目录,JSON格式storage/request_queues/default:待抓取URL队列,支持持久化package.json:项目依赖和脚本定义
storage目录是Crawlee的灵魂所在。它把请求队列和抓取结果都持久化到了本地文件系统,这意味着即使程序中途崩溃或者你手动停掉,下次重新运行时会自动从上次断掉的地方继续,已经抓过的URL不会重复请求。这种断点续爬能力,在长任务里特别有用,也是我后来一直用Crawlee而不是自己手写队列管理的重要原因。
3. 核心运行机制与关键配置项
3.1 一次完整爬取的流转链路
理解PlaywrightCrawler的运行机制,关键在于抓住一条数据流转链路,大致是这样:
- 你把初始URL通过
crawler.run()传给框架 - 框架把这些URL写入RequestQueue(请求队列)
- 调度器按照配置好的并发数,从队列里取出URL
- 每个URL被分配给一个浏览器页面实例,
requestHandler回调被执行 - 你在回调里写业务逻辑:定位元素、提取数据、决定是否把新链接加入队列
- 提取的数据通过
Dataset.pushData()写入结果存储 - 队列清空,爬虫结束,或者达到
maxRequestsPerCrawl上限后停止
这个过程中,框架自动完成了URL去重、并发池管理、失败重试、结果持久化。你写的只有第5步的页面逻辑。初看可能觉得有点抽象,但跑过一次就能体会,这种设计让你把精力放在“这个页面我要取什么字段”上,而不是“这个任务到底该怎么调度”。
3.2 值得先吃透的配置参数
Crawlee的配置项很多,但第一次上手只需要关注这几个核心参数,它们的优先级最高:
| 参数名 | 默认值 | 作用说明 |
|---|---|---|
maxConcurrency | 100 | 同时打开的浏览器页面数量,调太大会吃满内存 |
maxRequestsPerCrawl | 100 | 整个爬虫生命周期内最多处理多少个请求,防止失控 |
maxRequestRetries | 3 | 单个请求失败后最多重试次数 |
headless | true | 是否以无头模式运行浏览器,调试时建议设为false |
navigationTimeoutSecs | 60 | 页面导航超时时间 |
requestHandlerTimeoutSecs | 30 | 每个请求回调的执行超时时间 |
useSessionPool | true | 是否启用会话池,用于隔离Cookie和登录状态 |
我的建议是:第一次调试务必把headless改成false,亲眼看到浏览器在你面前打开页面,能极大减少“为什么元素找不到”这类问题的排查时间。只要别忘了一起设置小一点的maxConcurrency,比如2到3,否则屏幕上一堆浏览器同时打开反而看不清。
3.3 为什么默认帮你做了去重和断点续爬
Crawlee的RequestQueue会给每个URL生成一个uniqueKey,默认就是URL本身。队列在写入新请求时会检查这个key是否已存在,已存在就跳过。这种设计不仅避免重复请求,也能保证如果你在爬取过程中,某个页面提取出几十个链接,其中大部分可能已经在队列里了,框架不会重复消费。
断点续爬的原理类似。队列状态被定期持久化到storage/request_queues/default目录,每次运行结束或中断,当前未处理的URL列表都会保留。下次启动时,框架读取磁盘上的队列继续执行。这个能力对于需要跑几个小时的采集任务来说几乎是必备的,手动实现一轮至少小半天。
4. 实操:十分钟跑通列表页抓取
4.1 目标选择和字段设计
写代码前,先找一个允许抓取且页面结构清晰的目标。我用一个公开的演示书店站点来做示范,它的列表页包含了书名、价格、评分、库存状态这些典型字段,非常适合入门。你可以用同一个思路迁移到任何自己想抓的站点。
这次要抓的字段有三个:书名、价格、库存状态。数据结构设计如下:
{ "title": "A Light in the Attic", "price": "£51.77", "availability": "In stock", "url": "https://xxx/books/toscrape/catalogue/a-light-in-the-attic_1000/index.html" }字段本身很简单,但有一个细节值得注意:我特意把url也存了下来。爬虫后期如果要回溯数据、关联详情、排查异常,URL是最可靠的主键。
4.2 完整代码逐段拆解
新建src/main.js,把下面这份代码放进去:
import { PlaywrightCrawler, Dataset } from 'crawlee'; const crawler = new PlaywrightCrawler({ maxConcurrency: 5, maxRequestsPerCrawl: 20, headless: true, async requestHandler({ page, request, enqueueLinks, log }) { log.info(`正在处理: ${request.url}`); // 等待列表容器出现 await page.waitForSelector('.product_pod'); // 提取列表页上所有书籍的基础信息 const books = await page.$$eval('.product_pod', (nodes) => { return nodes.map((node) => { const title = node.querySelector('h3 a')?.getAttribute('title') || ''; const price = node.querySelector('.price_color')?.textContent?.trim() || ''; const availability = node.querySelector('.availability')?.textContent?.trim() || ''; return { title, price, availability }; }); }); // 写入数据集 await Dataset.pushData({ url: request.url, books, }); // 抓取下一页 await enqueueLinks({ selector: '.next a', label: 'PAGINATION', }); }, // 请求失败时打印原因,方便排查 async failedHandler({ request, log }) { log.error(`请求失败,已重试多次: ${request.url},原因: ${request.errorMessages[0]}`); }, }); await crawler.run(['https://xxx/books/toscrape/']);这段代码的逻辑很直白:
waitForSelector让爬虫在列表容器渲染完成前不会急着取数据,避免空结果$$eval在页面上下文里执行原生DOM查询,返回一个经过序列化的数组,性能比逐个page.$eval要高enqueueLinks从页面上找到翻页按钮的链接,自动加入队列- 一旦
maxRequestsPerCrawl达到20次,爬虫会主动停止,防止本地测试时跑太多
4.3 运行日志与结果验证
运行npm run start:dev,你会看到终端滚动输出类似这样的日志:
1200 - 正在处理: https://xxx/books/toscrape/ 1200 - 正在处理: https://xxx/books/toscrape/catalogue/page-2.html ...最后打开storage/datasets/default,能看到每个请求一个JSON文件。数据写出的路径也可以通过Dataset.pushData的datasetId参数自定义。如果你希望把所有结果汇总成一个方便查看的Excel或CSV,可以用一条很短的Node脚本完成:
npx crawlee dataset export combined --format csvCrawlee自带数据集导出能力,不支持输出CSV,还支持JSON、JSONL、Excel等格式。这个功能在实际交付项目时非常实用,尤其是后端同学要求“直接把一份CSV发给我”的场景。
5. 进阶实操:详情页抓取与请求标签分发
5.1 用label给请求打标签
列表页的提取只是入门。真实项目里,列表页往往只有简略信息,更多细节在详情页里。PlaywrightCrawler支持给每个入队链接打上label标签,然后在requestHandler里通过request.label判断当前页面是列表页还是详情页,从而执行不同的提取逻辑。这个能力是我个人认为Crawlee最实用的设计之一,它让一个爬虫同时处理多种页面成为可能。
看这段代码,在列表页的enqueueLinks里指定详情页链接:
await enqueueLinks({ selector: '.product_pod h3 a', label: 'BOOK_DETAIL', });然后修改requestHandler的开头:
async requestHandler({ page, request, enqueueLinks, log }) { if (request.label === 'BOOK_DETAIL') { // 详情页处理逻辑 const description = await page.locator('#product_description + p').textContent(); const rating = await page.locator('.star-rating').getAttribute('class'); await Dataset.pushData({ url: request.url, description: description.trim(), rating: rating.replace('star-rating', '').trim(), }); return; } // 列表页处理逻辑,和之前一样 }这其实是一种简单的状态机模式:列表页的任务是“采集摘要+分发详情链接”,详情页的任务是“采集完整字段”。一旦学会用label组织爬虫,处理再复杂的站点也能把逻辑拆得清清楚楚。
5.2 把列表页和详情页的数据关联起来
这里有一个实际项目中很容易忽略的问题:列表页推送一条数据,详情页又推送一条数据,两批数据里都有同一个URL,但字段分散在两处。如果直接交给业务方,对方会一头雾水。
我的做法是在两边的pushData里都带上url字段,结束之后用一个合并脚本,以URL为关联键把数据拼起来。比如列表页有title、price、availability,详情页有description、rating,合并后一条记录包含全部字段。这种“先分散抓取、后集中合并”的设计,比在同一个请求里既抓列表又进详情要稳健得多。因为详情页请求可能失败重试,重新执行时不必重复抓取列表页的数据。
import { Dataset } from 'crawlee'; // 合并逻辑可以单独成文件,读入两个数据集后按url join const rows = await Dataset.getData(); const mergedMap = new Map(); for (const row of rows.items) { if (!mergedMap.has(row.url)) { mergedMap.set(row.url, {}); } Object.assign(mergedMap.get(row.url), row); } console.log([...mergedMap.values()]);这样处理之后,哪怕爬虫跑到一半崩溃,只要两个数据集都有持久化,重新跑一个合并脚本就能恢复数据。
5.3 控制详情页的请求节奏
进入详情页之后,请求量通常是列表页的几十倍。如果不加控制,很容易打爆目标服务器,也会让你自己的IP被限制。PlaywrightCrawler的maxConcurrency能限制并发数,但更细粒度的节奏控制要用Crawlee的RateLimit模块。
实际代码可以这样写:
import { RateLimit } from 'crawlee'; const rateLimit = new RateLimit({ maxCallsPerSec: 5 }); async requestHandler({ page, request, log }) { await rateLimit.getToken(); // 获取一个许可,拿不到就等待 // 后续请求处理 }maxCallsPerSec设为5,意味着每秒钟最多处理5个详情页请求。这个速率对大多数中小型站点都比较友好,既能保证采集速度,又不至于给对方服务器造成压力。如果目标站点对访问频率特别敏感,可以进一步降到2或者1。同时请求之间加一个随机延迟,也能有效降低被风控识别为机器的概率。
6. 反爬规避与细节体验
6.1 请求头与浏览器指纹的默认策略
PlaywrightCrawler启动的是真实Chromium,所以它在TLS指纹、JavaScript执行环境这些层面与普通HTTP库完全不同,天然就更接近真实浏览器。Crawlee还会默认给每个会话提供与Chrome一致的User-Agent。大部分基础防护在这种组合下不会触发。
如果你遇到目标站点对User-Agent校验特别严格的情况,可以在创建爬虫时手动指定:
const crawler = new PlaywrightCrawler({ launchContext: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', }, });不过我通常不建议在入门阶段花太多时间在指纹伪装上。先把业务逻辑跑通,等真的遇到风控拦截再回来调。提前把所有反爬手段都加上,只会让调试难度成倍上升。
6.2 登录态和会话池管理
很多数据藏在登录之后。PlaywrightCrawler里管理登录态有两种常见方式。一种是直接把已有的Cookie传给浏览器上下文,适合你已经手动登录过一次、拿到了Cookie字符串的场景。另一种是用Crawlee的会话池,让框架自动为每次请求分配隔离的会话环境。
会话池的用法很简单,在配置里打开开关并设置预创建的会话数量:
const crawler = new PlaywrightCrawler({ useSessionPool: true, sessionsPreCreation: 2, });会话池的好处是,如果某个会话在请求过程中触发了风控,框架可以检测到异常响应并自动切换到新的会话。配合失败重试机制,整体采集稳定性会有明显提升。登录流程本身依然要你实现:先在requestHandler里判断是否出现登录页,如果是就执行登录操作,再把登录后的状态交给会话池管理。
6.3 处理滚动加载和延迟渲染
现在很多列表页不再是传统的分页,而是滚动到底部自动加载更多。PlaywrightCrawler处理这种场景很容易,只要你让页面执行滚动逻辑。一个通用做法是:
await page.evaluate(async () => { for (let i = 0; i < 10; i++) { window.scrollBy(0, window.innerHeight); await new Promise((resolve) => setTimeout(resolve, 500)); } });这段代码模拟用户连续向下滚动十次,每次间隔500毫秒。滚动结束后,页面底部的内容才会出现在DOM里。之后再用常规选择器提取数据即可。注意滚动次数不要写死得太夸张,否则会白白增加等待时间。我一般会根据目标页面的数据量先手动观察几次,再确定一个合理的滚动次数。
7. 常见问题与排查技巧实录
7.1 元素一直找不到
这是PlaywrightCrawler新手遇到最多的问题,也是最容易让心态崩溃的问题。通常原因有三个:第一个是页面没渲染完成,你提取的时候DOM里还没有目标元素;第二个是元素在iframe里,外层选择器永远定位不到;第三个是选择器写得不够健壮,页面class名带了动态哈希。
我的排查步骤是固定的:先把headless改成false,让浏览器跑起来,在requestHandler里加一行await page.pause()。这行代码会像调试器一样暂停浏览器,你可以直接打开开发者工具去确认元素到底长什么样。等到选择器在开发者工具里能正确高亮之后,再把暂停代码删掉,重新以无头模式运行。
7.2 请求超时或者状态码异常
爬虫跑着跑着,突然连续出现超时,或者收到的状态码不是200,大概率是触发了站点的访问频率限制。不要急着加代理、换指纹,先把并发数和请求频率降下来。我会把maxConcurrency从5降到2,把maxCallsPerSec从5降到2,通常能解决大部分问题。
如果降速之后仍然超时,再检查目标站点是不是临时故障。可以在failedHandler里把错误信息打印出来,那些信息会明确告诉你超时发生在导航阶段、等待阶段还是执行脚本阶段。根据阶段再去调整对应的超时配置,比盲猜有效得多。
7.3 内存占用越来越大,跑一会儿就卡死
浏览器自动化框架本身就吃内存,跑久了内存涨是正常的。但如果涨到卡死,通常是你没有控制并发,或者页面没关闭导致的资源泄漏。PlaywrightCrawler理论上会在每个请求处理完后关闭页面,但某些页面上有定时器、WebSocket连接,可能导致页面无法顺利回收。遇到这种情况,先降低maxConcurrency,再试试在requestHandler结束时主动关闭多余资源:
async requestHandler({ page }) { // ...业务逻辑 const contexts = page.context().pages(); if (contexts.length > 1) { await contexts[1].close(); } }实际项目中,把maxConcurrency控制在10以内,很少会遇到内存问题。如果采集量确实很大,可以考虑分批次运行,每次跑完之后释放进程,比试图用一个进程跑完全部请求更稳。
7.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 抓到的字段为空 | 页面未渲染完成 | 增加waitForSelector或延时 |
| 导航一直转圈 | 网络超时或被限流 | 降低并发、增大超时时间 |
| 请求全部失败 | Cookie失效或IP受限 | 检查会话池配置、降速重试 |
| 克隆页面太多 | 页面跳转链路有误 | 在requestHandler里检查跳转逻辑 |
| 程序启动报浏览器找不到 | 浏览器内核未安装 | 执行npx playwright install chromium |
8. 从零散脚本到规范爬虫工程的一些经验
8.1 把解析逻辑从回调里拆出来
requestHandler回调很容易膨胀成几百行的巨型函数,尤其是同时处理列表页和详情页的时候。我后来习惯在项目里单独建一个parsers目录,每个页面类型放一个解析文件,爬虫主文件只负责分发请求和调用解析器。这样改了某个页面的选择器,不需要在爬虫逻辑里大海捞针。
如果想把这条路走得更极致,可以把数据校验也独立出来。抓回来的数据先做一层字段格式校验,不合格的直接标记为异常数据,而不是让它混进最终结果。尤其是价格、日期这类需要后续计算的字段,少一个脏数据能省很多麻烦。
8.2 定时运行和增量抓取怎么做
PlaywrightCrawler本身不提供定时能力,但与其配套的Crawlee存储是支持增量的。你可以把爬虫打包成一个独立的Node脚本,再配合系统的定时任务或者CI调度,让它每天在固定时间跑一次。因为RequestQueue会做去重,第二次运行时已经抓过的URL不会被重复处理。只有新出现的链接才会进入队列。
增量抓取需要注意一个问题:目标站点可能会更新已有页面的内容,比如商品价格变了、评论新增了。如果只靠队列去重,这些更新会被漏掉。我的做法是对于需要持续监控的页面,强制让它们重新入队,设置优先级,这样每次运行都能重新抓取一遍核心页面。一个简单的实现是在初始化时用requestQueue.addRequest分别控制URL的uniqueKey和url,让某些URL绕过默认去重。
8.3 个人最推荐的一个调试习惯
最后分享一个我踩过几次坑之后养成的小习惯。每次新写一个爬虫,我第一件事不是直接全量跑,而是先用一个很小的maxRequestsPerCrawl限制到1,比如:
maxRequestsPerCrawl: 1,单独跑一次,确认列表页的提取结果正确,再放大量级去跑全站。这一步能帮你把90%的选择器错误、字段缺失问题扼杀在早期。很多人喜欢一上来就全量跑,跑完一看数据全空,再回头慢慢调试,反而浪费了大量时间。
另外,如果条件允许,尽量给自己的爬虫任务加上结果校验环节。比如抓完1000条数据后,检查一下有没有字段大面积为空,如果有,可以触发告警或者自动停止。一个稳定的爬虫一定不是写了就不管,更像是一门需要持续维护的活儿。把校验和监控前置,后面维护时才知道哪里出了问题。