Playwright实战:动态页面解析与数据去重脱敏完整指南
2026/9/8 8:03:21 网站建设 项目流程

1. 为什么从 Selenium 切换到 Playwright?谈谈我在真实项目里的感受

先说个背景。我之前有三年多时间一直在用 Selenium 做 Web 自动化,从最早的 PhantomJS 时代一路用到 ChromeDriver + Selenium 4,熟悉的同学都懂那套配置驱动的繁琐。直到去年接了一个动态页面采集的活,要抓一个前端渲染很重的数据平台,页面里全是异步加载、弹窗、嵌套 iframe,Selenium 写起来不仅代码量大,而且动不动就报元素找不到、点击不上、等待超时。最崩溃的是同一套脚本在本地能跑,部署到服务器上就各种环境问题。后来试了试 Playwright,一个下午重构完成,跑了一周连一次因等待导致的失败都没有,那个反差让我决定彻底换赛道。

Playwright 是微软开源的一套浏览器自动化框架,核心卖点不只是“能控制浏览器”,而是它把“页面在做什么、页面什么时候稳定”这件事封装得很聪明。自动等待机制是它和 Selenium 最本质的区别。Selenium 里你要自己去搞显式等待、隐式等待、sleep 三件套,时间短了不稳定,长了又拖慢整体速度。而在 Playwright 里,几乎所有操作都有内置的智能等待:点击按钮前它会自动等待元素可见、可操作;填写表单前会自动等待输入框可编辑;甚至页面在进行网络请求时,某些操作会自动等待响应完成。这不是“猜一个固定时间”,而是框架自己判断条件满足,快的时候毫秒级,慢的时候也不会提前乱点。

另一个让 Selenium 派感到舒服的点是隔离环境。Playwright 的 BrowserContext 概念有点类似一个独立的用户会话档,每个 context 拥有独立的存储、Cookie、缓存、UA。我可以在一条脚本里同时打开几个 context,比如一个登录态账号、一个游客身份、一个带特定地区 IP 的代理环境,互相之间完全隔离,彻底不串数据。这在抓取需要多账号切换的数据时特别好用,不用反复启动浏览器进程。

还有一点是 Playwright 自带的 Trace Viewer 和 codegen。codegen 可以直接录制你手工在浏览器里的操作,自动生成可复用的 Python/JS 代码,省去写选择器的时间。Trace Viewer 则能记录整个执行过程的截图、网络请求、控制台日志、DOM 快照,出问题时可以非常直观地回溯。我在实际项目中基本靠这两个工具把排错时间缩短了至少一半。

这篇博文我会以一个完整的动态页面解析实战为主线,手把手讲清楚怎么落地一个带数据去重和数据脱敏的采集程序。说句实在话,网上讲 Playwright 基础用法的文章一抓一大把,但真正把解析、去重、脱敏整合到工程代码里的不多。我会把我踩过的坑、试错过程中的经验一并放进来,尽量让你照着一抄就能用。

适合看这篇内容的人,我大概分三类:第一类是被 Selenium 各种超时和等待折磨过的爬虫开发者;第二类是刚接触动态页面解析,想找一套稳定方案的新手;第三类是已经在用 Playwright 但想优化数据质量和数据合规性的朋友。环境方面我用 Python 3.10 和 Playwright 1.40 版本做演示,更老的版本也能跑通,只是 API 上有些小差异,我会在对应位置标注。

2. 动态页面解析的核心思路与方案选型

2.1 动态页面到底难在哪

所谓动态页面,指的是页面主体内容并不是存在于初始 HTML 源码里,而是由 JavaScript 在浏览器里执行后动态渲染出来的。这类页面的数据可能要经历“请求 HTML -> 执行 JS -> 触发异步接口 -> 渲染 DOM -> 用户可见”这么一条链路。传统那种直接 requests 抓 HTML 再正则提取的方式,在动态页面上基本行不通,因为你拿到的 HTML 只是个空壳。

