霍格沃兹测试开发学社

《Python测试开发进阶训练营》(随到随学!)
2023年第2期《Python全栈开发与自动化测试班》(开班在即)
报名联系weixin/qq:2314507862

用 Playwright 打造可靠的企业级采集方案——从单机验证到集群化落地

关注 霍格沃兹软件测试开发 公众号,回复「资料」, 领取人工智能测试开发技术合集

聊正事之前先扯两句。

如果你玩过采集,大概率都经历过这个阶段:一开始写个 Playwright 脚本,点两下、滚一滚、打印个标题,觉得「真香」。然后开始爬几百个 URL,发现这玩意开始卡、崩、被封,浏览器进程变僵尸,CPU 直接打满。

我就是这么过来的。

三年前我刚接手公司数据产品团队的时候,整个采集体系就是一堆零散的 Python 脚本,散落在各个开发机和中转服务器上。今天这个脚本挂了,明天那个节点 OOM 了,运维天天被叫起来修爬虫。最夸张的一次,某个招聘网站的采集脚本跑了三天,最后发现浏览器实例没关干净,一台 64G 的机器硬生生被吃爆了。

后来我们用 Playwright 从头搭了一套方案,从单机验证一路做到 K8s 集群化部署。今天这篇文章,就是把这几年的实战经验整理出来——从“能跑”到“能撑”,中间到底要填哪些坑。

一、先泼盆冷水:单机脚本的「幻觉稳定」
先看一段代码,是不是很眼熟?

from playwright.sync_api import sync_playwright

def scrape(url):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
print(page.title())
browser.close()

scrape("https://www.example.com")
跑一下,输出正常。于是信心满满地开始爬几十、几百个 URL。

然后呢?

第一波:IP 被封,返回 403 或直接空白页。
第二波:浏览器实例一堆 zombie process,CPU 直接打满。
第三波:任务出错全盘崩溃,没有重试,没有日志,没有监控。

这玩意儿放到生产环境,撑不过一天。

问题出在哪?你把 Playwright 当 requests 用了。Playwright 是浏览器自动化工具,每个实例都是一个完整的 Chromium 进程,内存占用 150~250MB 起步。你把它放在循环里反复启动销毁,不崩才怪。

二、企业级方案长什么样
要让 Playwright 稳定跑在生产环境里,至少得具备这几个要素:

代理池:防止封禁,IP 轮换
任务队列:能重试、能分发、能去重
浏览器池:控制并发、避免内存炸裂
调度器:统一管理任务、监控执行情况
下面我会一步步拆解,从单机验证到集群化落地,每个阶段怎么搭、踩了什么坑、怎么填。

三、阶段一:单机验证——把逻辑跑通
这个阶段的目标只有一个:确认页面结构、JS 渲染逻辑、数据提取规则是否稳健。

别一上来就搞分布式,连目标页面的选择器都不稳,后面全是白搭。

我的做法是先用异步 Playwright 写一个单页采集函数,把代理配好,把字段提取逻辑写死,然后反复跑同一个页面,直到提取结果 100% 稳定。

single_playwright.py

import asyncio
from playwright.async_api import async_playwright

代理配置(示例用亿牛云)

proxy_host = "proxy.16yun.cn"
proxy_port = "3100"
proxy_user = "16YUN"
proxy_pass = "16IP"

asyncdef crawl_page(keyword: str, page_num: int = 1):
asyncwith async_playwright() as pw:
browser = await pw.chromium.launch(
headless=True,
proxy={
"server": f"http://{proxy_host}:{proxy_port}",
"username": proxy_user,
"password": proxy_pass
}
)
page = await browser.new_page()
url = f"https://example-job-site.com/search?kw={keyword}&p={page_num}"

关键:设置合理的超时和等待策略

await page.goto(url, timeout=30000)
await page.wait_for_selector('.job-card', timeout=15000)

cards = await page.query_selector_all('.job-card')
results = []
for card in cards:
title = await card.query_selector_eval('.job-title', 'el => el.textContent.trim()')
company = await card.query_selector_eval('.company', 'el => el.textContent.trim()')
salary = await card.query_selector_eval('.salary', 'el => el.textContent.trim()')
results.append({"title": title, "company": company, "salary": salary})

await browser.close()
return results
这个阶段的核心教训:

选择器要稳——别用过于具体的 CSS 路径,页面一改就废。优先用 data-* 属性或稳定的 class 组合。
等待策略要准——wait_for_selector 比 sleep 靠谱一百倍。别偷懒写 time.sleep(5),那是给自己挖坑。
代理一定要配——哪怕只跑一个页面。你永远不知道目标站点什么时候开始记恨你的 IP。
人工智能技术学习交流群
伙伴们,对AI测试、大模型评测、质量保障感兴趣吗?我们建了一个 「人工智能测试开发交流群」,专门用来探讨相关技术、分享资料、互通有无。无论你是正在实践还是好奇探索,都欢迎扫码加入,一起抱团成长!期待与你交流!👇

