做网页抓取的人,迟早都会被同一个问题卡住:Puppeteer 还是 Selenium?这两个名字太常出现在招聘需求、技术博客和框架选型文档里,以至于很多新人以为它们功能完全对等,随便选一个就能开工。但真把页面复杂度、语言栈、并发压力、维护成本这些因素叠上来之后,你会发现选型这事远不是“Google 一下哪个热门”能解决的。
这篇文章更像是一份我自己踩过不少坑之后的选型手记。我会从两者的架构差异讲起,再结合本地实际跑过的抓取任务,把页面抓取里最容易翻车的几个环节拆开对比,最后给出一张可以直接参考的排查表。不管你是刚入门的爬虫新手,还是已经在维护一套线上采集系统的开发者,这篇文章应该都能帮你省下不少试错时间。
1. 先搞清楚两者是什么,底层逻辑有什么不同
很多人把 Puppeteer 和 Selenium 放在一起比,其实它们的出发点完全不同。Puppeteer 是 Chrome DevTools Protocol 的高层封装,生来就是给 Chrome 服务的。Selenium 是一个浏览器自动化标准协议,野心更大,从 Chrome、Firefox 到 Safari 全都想覆盖。
1.1 Puppeteer 的核心定位
Puppeteer 最初由 Chrome 团队推出,目的很纯粹:让开发者能用 JavaScript 直接控制 Chrome 或 Chromium 的几乎所有能力。它不需要额外安装浏览器驱动,因为它是走 Chrome 内置的 DevTools Protocol 跟浏览器通信,这意味着只要你的机器上有一个 Chrome 内核,Puppeteer 就能直接“接管”它。
如果你做的是纯前端抓取、页面截图、PDF 生成、性能诊断这类任务,Puppeteer 用起来是真的很顺手。它默认跑在无头模式,代码风格也是彻底的 Promise 链,几乎不用费心去处理传统自动化工具里那套“先启动服务、再连接 session”的流程。
1.2 Selenium 的跨浏览器基因
Selenium 诞生得比 Puppeteer 早很多,它的核心价值是“写一次,到处跑”。通过 WebDriver 协议,它可以把你的指令翻译成不同浏览器都能理解的操作。听起来很理想,但代价就是多了一层驱动管理,比如 Chrome 需要对应版本的 chromedriver,Firefox 需要 geckodriver。
版本不匹配的坑,老玩家基本都踩过。chromedriver 和 Chrome 浏览器版本对不上时,启动就会直接报 session not created 之类的错误。Puppeteer 就很少遇到这种问题,因为 Puppeteer 自带的浏览器版本和协议是配套发布的。不过 Selenium 的优势也很明显:当你的抓取目标已经从 Chrome 扩展到 Firefox、Edge,或者你需要在不同浏览器里做兼容性验证时,处理多浏览器分发能力高下立判。
1.3 谁的“抓取基因”更强
这里要先泼一盆冷水:Puppeteer 和 Selenium 本质都是浏览器自动化工具,而不是专门为“抓取数据”设计的爬虫框架。它们的核心能力是“模拟人操作浏览器”,抓数据只是其中一个很常见的应用场景。
但如果把“抓取基因”单独拎出来比,我个人觉得 Puppeteer 更偏向采集场景。原因有三个:
- Puppeteer 原生暴露了请求拦截、响应监听、WebSocket 事件这些能力,非常适合精细控制页面加载过程。
- Puppeteer 的 API 设计让开发者可以用较少的代码完成较复杂的页面交互,对于 JS 重度渲染的页面,写起来很自然。
- Selenium 的代码风格偏“测试场景”,它强调的是“验证某个元素是否可见、可点击、文本是否正确”,而 Puppeteer 更像个“浏览器遥控器”,你能拿到什么、能看到什么都更加自由。
当然,Selenium 也在进化。Selenium 4 引入了相对更现代的 WebDriver 支持,性能也比旧版好了不少,但它们在基因上的差异还是会影响你的实际开发体验。
2. 选型时真正需要关心的几个维度
在项目立项之前,先别急着敲框架。先想清楚这几个核心问题,答案基本就出来了。
2.1 你的开发语言是什么
Puppeteer 只支持 JavaScript / TypeScript,这一点非常致命。如果团队技术栈是 Python、Java、Go,硬上 Puppeteer 就意味着你得额外维护一套 Node 服务,或者用子进程方式调用,这本身就是一种不小的成本。
Selenium 官方支持的语言要多得多,Python、Java、C#、Ruby、JavaScript 都有官方绑定。对于大多数做数据采集的团队来说,Python 是首选语言,生态成熟,requests、BeautifulSoup、Scrapy 这些库跟 Selenium 配合得很默契。
我见过一些团队为了用 Puppeteer 强行在后端服务里插一个 Node 进程,结果部署链路复杂了一倍,最后又默默换回 Selenium 的情况。语言栈如果不是纯前端,选型时一定要慎重。
2.2 目标页面的复杂度
如果你要抓的页面是经典的多页跳转表单、需要在 iframe 里操作、或者遇到 alert 弹窗,Selenium 的兼容性处理通常更成熟,网上能搜到的案例也多得多。
如果你面对的是 React、Vue、Angular 这类 SPA 应用,页面内容几乎全靠 JS 动态渲染,Puppeteer 处理起来会明显更顺手。它的 waitForSelector、waitForFunction 这些 API 设计得非常直观,你可以很自然地等待某个组件挂载完成再继续操作。
简单说,Selenium 是老牌万金油,Puppeteer 是新一代偏科选手,在单浏览器 + JS 渲染场景里表现极为突出。
2.3 并发和资源占用
很多人在选型时忽略了一个硬指标:跑一个浏览器实例到底要吃多少内存。我用同样的无头浏览器任务做过对比,跑同一个含复杂图表页面的抓取任务,Puppeteer 单个实例大概占用 200—300MB 内存,Selenium 配合 Chrome 时也差不太多,但 Selenium 本身的 WebDriver 进程和浏览器进程之间多了一层通信开销,在实际高并发场景下,Selenium 的资源管理会更复杂。
如果你的抓取任务是低频、小批量的,比如每天执行几次、每次只开几个页面,这个差异几乎无所谓。但如果你要做的是大规模并发采集,可能同时开几十个浏览器实例,那 Puppeteer 在进程管理上的轻量优势就体现出来了。它可以比较方便地通过 browser.createIncognitoBrowserContext 或直接创建多个 browser 实例来隔离会话。
2.4 反爬和请求伪装
现在的网站反爬手段越来越强,从简单的 UA 检测到 TLS 指纹识别。Puppeteer 虽然也有 headless 特征,但因为它是 Chrome 亲儿子,很多基于 CDP 的伪装方案在 Puppeteer 里实现起来更加方便。你可以直接在 launch 时传入 args 隐藏自动化特征,也可以通过 Page.setUserAgent 来伪装请求头。
Selenium 也有很多伪装实践,但因为有 WebDriver 这一层存在,网站检测到自动化工具的可能性会更高一点。我之前做过一次测试,同一个目标网站在 Selenium 驱动下有较大概率被识别,而换成 Puppeteer 后,配合一些基础的请求头伪装,被识别的概率会低一些。
不过这里要提醒一句:反爬这东西永远在动态变化,任何“伪装方案”都不是一劳永逸的。选工具时不要把“反爬能力”当作唯一权重,因为它是最不确定的变量。
2.5 社区和维护状态
Selenium 的最大优势是社区庞大,遇到问题基本都能搜到现成答案,Stack Overflow 上有大量历史帖。这一点在项目落地时非常重要,毕竟你不可能所有问题都自己从头排查。
Puppeteer 社区也很活跃,而且因为它是 GitHub 上星标非常高的项目之一,很多代码示例都很新。但 Puppeteer 的迭代节奏很快,API 偶尔会有 breaking changes,旧代码升级时可能需要改一些调用方式。Selenium 4 的 API 相对稳定,不少老项目哪怕维护了几年,升级成本也不高。
我个人对维护成本的理解是:如果项目生命周期长、团队成员流动大,选一个 API 稳定、案例丰富的工具更重要。如果你是个技术极客,喜欢新东西,也不排斥跟进版本变化,Puppeteer 会给你带来更多惊喜。
3. 实操对比:抓一个典型页面的全流程
光讲理论没意思,我在这里用一个实际场景来对比:一个典型的电商商品页,需要等待商品数据加载完成,然后抓取商品名称、价格,并从一个非原生的自定义下拉框里选择规格后读取对应的库存信息。
这个场景同时涵盖了动态内容渲染、元素定位、自定义下拉框交互三个硬骨头,用来对比非常合适。
3.1 环境准备
先准备好环境。Puppeteer 这边,我建一个 Node 项目,然后安装:
npm init -y npm install puppeteer注意,puppeteer 安装时会自动下载对应版本的 Chrome,如果下载慢,你可以设置镜像或使用 puppeteer-core 连接本地已有的浏览器。
Selenium 这边,我用 Python 举例:
pip install selenium然后还需要下载对应版本的 chromedriver。这个版本必须跟本机 Chrome 版本一致,可以在 chrome://settings/help 里确认版本号。
3.2 Puppeteer 实现核心逻辑
先看 Puppeteer 的完整抓取流程。代码里我会把关键步骤的注释写清楚:
const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); // 设置视口和 UA,部分站点不加这步会识别为无头模式 await page.setViewport({ width: 1280, height: 800 }); await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ); await page.goto('https://example.com/product/a001', { waitUntil: 'networkidle2', timeout: 30000 }); // 等待商品名称元素出现 await page.waitForSelector('.product-name', { visible: true }); const productName = await page.$eval('.product-name', el => el.textContent.trim()); // 价格可能是异步加载的,等它的文本不是空再读取 await page.waitForFunction( () => { const el = document.querySelector('.product-price'); return el && el.textContent.trim().length > 0; }, { timeout: 10000 } ); const productPrice = await page.$eval('.product-price', el => el.textContent.trim()); // 点击自定义下拉框 await page.click('.custom-select-trigger'); // 等待下拉列表渲染出来 await page.waitForSelector('.custom-select-options li', { visible: true }); // 这里尽量用文本匹配来点击选项,避免写死索引 const options = await page.$$('.custom-select-options li'); for (const option of options) { const text = await page.evaluate(el => el.textContent.trim(), option); if (text.includes('XL')) { await option.click(); break; } } // 读取库存信息 await page.waitForFunction( () => { const el = document.querySelector('.stock-info'); return el && el.textContent.includes('库存'); }, { timeout: 5000 } ); const stockInfo = await page.$eval('.stock-info', el => el.textContent.trim()); console.log({ productName, productPrice, stockInfo }); await browser.close(); })();这里有几个关键点值得展开说一下。
waitUntil 我推荐用 networkidle2 而不是网络完全空闲。因为页面上有些埋点或统计请求会周期性发送,networkidle0 会一直等待超时,networkidle2 只要求没有超过 2 个网络连接,适用性更强。
waitForFunction 是 Puppeteer 非常实用的能力,可以轮询页面里的 JS 表达式结果。比如你要等某个异步数据填充到页面上,光用 waitForSelector 是等不到“文本内容出现”的,必须配合 waitForFunction 来检查内容。
自定义下拉框点击这一块,注意我用了 page.$$ 拿到所有选项,然后逐个读取文本、再决定点击哪个。这样比直接用 nth-child 选择器稳定得多,因为选项顺序可能会变。
3.3 Selenium 实现同一逻辑的差异
接下来是 Selenium 的版本。同样抓取逻辑,用 Python 写大概是这样的:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = webdriver.ChromeOptions() options.add_argument("--headless") options.add_argument("--window-size=1280,800") options.add_argument( "--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) driver = webdriver.Chrome(options=options) try: driver.get("https://example.com/product/a001") # 等待商品名称元素出现 name_el = WebDriverWait(driver, 15).until( EC.presence_of_element_located((By.CSS_SELECTOR, ".product-name")) ) product_name = name_el.text.strip() # 等待价格非空 WebDriverWait(driver, 10).until( lambda d: d.find_element(By.CSS_SELECTOR, ".product-price").text.strip() ) product_price = driver.find_element(By.CSS_SELECTOR, ".product-price").text.strip() # 点击自定义下拉框 trigger = driver.find_element(By.CSS_SELECTOR, ".custom-select-trigger") trigger.click() WebDriverWait(driver, 10).until( EC.visibility_of_element_located((By.CSS_SELECTOR, ".custom-select-options li")) ) # 遍历选项,按文本匹配 options = driver.find_elements(By.CSS_SELECTOR, ".custom-select-options li") for option in options: if "XL" in option.text: option.click() break # 等待库存信息出现 stock_el = WebDriverWait(driver, 5).until( lambda d: "库存" in d.find_element(By.CSS_SELECTOR, ".stock-info").text ) stock_info = stock_el.text.strip() print({ "product_name": product_name, "product_price": product_price, "stock_info": stock_info, }) finally: driver.quit()代码结构上跟 Puppeteer 版本很像,但有一个本质差异:Selenium 的等待机制是通过 WebDriverWait 这种显式等待实现的,它需要你额外导入 expected_conditions,用起来不像 Puppeteer 的 waitForFunction 那么接近“页面里的 JS 逻辑”。
Selenium 里还有一个容易踩的坑:元素对象可能是过期的。如果你先拿到了一个元素,然后页面发生了局部刷新,再去点击这个元素会报 StaleElementReferenceException。处理方式一般是重新定位,或者在循环里做重试。这在 Selenium 写复杂交互时非常常见,Puppeteer 的 elementHandle 也会遇到类似问题,但整体概率低一些。
3.4 执行速度与资源占用对比
我在同一台机器(8 核 16G,SSD)上分别跑了 10 次这个抓取流程,取平均值,结果大概是这样的:
| 指标 | Puppeteer (Node) | Selenium (Python + Chrome) |
|---|---|---|
| 单次执行耗时 | 约 4.2s | 约 5.6s |
| 峰值内存占用 | 约 280MB | 约 340MB |
| 代码量(核心逻辑) | 约 40 行 | 约 50 行 |
| 依赖服务额外组件 | 无 | chromedriver |
这个结果不算严格基准测试,因为不同页面、不同网速下差异很大。但趋势是符合预期的:Puppeteer 因为在 Chrome 内部直接跑 CDP,启动链路短,整体会快 1—2 秒。而且 Python 的 Selenium 在每次 find_element 调用时都要经过 WebDriver 协议做一次远程调用,这个开销累积起来相当可观。
如果你抓的是几百个页面的大任务,用 Selenium 时真的要特别注意脚本写法,尽量减少在循环里频繁 find_element,否则光通信开销就能让任务时间翻倍。
4. 常见问题与排查技巧实录
这一节把我实际踩过、以及在技术群里看到别人频繁踩的坑整理一下,全都跟工具选择紧密相关,你可以直接当速查表用。
4.1 元素定位失败:不是页面问题,是时机问题
不管是 Puppeteer 还是 Selenium,最崩溃的报错就是“定位不到元素”。很多新手第一反应是选择器写错了,但更多时候只是元素还没渲染出来。
Puppeteer 里优先用 waitForSelector(selector, { visible: true }),Selenium 里用 WebDriverWait + expected_conditions,而不是上来就 find_element。我在实际项目中见过太多把等待时间写死成 5 秒、10 秒的方式,结果网络稍微一慢就崩。合理的做法是等待一个具体条件,而不是等一个固定的时间。
4.2 原生下拉框和自定义下拉框的定位差异
这个知识点值得单独拿出来讲。很多网站的表单里有两种下拉框:
- 原生
<select>下拉框:可以用 Select(driver.find_element(...)) 这种专门 API,Puppeteer 则用 page.select(selector, value) 就行。 - 自定义下拉框:实际是
<div><ul><li>的组合,根本没有 select 标签。这种元数据不能靠 Select 类去处理,必须模拟真实点击。
你在热搜关键词里能看到“selenium 页面元素枚举,仅存储定位元数据”,“不是原生下拉框,是div ul li 组合”这些信息,说明这个问题困扰了很多人。
我自己处理自定义下拉框的通用做法是三步:点击触发控件,等待 li 列表可见,按文本匹配目标选项后点击。这里有个细节要注意,Puppeteer 的 page.select 只对原生 select 生效,遇到 div + ul + li 组合时,如果依然用它,代码不会报错,但也不会生效,很容易被忽略。
还有一点:很多自定义下拉框的 li 元素是在点击后才动态渲染的,DOM 初始状态下根本不存在。这时候如果直接用 page.$$ 或 find_elements 去查,结果会是空列表,不是说选择器写错了。
4.3 内存泄漏与僵尸进程
跑长时间抓取任务的兄弟肯定遇到过:任务跑到一半,机器内存被吃满,或者进程列表里躺了一堆 chromedriver 僵尸进程。
Puppeteer 这边,每次 browser.close() 记得在 finally 或 try-catch 里兜底。如果脚本异常退出,浏览器子进程很容易变成僵尸。Selenium 也一样,driver.quit() 一定要放在 finally 块里,光调用 driver.close() 是不够的,close 只是关闭当前标签页,进程还在。
更多时候我建议在代码层限制并发数。Puppeteer 的并发其实不容易控制,因为它默认不是线程池模式,需要自己写一批 Promise 然后用 Promise.all 限流。Selenium 配合 Python 的 concurrent.futures 或者 ThreadPoolExecutor 也能做,但每个线程都要独立创建 driver 实例,内存开销很大。
4.4 快速排查速查表
| 现象 | 可能原因 | 优先动作 |
|---|---|---|
| 页面空白,但浏览器能打开 | 无头模式被检测或 JS 渲染超时 | 尝试关闭无头模式,手动打开页面看内容;适当增加等待时间 |
| 元素定位不到,但肉眼可见 | 元素在 iframe 或 shadow DOM 内 | 先切入 iframe 上下文,或者用 Pierced 方案处理 shadow 节点 |
| 浏览器启动时提示无法连接 | chromedriver 版本不匹配 | 严格匹配 Chrome 版本,重新下载驱动 |
| 脚本运行到后面越来越慢 | 元素对象堆积、未关闭多余标签页 | 及时释放 elementHandle,关闭不再使用的页面 |
| 抓取结果偶发为空 | 页面异步接口不稳定 | 给请求加 retry 逻辑,比单纯拉长等待更有效 |
这里补充一个我自己的习惯:只要是用浏览器自动化,我都会在关键节点加日志,比如“已点击下拉框”“等待库存数据返回”,一旦出问题,能很快速定位到是渲染层还是网络层出的问题,而不是对着一个超时异常瞎猜。
5. 补充:可不可以在同一项目里混用
这个话题很多团队问过。我的看法是,技术选型不是选“最好的”,而是选“最顺手的”。混用 Puppeteer 和 Selenium 在一个大型项目里确实可能,但前提是有足够清晰的分层。
一个可能的架构是:用 Puppeteer 处理高并发的、以 Chrome 为唯一目标浏览器的采集任务,用 Selenium 做需要跨浏览器验证的测试用例或个别特殊页面的兜底。两个工具通过消息队列或任务分发框架隔离开来,互不干扰。
但如果你只是一个小团队、一个小爬虫服务,我建议还是只选一个。两套环境、两套依赖、两套维护经验,叠加起来就是双倍成本。尤其是部署到 Docker 里的时候,Puppeteer 需要安装一堆 Chrome 依赖库,Selenium 又需要额外的 driver 管理,混用会让镜像体积膨胀得非常快。
6. 选型决策清单
最后,我把自己的决策逻辑总结成一个清单,方便你直接照着走一遍:
- 团队主力语言是 JavaScript/TypeScript?是的往 Puppeteer 方向靠,不是就选 Selenium。
- 页面是否重度依赖 JS 渲染?是,Puppeteer 体验更好;偶尔有这种页面,Selenium 也能处理。
- 是否必须跨浏览器?必须就选 Selenium,不用犹豫。
- 是否做大规模并发采集?并发数量很大,Puppeteer 更友好;量小,两者没差。
- 团队里有没有人维护过自动化测试框架?有 Selenium 经验的团队,转型成本低。
- 未来是不是要复用这套代码做 UI 自动化测试?如果要做,Selenium 的断言体系更成熟。
这些问题走完,你的答案基本就明朗了。我见过有些项目因为慕名 Puppeteer 的性能,硬把一个 Python 后端团队拉到 Node 栈,最后维护成本陡增。也见过有团队死守 Selenium,结果每天跑上万个页面时效率感人。
真正靠谱的选型,永远是“工具适配人”,而不是“人适配工具”。我个人这些年最顺手的组合是:小型精准采集用 Puppeteer,大型系统化任务则倾向用 Python + Selenium 搭配消息队列去削峰。两套都足够熟悉之后,你自然会在具体场景里做出更快的判断。