Selenium 解决这个问题的方式是暴力且有效的:让浏览器真的去打开页面,人工等待一段时间,然后从渲染好的 DOM 里取数据。但现实里的动态页面远比“等 3 秒”要复杂。很多单页应用是分批渲染的,先是骨架屏,然后再请求列表数据,再请求详情数据,中间还可能穿插图片懒加载。你在等待的时候,根本不确定页面到底渲染到哪一步了。

Playwright 面对这类问题的思路是提供多层次的等待策略。最常用的是等待选择器出现、等待某个元素包含特定文本、等待网络请求完成。这意味着你可以明确表达“我要等的不是时间,而是条件”。比如你要等一个列表渲染完成,通常可以等列表里第一个元素出现,再配合等待网络空闲,基本就能覆盖绝大多数场景。

2.2 技术选型:为什么我推荐 Playwright 而不是纯 requests + 接口逆向

有些朋友会问,既然动态页面最终也是通过接口拿数据,那我直接抓接口不行吗?速度还快。我不否认接口抓取在很多场景下效率更高,但它有几个天然痛点:接口参数可能带加密签名、可能带时间戳校验、可能需要先执行一段 JS 生成 token、可能对请求头或 Cookie 做绑定。每次反爬策略升级,你的接口逆向代码几乎要重写,维护成本非常高。

相比之下,Playwright 走的是“以浏览器为代理”的路线。你不需要理解接口层面的加密逻辑,只要页面在真实浏览器里能正常展示数据,你的脚本就能拿到数据。加密逻辑由页面自己执行,你只需要等待它执行完,然后摘取结果。这种方式牺牲了一些性能,但换来了极高的稳定性和开发效率。

按实际经验,在反爬强度中等偏上的网站上,接口逆向方案的平均维护周期大概以“周”为单位,而 Playwright 方案可以做到“月”级别不调整。我用过一段时间之后,最大的体感就是省心。

2.3 整体架构设计

这套采集程序我建议分成四个模块,各司其职:

  • 采集调度模块:负责控制浏览器的创建、页面的打开、翻页逻辑、异常重试。
  • 内容解析模块:从渲染好的页面里提取结构化数据,统一转成字典格式。
  • 去重模块:判断本条数据之前是否已采集过,避免重复入库。
  • 脱敏模块:在落库或输出之前,把敏感字段处理成合规格式。

模块之间通过简单的函数调用衔接,不引入复杂的消息队列或框架。因为采集程序的瓶颈通常在浏览器并发和页面加载上,业务逻辑没必要设计得过于重。代码结构清晰、容易调试才是第一位的。

3. 环境准备与基础用法速通

3.1 安装与浏览器初始化

安装 Playwright 非常直接,两步走。

pip install playwright playwright install chromium

第一行是装 Python 库,第二行是下载 Playwright 打包好的 Chromium 浏览器内核。注意这一步它会下载一个独立的 Chromium,而不是直接用你系统里的 Chrome,好处是版本和 API 完全兼容,不会出现驱动版本不匹配的问题。这一点当年用 Selenium 的时候真的折腾过太多次了,Chrome 自动升级之后 Chromedriver 就罢工,Playwright 这种自带浏览器的方案直接从根上解决了。

另外如果你是离线服务器环境,playwright install 下载的浏览器会被缓存到用户目录,具体路径在 linux 上是 ~/.cache/ms-playwright,Windows 上在 %USERPROFILE%\AppData\Local\ms-playwright。把整个目录打包拷到目标机器,再设置环境变量 PLAYWRIGHT_BROWSERS_PATH 指向它,就能实现离线安装,不需要外网也能用。

初始化浏览器的代码我一般这样写:

from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch( headless=False, # 调试时设为 False,正式跑可以改 True args=[ "--disable-blink-features=AutomationControlled", "--start-maximized", ] ) context = browser.new_context( viewport={"width": 1280, "height": 800}, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...", locale="zh-CN", ) page = context.new_page() page.goto("https://example.com", wait_until="networkidle")

这里有个容易忽略的点:playwright 启动浏览器默认并不是最大化窗口,需要用 new_context 时指定 viewport 尺寸。如果页面布局是响应式的,不同 viewport 下拿到的元素可见性可能不一样。我习惯固定 viewport 为 1280x800,保证每次采集时页面渲染结果一致,避免随机布局导致的选择器不稳定。

3.2 sync API 与 async API 怎么选

Playwright 同时提供了同步和异步两套 API。sync API 写起来更直观,普通脚本、快速原型、中小型采集任务完全够用;async API 适合需要高并发、大量浏览器实例同时跑的场景。我日常写采集脚本用 sync 居多,不是为了别的,就是因为同步代码逻辑顺序和人类思维一致,出了问题好排查。如果将来要做分布式采集,再改写成 async 也不复杂,核心 API 参数几乎是一样的。

3.3 元素定位的实用姿势

定位元素是动态页面解析里最频繁的操作。Playwright 的定位器和 Selenium 的 find_element 类似,但有一个重要差异:Playwright 的 locator 不是“瞬间查找然后固定元素”,而是一个“延迟定位器”,它会在你执行操作的那一刻才去页面里找目标。这个机制天然避开了“元素还没渲染完就去找”的问题,配合 auto-wait 用起来非常顺手。

常用定位方式我整理成一张表:

定位方式写法示例适用场景
CSS 选择器page.locator("div.card .title")通用场景,性能最好
文本定位page.get_by_text("下一页")按钮、链接、标签
角色定位page.get_by_role("button", name="确定")无障碍语义清晰的元素
占位符定位page.get_by_placeholder("请输入关键词")输入框
标签定位page.get_by_label("用户名")表单控件
XPathpage.locator("xpath=//div[@class='item']")结构复杂的兜底方案

我的建议是优先用 CSS 和 get_by_role,实在搞不定再上 XPath。别一上来就截图复制 xpath,那是纯纯的体力活,页面稍微改一点就废了。

另外有个经验:在多元素并列的场景里,locator.nth(index) 可以用来精确定位第几个元素,locator.filter(has_text="关键字") 可以用来过滤特定内容。这些组合用熟了之后,复杂页面也能很快写出稳的定位式。

4. 实测一个真实场景:解析动态列表页并翻页

为了演示,我模拟一个典型场景:一个新闻列表页,内容全部由前端渲染,列表项会显示标题、发布时间、点击量和摘要信息,页面底部的“加载更多”按钮需要点击后才会加载下一页数据。这个场景基本涵盖动态页面解析的主要难点。

4.1 等待列表数据加载

打开页面后,第一步不能急着提取,而是等数据真实渲染出来。我推荐用显式等待一个“标志性元素”,这个元素通常是列表中的第一条数据。代码是这样:

page.goto(url, wait_until="domcontentloaded") # 等待列表首条元素出现 page.locator("div.news-item").first.wait_for(state="visible", timeout=10000) # 若页面有骨架屏,可以额外等待骨架屏消失 skeleton = page.locator(".skeleton") if skeleton.count() > 0: skeleton.first.wait_for(state="hidden", timeout=10000)

wait_for(state="visible") 表示等待元素从隐藏变成可见;wait_for(state="hidden") 则表示等待元素消失。后者用来处理骨架屏或 loading 动画特别有效。

这里有个我没有直接使用 wait_until="networkidle" 的原因:networkidle 在网络环境复杂时等待的时间会非常长,某些页面存在轮询请求会导致它永远等不到空闲状态。除非页面很干净,否则我更推荐 domcontentloaded 配合局部等待,速度和稳定性都能兼顾。

4.2 解析列表项数据

列表项一般结构相似,用 locator 一次性拿到所有卡片,然后逐个解析。Playwright 的 locator 支持批量处理,用 all() 方法拿到所有匹配元素的句柄,然后再从每个句柄里继续查子元素。

items = page.locator("div.news-item").all() for idx, item in enumerate(items, 1): title_el = item.locator(".title") time_el = item.locator(".time") read_el = item.locator(".read-num") summary_el = item.locator(".summary") title = title_el.inner_text().strip() publish_time = time_el.get_attribute("data-time").strip() read_count = read_el.inner_text().strip() summary = summary_el.inner_text().strip() print(f"[{idx}] {title} | {publish_time} | {read_count}")

为什么要用 get_attribute 而不是直接取文本?因为很多前端框架(Vue、React)会把时间戳存在属性里,页面上展示的只是格式化后的文本。取属性相当于拿到了原始数据,后面统一处理时更灵活。

还有一个点:locator.all() 拿到的元素列表,只是定位一批元素,并不要求在那一刻全部渲染完毕。这里我再强调一次,Playwright 的 locator 是“活”的引用,执行 inner_text 或 get_attribute 时才真正到页面去取。所以即使某一两条数据加载慢一点,也都等到了帧稳定才返回,基本不会出现空指针。

4.3 翻页与循环采集

动态列表的翻页分为两种。一种是传统的分页组件,底部有页码,直接点击页码即可;另一种是“加载更多”按钮,点击后在列表末尾追加新数据。两种方式在 Playwright 里处理起来都很简单。

以“加载更多”为例,核心逻辑是不断点击按钮、等待新数据出现、继续抓取,直到按钮不可见或数据条数不再增加。

seen_total_before = 0 MAX_PAGES = 50 for page_no in range(1, MAX_PAGES + 1): items = page.locator("div.news-item").all() total_now = len(items) print(f"第 {page_no} 页,当前累计 {total_now} 条") if total_now <= seen_total_before: print("数据条数未增加,可能已到底") break # 提取当前页的新数据 for item in items[seen_total_before:]: # 处理单条数据... pass seen_total_before = total_now # 尝试点击加载更多 load_more = page.locator("button.load-more") if load_more.count() == 0 or not load_more.is_visible(): print("没有加载更多按钮,结束翻页") break # 点击前记录按钮文本,用于判断是否加载中 btn_text = load_more.inner_text().strip() load_more.click() # 等待新列表项出现,且数量大于当前 try: page.wait_for_function( "document.querySelectorAll('div.news-item').length > " + str(total_now), timeout=8000, ) except Exception: print("等待新数据超时,可能已到底或页面异常") break

用 wait_for_function 等个数增加比固定 sleep 要优雅得多。它是直接执行一段 JS 表达式,每帧都会评估,返回 true 才继续。这里的 8 秒超时根据实际网络情况调整,如果页面响应慢就放宽,但也不建议设太长,否则出故障时会卡很久。

还有个细节,点击之后按钮可能变成“加载中”并禁用,这时候若马上执行下一次点击会和当前加载产生冲突。稳妥的做法是等待按钮重新变成可点击,或者等待新数据渲染后再走下一轮。

4.4 数据字段清洗

列表页解析出来的是原始字符串,直接入库会带着各种乱七八糟的空白符、换行符、单位后缀。我习惯在解析模块里做一层标准化的清洗,比如:

import re def clean_text(value: str) -> str: value = re.sub(r"\s+", " ", value) # 合并空白字符 return value.strip() def parse_count(value: str) -> int: digits = re.sub(r"\D", "", value) return int(digits) if digits else 0

清洗逻辑不复杂,但非常实用。很多公司在数据质检环节才发现“标题里有换行符”“阅读量带了个‘次’字”这种问题,源头就是解析阶段没做规范化。宁可解析时多写几行,也别给下游埋雷。

5. 数据去重:从最简单的 Set 到耗时可控的持久化方案

采集程序跑起来之后,另一个常见问题就是重复数据。原因可能是翻页逻辑重复点击、网站接口偶发返回重复列表、程序中断后重新启动又从第一页开始抓。数据量小的时候用集合就能解决,但真正上生产必须做持久化去重。下面我按复杂度从低到高讲三种方案,你可以按需选。

5.1 内存去重:适合单次任务、数据量在百万以下

如果采集任务是一次性脚本,数据量不太大,直接用 Python 的 set 就能满足需求。核心思路是对某条数据的唯一标识做哈希,判断是否已存在于集合中。

seen: set[str] = set() ARTICLE_UNIQUE_FIELD = "title" def is_duplicate(record: dict, key_field: str = ARTICLE_UNIQUE_FIELD) -> bool: identity = record[key_field] if identity in seen: return True seen.add(identity) return False

这个方案的优点是代码简单、查找速度是 O(1),缺点也明显:任务一重启内存就清空,第二次运行会重新采集全部数据;数据量上到千万级别,内存占用会明显上涨。所以它适合“一次跑完、跑完即弃”的场景。

5.2 数据库持久化去重:支持增量采集的正确打开方式

只要你的采集任务需要长期运行,或需要增量采集,就必须把去重标记写到磁盘或数据库里。我的习惯是用 SQLite,因为它零部署、单文件、查询效率足够,完全不需要单独的服务。关键做法是给唯一字段建唯一索引,让数据库自己挡住重复数据。

import sqlite3 DB_PATH = "news_articles.db" def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, publish_time TEXT, read_count INTEGER, summary TEXT ) """) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_article_title ON articles(title)") conn.commit() return conn def try_insert_article(conn, record: dict) -> bool: try: conn.execute( "INSERT INTO articles(title, publish_time, read_count, summary) VALUES (?, ?, ?, ?)", (record["title"], record["publish_time"], record["read_count"], record["summary"]), ) conn.commit() return True except sqlite3.IntegrityError: return False

这里用唯一索引做“天然去重”的好处很明显:即使你的 Python 代码有并发问题、或者同时在多个 context 里跑任务,数据库层面也会拦住重复写入。这是比“先查再插”更稳妥的方案,因为“先查再插”在并发场景下存在竞态条件,两条线程可能同时查不到然后同时插入,最终还是会出现重复数据。

对于数据量更大的场景,把 SQLite 换成 MySQL 或 PostgreSQL 是一样的思路,无非是连接方式变了、SQL 略有区别。核心就一句话:把去重下沉到数据库的约束机制里,比在应用层花钱花精力要划算得多。

5.3 布隆过滤器:误判可控的极致内存方案

当数据量极大,比如上亿条 URL 需要去重,磁盘查询一样会成为瓶颈。这时候可以引入布隆过滤器。它是一个基于位数组和多个哈希函数的概率数据结构,能告诉你“一定不存在”或“可能存在”。所谓“可能存在”,就是它有一定误判率,会把未插入的数据误判为已存在,但不会把已存在的数据误判为不存在。

Python 里可以用 redis 的 setbit 实现,也可以直接用 pybloom_live 库。我以 pybloom_live 为例写个最小实现:

from pybloom_live import BloomFilter # 预期插入 100 万条数据,误判率 0.01% bloom = BloomFilter(capacity=1000000, error_rate=0.0001) def is_duplicate_url(url: str) -> bool: if url in bloom: return True bloom.add(url) return False

布隆过滤器适合的场景是重复数据占比较高、并且你能接受极小概率的误伤。如果你要求 100% 准确,那还是乖乖用数据库唯一索引。我在实际项目中通常会把布隆过滤器和数据库结合使用:先过布隆,快速挡掉绝大多数已知数据;不确定的再查数据库。用最小的成本解决最大的流量,布隆过滤器占的内存也远小于存全量 URL 列表。

6. 数据脱敏:上线合规采集的必修课

讲完去重,接下来谈脱敏。近几年数据合规要求越来越严,我们采集到的数据里经常包含手机号、邮箱、身份证号这类个人信息。如果直接把原始数据写进日志、数据库或第三方接口,很容易踩到合规红线。所以脱敏不是一个“可选项”,而是生产环境的基本功。

6.1 哪些字段需要脱敏

常见需要处理的敏感字段大致有这些:

  • 手机号:保留前 3 位和后 4 位,中间用星号替代。
  • 邮箱:保留首字母和域名后缀,用户名部分做部分遮蔽。
  • 身份证号:保留前 6 位和后 4 位,其余打码。
  • IP 地址:保留前三段或只保留前两段。
  • 密钥/Token:只显示前几位用于区分,其余一律打码。
  • 住址:省市区保留,详细街道隐藏。

6.2 通用脱敏函数的实现

我写了一个小工具模块,里面封装了常见的脱敏函数。注意脱敏的目标是“仍能识别数据形态,但无法还原真实信息”,所以不是简单把字符串替换成固定文本,而是要根据字段类型选择不同的遮蔽规则。

import re def mask_phone(phone: str) -> str: """手机号:138****5678""" return re.sub(r"(\d{3})\d{4}(\d{4})", r"\1****\2", phone) def mask_email(email: str) -> str: """邮箱:zh***@example.com""" name, domain = email.split("@") if len(name) <= 1: masked_name = "***" else: masked_name = name[0] + "*" * (len(name) - 2) + name[-1] return f"{masked_name}@{domain}" def mask_id_card(id_card: str) -> str: """身份证号:110***********1234""" return re.sub(r"(\d{6})\d{8}(\d{4})", r"\1********\2", id_card) def mask_ip(ip: str) -> str: parts = ip.split(".") if len(parts) == 4: return f"{parts[0]}.{parts[1]}.*.*" return "***" def mask_token(token: str) -> str: if len(token) <= 8: return "***" return token[:4] + "****" + token[-4:]

这些函数都遵循一个共同原则:保留足够的信息用于排查和对账,但去除完整的敏感内容。以手机号为例,保留前 3 位可以粗略判断运营商归属地,保留后 4 位方便数据方核验是否是同一个人,中间的 4 位是隐私重灾区,必须打码。

6.3 在采集链路里如何优雅地接入脱敏

脱敏不应该散落在各处业务代码里,更建议做成一个统一的方法,在数据落地前最后一个环节统一处理。比如我在解析完单条数据后,会走一条清洗函数链:

def sanitize_record(record: dict) -> dict: record["phone"] = mask_phone(record["phone"]) if record.get("phone") else record.get("phone") record["email"] = mask_email(record["email"]) if record.get("email") else record.get("email") record["id_card"] = mask_id_card(record["id_card"]) if record.get("id_card") else record.get("id_card") record["ip"] = mask_ip(record["ip"]) if record.get("ip") else record.get("ip") return record

这样做的好处是,无论未来数据来源是页面解析、接口返回还是手工录入,只要在统一出口过一遍 sanitize_record,合规口径就不会出现遗漏。我甚至还在这个函数里加了一层字段白名单机制:不属于采集需求的字段直接丢弃,只保留定义过的字段入库。这样做既省存储空间,又能天然屏蔽掉一些意外的敏感数据。

7. 完整项目代码串讲:Playwright + 去重 + 脱敏一把梭

前面四个章节铺垫了各种知识点,现在我把它们整合成一个可直接运行的完整示例。这个示例模拟了一个动态列表页,做了翻页、数据解析、去重入库、脱敏输出。你可以把它当脚手架,换成自己的真实目标页面。

import re import sqlite3 from datetime import datetime from playwright.sync_api import sync_playwright # ---------- 配置区 ---------- TARGET_URL = "https://example-news.com/list" MAX_PAGES = 20 DB_PATH = "articles.db" HEADLESS = True # --------------------------- # ---------- 清洗函数 ---------- def clean_text(value: str) -> str: return re.sub(r"\s+", " ", value).strip() def parse_count(value: str) -> int: digits = re.sub(r"\D", "", value) return int(digits) if digits else 0 # ---------- 脱敏函数 ---------- def mask_phone(phone: str) -> str: return re.sub(r"(\d{3})\d{4}(\d{4})", r"\1****\2", phone) def mask_email(email: str) -> str: name, domain = email.split("@") masked_name = name[0] + "*" * (len(name) - 2) + name[-1] if len(name) > 1 else "***" return f"{masked_name}@{domain}" def sanitize_record(record: dict) -> dict: if record.get("phone"): record["phone"] = mask_phone(record["phone"]) if record.get("email"): record["email"] = mask_email(record["email"]) return record # ---------- 数据库 ---------- conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, publish_time TEXT, read_count INTEGER, summary TEXT, phone TEXT, email TEXT ) """) conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_title ON articles(title)") conn.commit() def insert_article(record: dict) -> bool: try: conn.execute( "INSERT INTO articles(title, publish_time, read_count, summary, phone, email) VALUES (?, ?, ?, ?, ?, ?)", ( record["title"], record["publish_time"], record["read_count"], record["summary"], record.get("phone", ""), record.get("email", ""), ), ) conn.commit() return True except sqlite3.IntegrityError: return False def deduplicate_and_save(record: dict) -> bool: sanitized = sanitize_record(record) return insert_article(sanitized) # ---------- 抓取主流程 ---------- def crawl(): with sync_playwright() as p: browser = p.chromium.launch(headless=HEADLESS, args=["--disable-blink-features=AutomationControlled"]) context = browser.new_context( viewport={"width": 1280, "height": 800}, locale="zh-CN", ) page = context.new_page() page.goto(TARGET_URL, wait_until="domcontentloaded") # 等待列表出现 page.locator("div.news-item").first.wait_for(state="visible", timeout=15000) seen_before = 0 inserted_count = 0 duplicate_count = 0 for page_no in range(1, MAX_PAGES + 1): items = page.locator("div.news-item").all() total_now = len(items) print(f"[第{page_no}轮] 列表累计 {total_now} 条") if total_now <= seen_before: print("没有新数据,停止翻页") break for item in items[seen_before:]: record = { "title": clean_text(item.locator(".title").inner_text()), "publish_time": item.locator(".time").get_attribute("data-time") or "", "read_count": parse_count(item.locator(".read-num").inner_text()), "summary": clean_text(item.locator(".summary").inner_text()), } # 从详情中提取手机号和邮箱(示意) detail_text = clean_text(item.locator(".detail").inner_text()) phone_match = re.search(r"1[3-9]\d{9}", detail_text) email_match = re.search(r"[\w.+-]+@[\w-]+\.[\w.]+", detail_text) if phone_match: record["phone"] = phone_match.group(0) if email_match: record["email"] = email_match.group(0) if deduplicate_and_save(record): inserted_count += 1 else: duplicate_count += 1 seen_before = total_now load_more = page.locator("button.load-more") if load_more.count() == 0 or not load_more.is_visible(): print("没有加载更多按钮,采集结束") break load_more.click() try: page.wait_for_function( "document.querySelectorAll('div.news-item').length > " + str(total_now), timeout=8000, ) except Exception: print("等待新数据超时") break browser.close() print(f"本次采集完成:新增 {inserted_count} 条,重复 {duplicate_count} 条") if __name__ == "__main__": crawl()