image

四、阶段二:加队列和重试——让脚本「扛揍」
单页逻辑稳了之后,第二个坑就来了:批量跑的时候,各种莫名其妙的失败。

超时、连接重置、429 限流、页面结构偶发变化……你会发现 10 个任务里总有那么一两个莫名其妙地挂了。

这时候你需要两样东西:任务队列和重试机制。

4.1 任务队列
别用列表。用 Redis。

import redis
import json

r = redis.StrictRedis(host='redis-master', port=6379, db=0, decode_responses=True)

生产者:往队列推任务

def push_task(url, retry=0):
r.lpush('crawl_queue', json.dumps({"url": url, "retry": retry}))

消费者:从队列取任务

def pop_task():
data = r.rpop('crawl_queue')
return json.loads(data) if data else None
Redis 做任务队列的好处是:多个 Worker 可以共享同一个队列,天然支持分布式。而且 Redis 的原子操作能保证同一个任务不会被多个 Worker 同时取走。

4.2 重试机制
重试不是无脑重试。要区分哪些错误值得重试,哪些不值得。

import asyncio
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

值得重试的异常

RETRYABLE_EXCEPTIONS = (
asyncio.TimeoutError,
ConnectionError,
# 429 限流
)

不值得重试的异常——代理认证失败、403 封禁等

FATAL_EXCEPTIONS = (
# 407 代理认证错误
# 403 禁止访问
)

@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=2, max=10),
retry=retry_if_exception_type(RETRYABLE_EXCEPTIONS)
)
asyncdef crawl_with_retry(url):
# 你的采集逻辑
pass
关键点:重试间隔用指数退避(exponential backoff),别用固定间隔。不然你重试的时候正好撞上别人的重试,大家一起把目标站点冲垮。

五、阶段三:浏览器池——管住内存
这是很多人最容易忽视的一环。

Playwright 的 browser.new_page() 和 browser.close() 看着简单,但如果你在循环里频繁创建和销毁浏览器实例,内存泄漏是迟早的事。

正确做法是复用浏览器实例和上下文。

class BrowserPool:
def init(self, max_browsers=5):
self.max_browsers = max_browsers
self.browsers = []
self._lock = asyncio.Lock()

asyncdef get_browser(self):
asyncwith self._lock:
if self.browsers:
return self.browsers.pop()
if len(self.browsers) < self.max_browsers:
pw = await async_playwright().start()
browser = await pw.chromium.launch(headless=True)
return browser
# 等待有可用浏览器
await asyncio.sleep(0.1)
returnawait self.get_browser()

asyncdef return_browser(self, browser):
asyncwith self._lock:
# 清理页面状态,但保持浏览器存活
contexts = browser.contexts
for ctx in contexts:
for page in ctx.pages:
await page.close()
self.browsers.append(browser)
池化策略的核心思想:浏览器实例是重资源,创建一次、反复使用。每个浏览器可以开多个上下文(context),每个上下文相当于一个独立的会话(含 cookie、localStorage),这样既隔离了不同任务的状态,又避免了反复启动浏览器的开销。

我们生产环境的配置是:每台机器 8 个浏览器实例,每个实例最多 4 个并发上下文,总共 32 个并发任务。内存稳定在 6-8G 左右,跑一周不重启。

六、阶段四:容器化——让部署可重复
单机方案再稳,也扛不住规模化。你得把采集单元容器化。

Dockerfile 大概长这样:

FROM mcr.microsoft.com/playwright/python:v1.47.2-jammy

WORKDIR /app
COPY . /app

RUN pip install -r requirements.txt

安装 Chromium(基础镜像已经带了,这步可省略)

RUN playwright install chromium

CMD ["python", "worker.py"]
注意:官方 Playwright 镜像 2GB+。如果只是用 Chromium,可以考虑自己构建轻量镜像,去掉 Firefox 和 WebKit。我们团队自己维护的镜像最终压到了 800MB 左右。

容器化之后,每个容器就是一个“浏览器节点”。主控端通过 Redis 队列派发任务,Worker 从队列里取任务、执行、把结果写回 MongoDB 或 ES。

七、阶段五:集群化——K8s 编排
容器化之后,集群化就水到渠成了。

我们的生产架构是这样的:

┌─────────────────────────────────────────────────────────────┐
│ K8s Cluster │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Scheduler │ │ Scheduler │ │ Scheduler │ │
│ │ (Pod) │ │ (Pod) │ │ (Pod) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Redis (Queue) │ │
│ └─────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Worker │ │ Worker │ │ Worker │ │
│ │ (Pod) │ │ (Pod) │ │ (Pod) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ MongoDB / ES │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
关键设计:

Scheduler 和 Worker 分离——Scheduler 只负责任务生产和调度,Worker 只负责执行。解耦之后,扩容 Worker 完全不需要动 Scheduler。
Redis 做任务队列和去重——用 Redis Set 做 URL 去重,用 List 做 FIFO 队列。
HPA 自动扩缩容——根据 Redis 队列长度自动调整 Worker 副本数。队列长了就加 Pod,队列空了就缩回去,省资源。
K8s 部署的 YAML 大概这样(简化版):

