做数据采集的朋友应该都有过这种经历:拿requests把页面源码拉下来,再用解析库一顿操作,最后发现关键字段全是空的。这不是代码写得不对,而是现在绝大多数网页早就不是纯静态 HTML 了。数据靠 JavaScript 动态渲染,真实内容要么藏在接口里,要么等页面执行完脚本才出现在 DOM 中。Playwright 就是专门解决这类问题的工具:一套 API 驱动真实浏览器打开页面、等待异步数据加载、模拟点击和滚动,再把渲染完成后的 DOM 数据取回来。我实际用下来的感受是,它在动态渲染场景下的稳定性明显优于老牌 Selenium 方案,而且自带的自动等待机制能省掉大量“sleep 大法”,非常适合需要交互才能出数据的页面。这篇文章从原理逐步讲到实战代码,适合已经有 Python 基础、正被动态页面抓取折磨的朋友,也顺手覆盖一些新手必须知道的环境配置和避坑点。
1. 为什么普通爬虫抓不到动态渲染网页
1.1 动态渲染的本质:你以为的 HTML 只是一具空壳
先聊个基础问题。早期网页的数据是服务器拼好再发给浏览器的,HTML 里直接就有完整内容,爬虫拿到的源码和浏览器里看到的基本一致。现在的前端框架,比如 Vue、React,普遍采用客户端渲染:服务器只回传一个空的div外壳和一堆 JS 文件,真正的内容由 JS 执行后动态插入 DOM。你如果用curl或者requests去拿,得到的就是那具空壳,里面只有root节点,啥都没有。
这种模式对用户没有任何感知差异,但对爬虫是翻天覆地的变化。你面对的不再是“直接解析 HTML”,而是“要模拟一个完整的浏览器执行环境”。很多朋友卡在这一步,纠结要不要用渲染服务、要不要用无头浏览器,其实本质上就是没想明白:动态网页的数据不在响应体里,而在浏览器的运行时状态里。
1.2 为什么选 Playwright 而不是别的方案
市面上能模拟浏览器的方案不少,Selenium 是老牌,Puppeteer 是 Node 生态的,Playwright 则是后来居上。我选 Playwright 的原因很实际。
Selenium 的 WebDriver 走的是 JSON Wire Protocol,需要额外维护浏览器驱动版本,动辄因为浏览器升级导致驱动不匹配。Playwright 不需要单独装驱动,装好库之后直接一条命令下载对应版本的浏览器核心,版本是绑定的,不会出现驱动和浏览器版本对不上的尴尬。
另一个核心优势是自动等待。Selenium 写久了你会发现代码里全是time.sleep和WebDriverWait,Playwright 的定位器(Locator)自带自动重试机制,元素出现前会一直轮询,配合显式等待几乎不用手动 sleep。这一点在“数据延迟加载”场景里非常省心。
还有一个容易忽略的点:Playwright 的 API 设计更现代,支持sync和async两套接口,语法也简洁。比如page.locator('selector')的写法比 Selenium 的find_element链式调用清晰得多。后面实战代码里你会直接体会到这种差异。
2. 环境搭建与浏览器启动参数配置
2.1 安装库与下载浏览器核心
安装很简单,但有几个细节值得注意。推荐用虚拟环境,别直接装到全局,爬虫项目依赖多,后期容易乱。
mkdir dynamic_scraper cd dynamic_scraper python -m venv venv source venv/bin/activate pip install playwright playwright install chromium这里有个新手常见困惑:为什么装完playwright还要单独执行playwright install chromium。因为这库本身只是控制层,不带浏览器内核,必须显式下载对应的浏览器构建。playwright install的默认下载路径是用户目录下的缓存文件夹,没必要改,但要注意服务器环境(比如 Linux 最小化安装)经常缺系统依赖库,启动浏览器会报一堆libgtk之类的错误。真遇到的话,用playwright install-deps来安装系统依赖,这是最省事的方案。
2.2 启动参数里的门道:headless、viewport 与 UA
项目里我习惯写一个统一的浏览器启动函数。headless=True是无头模式,运行在后台不弹窗;调试阶段建议用headless=False加上slow_mo=100,能看到浏览器每一步动作,定位问题时非常有帮助。
from playwright.sync_api import sync_playwright def get_browser(p): browser = p.chromium.launch( headless=True, # 调试时改为 False slow_mo=200, # 调试时启用,放慢操作速度 args=[ "--disable-blink-features=AutomationControlled", "--no-sandbox" ] ) context = browser.new_context( viewport={"width": 1366, "height": 768}, 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" ) return browser, context这里有几个关键认知。user_agent必须设成真实浏览器的 UA,默认的自动化 UA 一眼就会被服务端识别。viewport尺寸尽可能接近普通用户,有些网站会根据窗口宽度返回不同的渲染结果。如果页面数据涉及地理位置,context里还可以设置locale和timezone_id,比如中文环境就用zh-CN和Asia/Shanghai,这样返回的文案和时间格式才是正常的。
--disable-blink-features=AutomationControlled这个参数的作用是关闭 Chromium 的自动化控制标记。很多反爬脚本会检查navigator.webdriver属性,默认情况下 Playwright 的浏览器该属性值是真,加了这个参数能从源头降低被识别概率。后面第 5 节我们再细说反检测的事。
3. 核心实操:等待策略是成败的命门
3.1 三种常用等待方式及适用场景
动态页面最大的不可控因素,是“数据什么时候渲染完”。用固定time.sleep(3)是很多新人最顺手的方式,但也是最差的方案——网络快的时候白等,网络慢的时候不够等。Playwright 提供了几种更科学的等待手段,我按使用频率排个序。
第一种,page.wait_for_selector(selector)。这个方法会一直等待某个选择器对应的元素出现在 DOM 中,适合“页面加载后在特定位置才出现数据”的场景。比如某个列表页的数据容器是div.product-list,那就:
page.goto(url) page.wait_for_selector(".product-list .item", timeout=15000) items = page.locator(".product-list .item")第二种,page.wait_for_load_state(state)。它等待页面进入某种加载状态,取值包括load、domcontentloaded、networkidle。domcontentloaded是等 HTML 解析完,load是等所有资源加载完,networkidle是等网络 500 毫秒内没有请求。后两个在动态页面中并不总是好用,因为现代页面会持续发送统计、埋点请求,可能永远等不到“空闲”。
第三种,locator自带等待。Playwright 的定位器操作会自动等待元素就绪,比如page.locator(".item").click(),它会自动等元素出现、可见、可点击。这其实是最优雅的方式,因为它把“等待”拆散到每一次具体操作里。
3.2 networkidle 的坑与自定义等待函数
networkidle我实际用下来感觉很不可靠。某次写采集任务,页面数据明明是接口返回的,但页面还在轮询某个广告位接口,几百毫秒一发,wait_until="networkidle"直接超时。后来我改成等接口响应,或者干脆用一个自定义的wait_for_function:
page.wait_for_function( "() => document.querySelectorAll('.item').length > 20", timeout=10000 )这种等待方式的思路是,你不要等“某个时间点”,而要等“某个条件达成”。对动态采集来说,条件通常是数据条数达到预期,或者某个关键元素有了指定文本。这比任何固定 time 方案都要稳。
3.3 一个完整的最小实战示例
用前面讲的内容,写一个抓取动态列表页的最小闭环。假设目标页面某个数据看板,需要通过渲染得到一个带条数的统计列表,元素结构是.data-row,每条里面还有.title和.value字段。
from playwright.sync_api import sync_playwright def scrape(): with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36" ) page = context.new_page() page.goto("https://your-target-page.example/list", wait_until="domcontentloaded") page.wait_for_selector(".data-row", timeout=20000) rows = page.locator(".data-row") total = rows.count() print(f"共找到 {total} 条数据") result = [] for i in range(total): row = rows.nth(i) title = row.locator(".title").inner_text() value = row.locator(".value").inner_text() result.append({"title": title, "value": value}) browser.close() return result if __name__ == "__main__": data = scrape() print(data)这段代码里你能看到 Playwright 的风格:定位器从父节点一路链式往下找,.nth(i)可以精确拿到第几个元素,整个过程没有任何显式 sleep。如果页面确实加载很慢,wait_for_selector的timeout兜底超时,不至于无限卡死。
4. 动态加载场景:滚动加载、点击展开与接口截获
4.1 列表无限滚动:循环滚到数据不再增加
很多信息流页面采用滚动分页,滑到底部自动加载下一页。这种场景的难点在于“什么时候停止滚动”。我的做法是设定一个“连续 N 次滚动后条数不变就结束”的逻辑。
def scrape_infinite_page(page, item_selector): last_count = 0 stable_count = 0 result = [] while stable_count < 5: page.evaluate("window.scrollTo(0, document.body.scrollHeight)") page.wait_for_timeout(1200) current = page.locator(item_selector).count() if current == last_count: stable_count += 1 else: stable_count = 0 last_count = current for i in range(last_count): row = page.locator(item_selector).nth(i) result.append(row.inner_text()) return result这里wait_for_timeout(1200)是少数几个我认为可以接受的固定等待,原因是滚动加载的触发和渲染之间有真实的物理时间,用条件等待反而不太好写。但要注意这个等待放在循环里会显著拉长总时长,建议把 1200 调成 800~1500 之间,并且在滚动结束后统一提取数据,不要在滚动中间穿插太多提取操作,否则会拖慢页面速度。
4.2 “加载更多”按钮的循环点按
有些网站不用滚动,而是放一个“加载更多”按钮,点一次就请求一批数据。这种场景要特别注意按钮状态的变化——它可能加载完消失,也可能变成禁用态,还可能文案从“加载更多”变成“没有更多了”。稳妥的写法是循环里先判断按钮可见和可用,再点击。
def click_load_more(page, btn_selector, item_selector, max_clicks=10): clicks = 0 while clicks < max_clicks: btn = page.locator(btn_selector) if not btn.is_visible(): break status = btn.get_attribute("disabled") if status is not None: break try: btn.click(timeout=5000) page.wait_for_timeout(1000) clicks += 1 except Exception: break return page.locator(item_selector).count()有个细节值得单独说:btn.click()之后最好确认一下数据量是否真的增加了。有时候按钮是假的,点一下什么都不发生,但点击本身没有报错,这时候会陷入“点击数够了但数据没拿全”的假象。加一句“每次循环后检查 item 数量是否变化”能有效避免这个问题。
4.3 更高效的方式:直接截获接口响应
如果页面数据完全来自某个 JSON 接口,那么渲染浏览器去提取 DOM 其实是绕远路。更高效的方式是用 Playwright 监听网络响应,把接口返回的 JSON 直接拿下来。这是实战里非常实用的一招。
captured_data = [] def on_response(response): if "/api/list" in response.url and response.status == 200: try: data = response.json() captured_data.extend(data.get("items", [])) except Exception: pass page.on("response", on_response) page.goto(url)这段代码的思路是:浏览器正常工作,我们不干涉它的渲染过程,只是在网络层“顺手把接口数据抄了一份”。被监听进来的数据是结构化的,比 DOM 文本再清洗要准确得多。用好这个技巧需要你先打开浏览器开发者工具,在 Network 面板里找出真正返回数据的那个 XHR 请求 URL 特征,然后在代码里用response.url.__contains__去匹配。
但是要注意,接口数据有时是分页的,一个页面会触发多个相同特征的接口请求,captured_data.extend正好把多页数据自动拼起来。另外,如果接口返回的是加密 JSON,response.json()会失败,那还是老老实实回到 DOM 提取。
5. 反爬识别、请求拦截与采集稳定性
5.1 为什么我会被识别为机器人
动态网页采集做得多了,你会发现真正“抓不到数据”的原因往往不是代码问题,而是被反爬系统盯上了。最常见的识别依据有这几个:
一是navigator.webdriver属性。自动化浏览器的这个标志默认为true,普通浏览器则显示undefined。二是浏览器指纹一致性。如果无头浏览器的User-Agent是 Windows,但navigator.platform返回的结果和 Mac 一样,这种矛盾非常容易被识别。三是行为轨迹。鼠标移动、点击间隔、滚动速度都太规律,也会触发风险策略。
Playwright 本身不提供完整的反检测方案,但我们可以通过配置降低暴露面。最直接的方式是像第 2 节那样设置真实的 UA、viewport、locale,并且用context.add_init_script隐藏自动化相关的 JS 属性。
context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); Object.defineProperty(navigator, 'languages', {get: () => ['zh-CN', 'zh']}); Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3]}); """)这里add_init_script会在页面任何脚本运行前执行,相当于给浏览器套了一层“伪装”。它不是绝对能突破所有防护,但对常规的基于 JS 特征检测的手段是有效的。
5.2 拦截图片和广告请求给抓取加速
网页加载速度直接影响采集效率,但很多资源对数据提取毫无意义,比如图片、字体、埋点脚本。Playwright 允许用page.route拦截请求,直接取消这些无用的资源加载,能明显减少等待时间。
import re BLOCK_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".woff", ".woff2", ".mp4"] def block_unused(route): url = route.request.url if any(url.endswith(ext) for ext in BLOCK_EXTENSIONS): route.abort() else: route.continue_() page.route(re.compile(r".*"), block_unused)拦截资源有一个权衡:某些懒加载页面会用图片是否加载来判断后续滚动,全部拦截可能导致页面不再触发新的接口请求。遇到这种情况,我只拦截字体文件,因为字体对数据提取完全没用,而且体积大、拖网速。
5.3 频率控制与断点续抓
爬虫最忌讳的就是高并发低延时地“扫”数据,不仅容易被封,还容易把抓取任务跑崩。我的经验是把速率控制写进任务调度里。比如每抓完一个页面,随机停 2 到 5 秒,模拟人阅读页面的节奏。
import random import time def polite_gap(): time.sleep(random.uniform(2, 5)) # 抓完一页后调用 polite_gap()如果是长时间大任务,建议把已抓取的数据持续写入本地文件或数据库,而不是最后一次性输出。这样中间任何一步挂了,都能从断点继续。简单做法是用jsonlines文件每收集一条就追加一行。
import json def save_record(path, record): with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n")6. 常见问题与排查技巧实录
6.1 高频报错对照表
我把实际开发中最常遇到的几个报错整理成一张表,方便各位直接对照排查。
| 报错信息 | 常见原因 | 排查方向 |
|---|---|---|
Timeout 30000ms exceeded | 元素或事件在指定时间内没出现 | 检查选择器是否变化,用page.wait_for_selector分步等待 |
Element is not attached to the page | 定位后在操作前 DOM 节点被重新渲染 | 立即操作元素,不要先存储对象再后续使用 |
Target page, context or browser has been closed | 页面被主动关闭或异常退出 | 确认try/finally中正确关闭浏览器,检查是否触发跳转 |
net::ERR_ABORTED | 资源拦截误伤 | 查看route.abort()的匹配规则 |
Execution context was destroyed | 页面导航导致旧上下文失效 | 导航后重新获取定位器,不重复使用旧句柄 |
Playwright install后启动报缺少系统库 | Linux 环境缺依赖 | 执行playwright install-deps |
这里面最值得多说一句的是“Element is not attached”。Playwright 的定位器虽然给了很大便利,但如果你把一个locator对象存下来,页面内容更新后再用它操作,就会报错。正确做法是每次操作前现场通过page.locator()重新获取。
6.2 几个我每天都在用的排查技巧
第一个技巧是“抓包式调试”。不要一上来就跑无头模式,先headless=False配合page.pause(),代码执行到那里会停下来,你可以在浏览器里手动检查元素结构,比瞎猜选择器效率高得多。调试完再切回无头模式。
第二个技巧是“监听 console 和 requestfailed”。页面 JS 报错和数据接口失败通常会在浏览器控制台留下痕迹,Playwright 可以把这些事件打出来,帮助定位“为什么没有数据”。
page.on("console", lambda msg: print(f"CONSOLE: {msg.text}")) page.on("requestfailed", lambda req: print(f"REQ FAIL: {req.url} {req.failure}"))第三个技巧是“截图留证”。抓取过程中偶尔会出现某次数据异常,但又无法稳定复现。这时候在关键节点调用page.screenshot(path="debug.png", full_page=True),把现场状态存下来,回头分析非常有价值。尤其适合页面异步加载异常时,一眼就能看出是渲染没完成还是数据本身为空。
第四个技巧是“合理利用存储状态”。如果某网站需要登录才能看到数据,手工登录一次后,把context.storage_state(path="login.json")保存下来。后续每次创建新上下文时加载这个文件,就能保持登录态,不需要每次跑验证码。这种方法在只要不违规的前提下可以大幅提升采集效率。
6.3 数据校验与去重
采集不是数据落盘就算完,脏数据害死人。每条数据提取后,我至少会做三类校验:字段是否为空、类型是否合理、是否有明显的截断问题。比如某个金额字段如果解析出来是空字符串,那这条记录大概率是页面还没有渲染完整就提取了,最好是丢弃,而不是硬塞进数据库。
去重也一样,动态页面在滚动加载时可能出现“上一页的最后一条数据”在下一页重复出现。用主键比对可以解决,比如内容页的 URL 或者唯一 ID。把已经处理过的 ID 放进一个set里,出现重复就跳过,这能省下大量存储和后续清洗成本。
写到最后的一些经验
平时帮朋友处理抓取需求,我最常说的一句话是:动态网页采集的技术难点不在代码,而在你对页面加载机制的理解。Playwright 只是把“模拟真实用户”这件事做得更好用,但它不解决“你想抓什么、数据从哪来、什么时候出现”这三个根问题。动手写代码之前,花十分钟打开开发者工具,把页面渲染的逻辑摸清楚,往往比调试脚本省下一两个小时。
另外也想劝一句:采集这事,工具无罪,但使用要有边界。目标网站的访问协议、平台规则,该遵守的还是要遵守。个人学习和研究用途,控制频率、控制数据量,别把写采集器变成打游击战。
这几年做采集项目,我的体会是:把浏览器自动化当成一把螺丝刀来看待,它解决的是“操作浏览器”这个通用问题,能不能拧好螺丝,看你有没有把问题想透。如果你能在页面加载、数据位置、接口规律这些基本功上多下功夫,Playwright 会成为你工具箱里非常趁手的一件工具。