这段代码我把每个环节都串起来了。去掉注释后大概一百行左右,放在真实的动态页面解析项目里,属于非常精简但五脏俱全的骨架。

有一点要提醒:页面结构选择器必须按你的目标页面实际调整,没有哪套选择器是万能通用的。建议你先用 page.locator("...").count() 验证选择器能不能匹配到元素,再跑全量逻辑。开发阶段把 HEADLESS 设为 False,能直观看到浏览器到底做了什么,排查问题会快很多。

8. 反爬与检测规避的真实情况

我明白很多人关心“Playwright 会不会被网站检测”。这里我不讲任何违规手段,只说一个客观事实:部分网站确实会检测当前浏览器是否由自动化工具控制,常见的检测维度包括 navigator.webdriver 属性、浏览器窗口尺寸、鼠标轨迹、CDP 连接特征等。

Playwright 提供了一些基础的规避方法,比如 launch 时加 --disable-blink-features=AutomationControlled 参数,或者在创建 context 时传入自定义 user_agent。这些手段能防住比较粗糙的检测,但遇到专门针对自动化框架做指纹识别的风控系统,效果依然有限。我的原则是:只采集公开可访问的数据,不碰需要绕过登录认证或验证码的服务,不做任何破坏性操作。技术本身是中性的,关键在于用在什么场景。

