ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Playwright实战:动态页面解析与数据去重脱敏完整指南

2026/9/8 8:03:26 拓冰建站 浏览量
Playwright实战:动态页面解析与数据去重脱敏完整指南 1. 为什么从 Selenium 切换到 Playwright谈谈我在真实项目里的感受先说个背景。我之前有三年多时间一直在用 Selenium 做 Web 自动化从最早的 PhantomJS 时代一路用到 ChromeDriver Selenium 4熟悉的同学都懂那套配置驱动的繁琐。直到去年接了一个动态页面采集的活要抓一个前端渲染很重的数据平台页面里全是异步加载、弹窗、嵌套 iframeSelenium 写起来不仅代码量大而且动不动就报元素找不到、点击不上、等待超时。最崩溃的是同一套脚本在本地能跑部署到服务器上就各种环境问题。后来试了试 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-playwrightWindows 上在 %USERPROFILE%\AppData\Local\ms-playwright。把整个目录打包拷到目标机器再设置环境变量 PLAYWRIGHT_BROWSERS_PATH 指向它就能实现离线安装不需要外网也能用。初始化浏览器的代码我一般这样写from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch( headlessFalse, # 调试时设为 False正式跑可以改 True args[ --disable-blink-featuresAutomationControlled, --start-maximized, ] ) context browser.new_context( viewport{width: 1280, height: 800}, user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., localezh-CN, ) page context.new_page() page.goto(https://example.com, wait_untilnetworkidle)这里有个容易忽略的点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[classitem])结构复杂的兜底方案我的建议是优先用 CSS 和 get_by_role实在搞不定再上 XPath。别一上来就截图复制 xpath那是纯纯的体力活页面稍微改一点就废了。另外有个经验在多元素并列的场景里locator.nth(index) 可以用来精确定位第几个元素locator.filter(has_text关键字) 可以用来过滤特定内容。这些组合用熟了之后复杂页面也能很快写出稳的定位式。4. 实测一个真实场景解析动态列表页并翻页为了演示我模拟一个典型场景一个新闻列表页内容全部由前端渲染列表项会显示标题、发布时间、点击量和摘要信息页面底部的“加载更多”按钮需要点击后才会加载下一页数据。这个场景基本涵盖动态页面解析的主要难点。4.1 等待列表数据加载打开页面后第一步不能急着提取而是等数据真实渲染出来。我推荐用显式等待一个“标志性元素”这个元素通常是列表中的第一条数据。代码是这样page.goto(url, wait_untildomcontentloaded) # 等待列表首条元素出现 page.locator(div.news-item).first.wait_for(statevisible, timeout10000) # 若页面有骨架屏可以额外等待骨架屏消失 skeleton page.locator(.skeleton) if skeleton.count() 0: skeleton.first.wait_for(statehidden, timeout10000)wait_for(statevisible) 表示等待元素从隐藏变成可见wait_for(statehidden) 则表示等待元素消失。后者用来处理骨架屏或 loading 动画特别有效。这里有个我没有直接使用 wait_untilnetworkidle 的原因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), timeout8000, ) 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(capacity1000000, error_rate0.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(headlessHEADLESS, args[--disable-blink-featuresAutomationControlled]) context browser.new_context( viewport{width: 1280, height: 800}, localezh-CN, ) page context.new_page() page.goto(TARGET_URL, wait_untildomcontentloaded) # 等待列表出现 page.locator(div.news-item).first.wait_for(statevisible, timeout15000) 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(r1[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), timeout8000, ) 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-featuresAutomationControlled 参数或者在创建 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 嵌套。对于 iframePlaywright 提供了 frame_locator用法是 page.frame_locator(iframe[namemain]).locator(.content)。这一点比 Selenium 的 switch_to.frame 要自然得多。排查超时问题我会优先打开 Trace Viewer 录一段脚本然后逐步回放看元素到底在哪个阶段就消失了。有一次我排查到一个诡异的现象元素明明在页面上却一直说不可见后来发现是页面里有一个透明的遮罩层挡在上面Playwright 的自动等待认为元素被遮挡就拒绝点击。解决方案是加 forceTrue 强制点击或者先隐藏遮罩层。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_untildomcontentloaded)这样操作之后即使内存有一点小的泄漏也会被周期性的 context 重建抵消掉。10. 关于 Playwright 生态的扩展建议Playwright 不止能抓数据它还能做很多上游工作。比如结合 codegen 快速生成测试脚本做前端回归或者接 pytest-playwright 做自动化测试套件。如果你负责的项目既需要爬虫又需要测试那学好 Playwright 一举两得。在爬虫生态里Playwright 也经常和 Scrapy 配合。Scrapy 负责调度、管道、中间件Playwright 负责渲染和提取动态内容两者之间通过 scrapy-playwright 插件衔接。如果你已经有较重的 Scrapy 项目可以考虑渐进式迁移而不是整体推翻重来。这个插件在底层维护了一个 asyncio 事件循环让你在 Scrapy 里以异步方式调用 Playwright性能比同步阻塞式要强不少。我在写完前面那套脚本之后最后做的一件事就是把采集结果接入到了自己的数据看板里每天定时自动跑一次增量抓取新增文章直接入库重复数据被丢弃敏感字段在入库前已脱敏。连续跑了快两个多月除了偶尔目标网站改版调整过一次选择器其他时间都非常稳定。如果你也想改造自己的采集项目我建议分三步走先跑通单页解析和数据入库再接入去重和脱敏最后再把任务挂到定时调度里。每一步的验证成本都低出了问题也更容易定位。不要指望一次性写个大而全的完美系统那东西在原地上线的那一天往往就是最难维护的那一天。