Node.js 浏览器引擎 + Python 大脑:Playwright 混合架构爬虫系统深度解析
当 requests 拿不到数据、Selenium 跑不动并发时这套架构救了我一个让我失眠的夜晚去年秋天接了一个电商价格监控的项目甲方要求实时抓取日本雅虎拍卖的商品数据——当前出价、剩余时间、竞拍人数延迟不能超过 5 秒。一开始我想得挺简单requests BeautifulSoup半小时写完跑起来一看——页面一片空白。原因不复杂雅虎拍卖的商品页是典型的 SPA单页应用价格和倒计时全部通过 JavaScript 异步加载。requests 拿到的只是一个空壳 HTML真正的数据根本不在里面。换 Selenium我试了。开了 20 个浏览器实例机器 CPU 直接飙到 96%内存暴涨跑不到 20 分钟容器就 OOM 崩了。甲方还在催我差点想跑路。后来折腾出来的这套方案就是今天想跟大家聊的——Node.js 做浏览器引擎Python 做大脑的 Playwright 混合架构。为什么要搞混合架构直接说结论纯 Python Playwright 不是不能跑但在高并发场景下资源开销是个大问题。Playwright 本身是多语言支持的——Python、Java、.NET、Node.js 都能用。但有一个细节很多人没注意到Node.js 版本的 Playwright 不会像 Python 版本那样为每个浏览器窗口生成一个新进程因此在管理多个标签页时CPU 和内存开销要小得多。什么意思呢简单说——Python Playwright每个浏览器上下文会启动独立的子进程开 20 个实例基本就把机器榨干了Node.js Playwright事件驱动模型同一个进程里可以高效管理多个浏览器实例所以我的思路很直接用 Node.js 专门管浏览器体力活用 Python 做调度和数据处理脑力活 。两者通过 REST API 通信。架构长什么样整体拆成三层┌─────────────────────────────────────────────────┐│ Python 调度层 ││ 任务队列 → 调用渲染接口 → 解析数据 → 落库 │└────────────────────┬────────────────────────────┘│ REST API / JSON┌────────────────────▼────────────────────────────┐│ Node.js 浏览器服务层 ││ 浏览器池管理 → 页面渲染 → 执行 JS 交互 │└────────────────────┬────────────────────────────┘│ CDP 协议┌────────────────────▼────────────────────────────┐│ 无头 Chromium 实例 │└─────────────────────────────────────────────────┘这个分层的好处很明显浏览器服务可以独立扩容Python 调度器完全不用关心浏览器怎么跑。想加并发多部署几个 Node.js 服务实例就行。Node.js 浏览器服务核心代码浏览器服务我用 Express 搭了一个 HTTP 接口核心是维护一个浏览器实例池const express require(‘express’);const { chromium } require(‘playwright’);const app express();app.use(express.json());const PORT 3000;const browserPool {};const MAX_BROWSERS 5;asyncfunction getBrowserInstance(id) {if (!browserPool[id]) {browserPool[id] await chromium.launch({headless: true,args: [‘–no-sandbox’]});}return browserPool[id];}app.post(‘/render’, async (req, res) {const { url, js_actions, session_id ‘default’ } req.body;try {const browser await getBrowserInstance(session_id);const context await browser.newContext({userAgent: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36’});const page await context.newPage();await page.goto(url, { waitUntil: networkidle, timeout: 60000 }); // 执行自定义 JS 操作点击、滚动等 for (const action of js_actions || []) { if (action.type click) { await page.click(action.selector); await page.waitForTimeout(2000); } elseif (action.type scroll) { await page.evaluate(() window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(1000); } } const content await page.content(); const screenshot await page.screenshot({ fullPage: true }); await context.close(); res.json({ success: true, html: content, screenshot: screenshot.toString(base64) }); } catch (error) { res.status(500).json({ success: false, error: error.message }); }});app.listen(PORT, () console.log(Browser service running on port ${PORT}));踩坑提醒浏览器实例池的大小要根据机器配置来调。我实测 8 核 16G 的机器每个浏览器进程大概吃 150~250MB 内存MAX_BROWSERS 设到 5 是比较稳妥的。想跑更多上容器拆分。Python 调度器大脑怎么指挥手脚Python 这边就简单多了——发 HTTP 请求让 Node.js 去干活拿到 HTML 后用 BeautifulSoup 解析import requestsfrom bs4 import BeautifulSoupimport timeBROWSER_API “http://localhost:3000/render”def fetch_rendered_page(url, actionsNone):payload {“url”: url,“js_actions”: actions or [],“session_id”: “default”}resp requests.post(BROWSER_API, jsonpayload, timeout120)if resp.status_code 200:data resp.json()if data.get(‘success’):return data.get(‘html’)returnNonedef parse_product_data(html):soup BeautifulSoup(html, ‘html.parser’)# 这里写具体的解析逻辑price soup.select_one(‘.product-price’)title soup.select_one(‘.product-title’)return {‘price’: price.text.strip() if price elseNone,‘title’: title.text.strip() if title elseNone}调度循环urls [‘https://example.com/item/1’, ‘https://example.com/item/2’]for url in urls:html fetch_rendered_page(url, [{‘type’: ‘scroll’}, # 先滚动加载更多{‘type’: ‘click’, ‘selector’: ‘.load-more’} # 再点加载按钮])if html:data parse_product_data(html)# 存数据库…time.sleep(2) # 礼貌爬虫控制频率关键点js_actions 这个设计让 Python 可以灵活控制浏览器的行为——滚动、点击、输入全都能远程指挥。这样就算页面逻辑再复杂Python 调度层也不需要改动只需调整 actions 参数就行。反爬怎么办三层防御这套架构跑通之后第二个问题来了雅虎的反爬不是吃素的。我踩过的坑包括IP 封禁、自动化特征检测、请求频率限制。解决方案分三层第一层指纹伪装普通的 Playwright 跑的是 Chromium 标准构建版本反爬系统早就把特征研究透了——哪些 JavaScript API 行为和真实 Chrome 不一样、Canvas 渲染有什么细微差异、WebGL 特征码对不上全都有档案。最基本的伪装browser await p.chromium.launch(headlessTrue,args[‘–disable-blink-featuresAutomationControlled’] # 关掉自动化标记)context await browser.new_context(user_agent‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36’,viewport{‘width’: 1920, ‘height’: 1080})更狠的可以用 playwright-stealth 这类库来抹掉自动化痕迹。但注意老版的 playwright-stealth 已经停更了现在推荐用 pw-stealth-enhanced。第二层代理轮换高并发请求下固定出口 IP 很快就会被封。我的做法是给每个浏览器会话分配独立代理proxies get_proxy_from_pool() # 从代理池取一个context await browser.new_context(proxy{‘server’: f’http://{proxies[“host”]}:{proxies[“port”]},‘username’: proxies[‘user’],‘password’: proxies[‘pass’]})第三层行为模拟很多反爬系统会分析鼠标轨迹、点击节奏这些行为特征。纯粹的无头浏览器很容易被识破。解决方案是引入 humanization-playwright 这类库模拟贝塞尔曲线的鼠标移动、随机延迟等人类行为。这三层叠起来我的采集系统现在已经稳定跑了 3 个月日均采集超 10 万条数据。异步改造从串行到并发一开始我用的是同步模式一个一个爬效率惨不忍睹。后来改成了 asyncio Playwright 异步模式import asynciofrom playwright.async_api import async_playwrightasyncdef fetch_page(url, proxyNone):asyncwith async_playwright() as p:browser await p.chromium.launch(headlessTrue)context await browser.new_context(proxyproxy)page await context.new_page()await page.goto(url, wait_until‘networkidle’)content await page.content()await browser.close()return contentasyncdef main():urls [‘url1’, ‘url2’, ‘url3’, …]semaphore asyncio.Semaphore(10) # 限制并发数asyncdef fetch_with_limit(url): asyncwith semaphore: returnawait fetch_page(url) results await asyncio.gather(*[fetch_with_limit(url) for url in urls]) # 处理结果...asyncio.run(main())注意并发数不是越大越好。我实测 10 个并发是比较舒服的数字再高就容易触发反爬或者把机器搞崩。用 asyncio.Semaphore 做限流是基本操作。这套架构的适用场景折腾完这套东西之后我总结了一下它最适合的场景SPA 单页应用React/Vue 渲染的页面requests 完全拿不到数据的那种需要交互操作的采集要点击、滚动、登录才能看到完整内容的页面高并发的动态渲染需求纯 Python Playwright 扛不住并发的时候不适合的场景静态页面、简单的 API 接口采集——杀鸡用牛刀了requests 一把梭更高效。最后说几句这套架构我从立项到稳定运行大概花了三周时间。最大的感悟是不要在一个技术栈里死磕合适的事情交给合适的工具。Node.js 的 Playwright 管浏览器就是比 Python 版本省资源这是架构层面的优势不是写代码能弥补的。Python 做调度和数据处理就是方便生态丰富。两者结合起来各取所长。如果你也在被 SPA 爬虫折磨或者正愁 Python Playwright 跑不动并发不妨试试这个思路。