如果你的采集目标是公开信息、且对频率做了合理的控制,绝大多数网站不会对你做极端风控。实际项目里更要关注的是请求频率,不要把单页并发开得太高,给目标服务器留够呼吸空间。我一般会把并发控制在 2 到 3 个页面同时访问,单页内部翻页间隔保持在 1 到 2 秒。慢是慢一点,但胜在稳。

9. 常见问题与排查技巧实录

9.1 启动浏览器失败:target closed

这个报错我遇到很多次。大部分原因是页面在加载过程中发生了跳转、关闭或崩溃,而你代码里还要继续操作旧页面。排查思路是先看是否使用了 popup 窗口,如果是,要确认你拿到的是新窗口的 page 对象。另一个常见原因是 page.goto 的 URL 发生了重定向,重定向后原来的选择器失效。

建议做法:在关键步骤后用 page.wait_for_load_state("domcontentloaded") 或显式等待来同步页面状态,不要在一个 page 对象上做太多跨导航的操作。如果页面存在多个标签页,要分别保存每个页面的句柄。

9.2 定位元素一直超时

超时原因基本上是选择器写错或元素确实不存在,少数情况是 iframe 嵌套。对于 iframe,Playwright 提供了 frame_locator,用法是 page.frame_locator("iframe[name='main']").locator(".content")。这一点比 Selenium 的 switch_to.frame 要自然得多。