apiVersion: apps/v1
kind:Deployment
metadata:
name:playwright-worker
spec:
replicas:5
selector:
matchLabels:
app:playwright-worker
template:
metadata:
labels:
app:playwright-worker
spec:
containers:
-name:worker
image:your-registry/playwright-worker:latest
resources:
requests:
memory:"2Gi"
cpu:"1000m"
limits:
memory:"4Gi"
cpu:"2000m"
env:
-name:REDIS_HOST
value:"redis-service"

apiVersion:autoscaling/v2
kind:HorizontalPodAutoscaler
metadata:
name:playwright-worker-hpa
spec:
scaleTargetRef:
apiVersion:apps/v1
kind:Deployment
name:playwright-worker
minReplicas:2
maxReplicas:20
metrics:
-type:Pods
pods:
metric:
name:redis_queue_length
target:
type:AverageValue
averageValue:"10"
这套架构我们跑了快两年,日均采集 50 万+ 条数据,最稳定的时候连续三个月没出过 P0 故障。

八、几个要命的坑,提前告诉你
坑 1:内存泄漏
Playwright 的内存泄漏问题在社区里讨论了很久。我们踩过最狠的一次是 tracing 功能没关,ProtocolCallback 对象越积越多,跑了三天 OOM。

解决方案:

生产环境关掉 tracing
每个任务执行完主动 await page.close() 和 await context.close()
定期重启 Worker(我们用 K8s 的 restartPolicy 配合 CronJob 每天凌晨重启一轮)
坑 2:headless 模式被检测
现代反爬系统已经进化到了连你打字时手抖几下都能察觉的地步。默认的 headless 模式几乎是一抓一个准。

解决方案:

用 playwright-stealth 插件隐藏自动化痕迹
配置合理的 viewport、User-Agent、语言等指纹信息
必要时用有头模式(headless=False),虽然资源消耗更大,但存活率更高
坑 3:代理池不够用
单机方案里配一个代理就够了,但集群化之后,几十个 Worker 同时跑,一个代理根本扛不住。

解决方案:

接入代理池服务,每次请求从池子里随机取一个代理
按域名做代理分组,不同站点用不同的代理出口
监控代理可用率,自动剔除失效代理
坑 4:页面加载策略
默认的 page.goto() 是等 load 事件。但对于很多 SPA 应用,load 事件触发时数据还没渲染完。

解决方案:

不要用默认的 load

await page.goto(url, wait_until="networkidle") # 等网络空闲

或者配合 wait_for_selector 精准等待目标元素

await page.wait_for_selector('.data-container', timeout=15000)
九、写在最后
从单机脚本到 K8s 集群,这条路我们走了大半年。回头看,最大的体会是:

Playwright 本身只是个工具,真正的挑战在于怎么管好它。

单机验证阶段,你要搞定的是页面逻辑和数据提取。
加队列和重试,你要搞定的是任务的可靠交付。
浏览器池,你要搞定的是资源管理。
容器化和集群化,你要搞定的是部署和编排。

每一步都有各自的坑,但每一步踩完之后,系统的稳定性都会上一个台阶。

如果你现在正在从单机脚本往企业级方案演进,希望这篇文章能帮你少踩几个坑。有什么问题,欢迎在评论区交流。

推荐学习
测试智能体与智能化测试平台公开课,从Web/App/接口测试智能体,再到业务测试用例生成,爱测智能化测试平台,手把手带你掌握AI智能体与智能化测试平台!

👉 扫码进群,报名学习!

image

关于我们
霍格沃兹测试开发学社,隶属于 测吧(北京)科技有限公司,是一个面向软件测试爱好者的技术交流社区。

学社围绕现代软件测试工程体系展开,内容涵盖软件测试入门、自动化测试、性能测试、接口测试、测试开发、全栈测试,以及人工智能测试与 AI 在测试工程中的应用实践。

我们关注测试工程能力的系统化建设,包括 Python 自动化测试、Java 自动化测试、Web 与 App 自动化、持续集成与质量体系建设,同时探索 AI 驱动的测试设计、用例生成、自动化执行与质量分析方法,沉淀可复用、可落地的测试开发工程经验。

在技术社区与工程实践之外,学社还参与测试工程人才培养体系建设,面向高校提供测试实训平台与实践支持,组织开展 “火焰杯” 软件测试相关技术赛事,并探索以能力为导向的人才培养模式,包括高校学员先学习、就业后付款的实践路径。

同时,学社结合真实行业需求,为在职测试工程师与高潜学员提供名企大厂 1v1 私教服务,用于个性化能力提升与工程实践指导。

posted @ 2026-07-08 10:14  霍格沃兹测试开发学社  阅读(34)  评论(0)    收藏  举报