排查超时问题,我会优先打开 Trace Viewer 录一段脚本,然后逐步回放,看元素到底在哪个阶段就消失了。有一次我排查到一个诡异的现象,元素明明在页面上却一直说不可见,后来发现是页面里有一个透明的遮罩层挡在上面,Playwright 的自动等待认为元素被遮挡就拒绝点击。解决方案是加 force=True 强制点击,或者先隐藏遮罩层。

9.3 翻页数据重复或漏抓

这种情况通常是“加载更多”点击后没有等新数据渲染就进入下一轮,或者翻页指针记录方式有误。建议记录的不是“已处理条目数”,而是每轮最后一个元素的唯一标识,比如标题 MD5。若下一轮第一条数据和上一轮最后一条数据相同,说明翻页逻辑出了问题,直接停止,避免死循环。

9.4 无头模式跑的比有头模式慢

这是正常的,因为无头模式下浏览器可能会被限制了一些资源加载策略,或者页面自身检测到无头环境改变了渲染逻辑。如果无头模式明显比有头模式慢很多,可以先看看是否被目标网站做了针对性的降级。实在找不到原因,就用有头模式跑,部署时保证服务器有显示环境即可,或用 xvfb 做虚拟显示。

9.5 内存泄漏与 long-running 任务

长时间运行的采集任务,内存占用会稳步上涨。我的经验是建立“按任务批次重启上下文”的机制。每采集 N 页或每跑 N 分钟,主动关闭当前 context 再新建一个,相当于让浏览器进程瘦身。代码实现起来很简单:

if page_no % 5 == 0: context.close() context = browser.new_context(viewport={"width": 1280, "height": 800}) page = context.new_page() page.goto(TARGET_URL, wait_until="domcontentloaded")

这样操作之后,即使内存有一点小的泄漏,也会被周期性的 context 重建抵消掉。

10. 关于 Playwright 生态的扩展建议

Playwright 不止能抓数据,它还能做很多上游工作。比如结合 codegen 快速生成测试脚本做前端回归,或者接 pytest-playwright 做自动化测试套件。如果你负责的项目既需要爬虫又需要测试,那学好 Playwright 一举两得。

在爬虫生态里,Playwright 也经常和 Scrapy 配合。Scrapy 负责调度、管道、中间件,Playwright 负责渲染和提取动态内容,两者之间通过 scrapy-playwright 插件衔接。如果你已经有较重的 Scrapy 项目,可以考虑渐进式迁移,而不是整体推翻重来。这个插件在底层维护了一个 asyncio 事件循环,让你在 Scrapy 里以异步方式调用 Playwright,性能比同步阻塞式要强不少。

我在写完前面那套脚本之后,最后做的一件事就是把采集结果接入到了自己的数据看板里,每天定时自动跑一次增量抓取,新增文章直接入库,重复数据被丢弃,敏感字段在入库前已脱敏。连续跑了快两个多月,除了偶尔目标网站改版调整过一次选择器,其他时间都非常稳定。

如果你也想改造自己的采集项目,我建议分三步走:先跑通单页解析和数据入库,再接入去重和脱敏,最后再把任务挂到定时调度里。每一步的验证成本都低,出了问题也更容易定位。不要指望一次性写个大而全的完美系统,那东西在原地上线的那一天往往就是最难维护的那一天。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询