资讯动态

Playwright实战:动态网页爬虫的浏览器自动化解决方案

发布时间:2026/10/18 4:48:52 来源:尧图企业网站定制
爬虫遇到动态加载页面很多人第一反应是劝退。上个月我接到一个挺实际的需求宜家官网的商品目录需要批量采集把各个商品类目下的名称、价格、链接、图片地址全部结构化存下来备用。按理说这种大型零售网站结构规整、数据也不复杂但真正动手才发现踩了个大坑——商品目录页是典型的前后端分离数据全部靠 JavaScript 异步渲染用传统的 requests BeautifulSoup 组合拿回来的 HTML 只是一个空壳压根找不到任何商品信息。换用 Playwright 之后局面立刻改观。作为一个浏览器自动化框架Playwright 直接驱动真实浏览器内核页面怎么渲染它就怎么采集天然具备人眼看到什么就抓什么的能力。这篇文章我打算完整复盘一次 Playwright 漫游宜家商品目录的实操过程从环境搭建、页面定位、目录遍历、分页翻页到数据清洗、入库与增量更新把踩过的坑和验证过的方案原原本本写出来。适合被动态网页折磨过、想快速上手浏览器自动化的爬虫开发者也适合想了解 Playwright 在真实业务场景中怎么落地的同学。这件事本质上是在和浏览器打交道其中的思路和避坑经验放到其他动态站点上一样能复用。咱们一步步来。1. 为什么动态目录让传统爬虫铩羽而归1.1 静态页面与动态渲染页面的本质区别先弄明白问题出在哪。传统的 requests 方案能够奏效的前提是服务器返回的 HTML 里直接包含了所有目标数据。这种页面叫静态 HTML服务端在收到请求时就已经把商品名称、价格、图片地址等字段填充到页面模板里客户端拿到的是什么浏览器最终渲染出来就是什么。但现代电商网站为了提高首屏速度、降低服务端压力普遍换成前后端分离架构。服务端只返回一个 HTML 骨架和一堆 JavaScript 脚本商品数据要等浏览器执行完脚本后再通过异步接口从后端获取然后由 JavaScript 动态拼装成 DOM 节点。网页源码里连个商品价格的影子都看不到真正有价值的数据藏在 XHR 请求的 JSON 响应里。用生活类比理解这件事静态页面就像做好了的饭菜直接端上桌requests 伸手夹菜就行动态渲染页面则像给你一份菜谱和一包原料还得自己开火做熟而浏览器扮演的就是那个负责做熟的厨师。requests 不会做饭它只会干瞪眼。1.2 requests 方案失败的真实过程我用一个实际测试来说明这个坑有多隐蔽。先用 requests 访问宜家官网的某个商品目录页import requests resp requests.get( https://www.ikea.com/cn/zh/cat/sofas/, headers{User-Agent: Mozilla/5.0}, timeout10, ) print(resp.status_code) # 200状态码正常 print(len(resp.text)) # 页面体积不小但都是 JS 脚本和容器 div响应状态码是 200页面 HTML 也有几十 KB看起来一切正常。真去解析就露馅了from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, html.parser) names soup.select(.product-card__name) prices soup.select(.product-card__price) print(len(names), len(prices)) # 输出0, 0一个商品卡片都抓不到。这时候如果没经验很容易怀疑是不是 CSS 选择器写错了。其实不是商品节点在服务器返回的 HTML 里根本不存在是后续 JS 执行才生成的。看源码会发现大量类似div idroot/div这种空容器数据都在 JS 脚本和后端接口里。1.3 破局思路从请求页面切换到驱动浏览器既然目标不是 HTML 文档而是浏览器渲染完毕后的 DOM 树最直接的办法就是让浏览器去渲染然后从渲染结果里取数据。Playwright 做的事就是把你平时手动操作浏览器的动作全部用 API 表达出来。打开页面、等待加载、定位元素、提取文本、点击翻页、滚动到底部这些都能通过几行代码完成。这等于把用户操作这条路完整走了一遍既绕开了解析空壳 HTML 的死胡同又能应对大量需要点击、滚动才能加载出来数据的交互场景。对我来说选 Playwright 还有一个好处它天然支持自动等待。页面元素没出现时代码会自动重试等待不用像以前那样填一堆time.sleep()碰运气。2. 技术选型拆解Playwright 的底气在哪里2.1 浏览器自动化框架横向对比市面上做浏览器自动化的工具不少主流的无非就是 Selenium、Puppeteer、Playwright 这三个。我把它们摆在一张表里对比过对比项SeleniumPuppeteerPlaywright支持浏览器Chrome、Firefox、Edge仅 Chromium 系Chromium、Firefox、WebKitAPI 风格偏 Java 时代冗余较多基于 Node.js异步友好同步/异步都丝滑Python 一等公民自动等待需要显式写 WebDriverWait部分支持较原始内置自动等待体验最好录屏/追踪有插件方案支持原生支持调试很省心多上下文隔离靠多实例成本高不支持Context 原生隔离社区生态最大案例多大增长最快微软维护Selenium 胜在历史包袱重、网上资料多但配置 WebDriver 那套流程比较折腾而且现在的反爬设备平台对navigator.webdriver标记检测也越来越严格。Puppeteer 很轻巧可它是 Node.js 生态Python 开发者直接上手总感觉隔了一层。Playwright 的 Python 版本 API 设计走的是贴近人类直觉路线定位元素、点击按钮都像在写自然语言。再加上它内核级的自动等待机制对爬虫场景来说确实是最省心的一档。2.2 三层对象模型Browser、Context、Page用 Playwright 前先把对象模型搞明白后面才不会在 Context 上栽跟头。它自顶向下分三层Browser一个浏览器实例相当于你打开了一个独立浏览器窗口。Context浏览器实例里的独立上下文环境可以理解成隐身窗口。每个 Context 都拥有自己独立的 Cookie、LocalStorage、User-Agent。PageContext 里的具体标签页你真正操作的页面对象。Context 这个层很多爬虫新手会忽略其实特别实用。比如我要同时采集几个不同类目的目录页又想给每个类目分配不同的 Cookie 状态直接用多个 Context 就能做到互不污染。再比如想模拟多个用户身份把不同 Context 的登录态分别存成 JSON 文件下次恢复出来直接用即可。代码里通常长这样from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessFalse) ctx browser.new_context(user_agentMozilla/5.0 ..., viewport{width: 1280, height: 720}) page ctx.new_page()2.3 自动等待机制告别 time.sleep 时代很多爬虫脚本的失败不是因为定位写错而是因为代码执行太快页面还没渲染完就急着去取元素。Playwright 的自动等待机制专门解决这个问题。它的核心原则是元素没出现就一直等直到超时。以page.locator(.product-card).click()为例Playwright 会先等这个元素出现在 DOM 里并处于可交互状态再执行点击动作。比手动time.sleep(3)靠谱得多因为不同网速下加载时间完全不同固定等待要么太慢、要么不够。我自己习惯配合使用的还有两个方法# 等待某个关键选择器出现超时抛出异常 page.wait_for_selector(.product-card, timeout10000) # 等待页面进入某个加载状态 page.goto(url, wait_untildomcontentloaded)关于wait_until有个经验别盲目用networkidle。加载过程中 XHR 请求太多networkidle经常要等很久实测下来体验一般。我默认用domcontentloaded再配合具体元素的wait_for_selector既快又稳。2.4 选择器系统从 text 到 XPath 的优先级Playwright 内置的选择器引擎很好用不只是 CSS。我最常用的是它原生的text选择器直接按可见文本定位免去写一长串 CSS 路径的麻烦page.click(text继续)这种写法在翻页按钮、弹窗确认这类场景特别好使人眼看到的是什么文字代码里就写什么文字。遇到模糊匹配用:has-text()或者正则text/确认|OK/。不过碰上复杂结构纯文本定位会失灵还是老老实实用 CSS 或 XPath# CSS 选择商品卡片 card page.locator(div.product-card) # XPath 定位某个商品名 name page.locator(//span[contains(class, product-card__name)])我的优先级排序是text优先用于按钮和明确文本CSS 用于结构化组件XPath 用于需要通过祖先兄弟关系定位的场景。日常大部分采集工作前三者已经够用。3. 环境准备与第一个自动化脚本3.1 安装依赖与浏览器初始化开始动手前先装好环境。Python 版本 3.8 以上基本都没有问题核心步骤只有两个命令pip install playwright playwright install chromium第一行装的是 Playwright 的 Python 库第二行会下载 Chromium 内核到本地。很多人会漏掉第二行导致运行时报错找不到浏览器这里先打个预防针。装完可以用最短的一行代码验证环境是否正常from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://www.ikea.com/cn/zh/, wait_untildomcontentloaded) print(page.title()) browser.close()能打印出页面标题就说明整个链路已经通了。提示sync_playwright是同步模式比异步 API 更直观适合大部分爬虫业务。除非你要做高并发采集否则同步模式完全够用。3.2 打开宜家目录页并确认渲染完成接下来进入正题。我以宜家官网某个分类目录页为例先选中正确的导航入口。直接用链接访问分类页会比模拟点击导航链路更稳定with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() page.goto(https://www.ikea.com/cn/zh/cat/, wait_untildomcontentloaded) # 等待商品卡片容器出现确认动态渲染已经完成 page.wait_for_selector(div.product-card, timeout15000) cards page.locator(div.product-card) print(首屏商品卡片数量:, cards.count()) browser.close()这里真正的重点在wait_for_selector。目录页首屏的商品卡片是通过 JS 异步加载的如果页面没渲染完就去数卡片数量大概率得到 0。等待选择器出现等于告诉程序卡片已经挂在 DOM 上了可以开始操作。3.3 定位商品卡片并提取首屏数据卡片容器定位到了接下来就是把每张卡片内的字段提取出来。我先把一个卡片的 HTML 结构大致梳理出来再写提取逻辑card cards.nth(0) # 商品名称 name card.locator(span.product-card__name).inner_text() # 商品价格一般是 ¥ 499.00 这种字符串 price_text card.locator(span.product-card__price).inner_text() # 商品详情页链接 link card.locator(a).get_attribute(href) # 商品缩略图地址 image card.locator(img).get_attribute(src) print(name, price_text, link, image)这一步的细节决定成败。第一inner_text()拿到的文本通常带空格或换行后面清洗时要统一处理第二get_attribute(href)拿到的很可能是相对路径入库前要拼上域名前缀第三有些商品卡片的图片用的是懒加载src会是占位图真正图片地址在>page.wait_for_selector(div.product-card, timeout15000) page.screenshot(pathdebug_catalog.png, full_pageTrue)full_pageTrue参数可以截取整个页面长图一眼看出页面结构和商品加载情况。路径别用相对路径散落到项目各处我会专门建一个debug/目录统一存放排错时按时间戳翻查。关于headless参数的选择我的经验是调试阶段用有头模式跑任务时用无头模式。有头模式下能看到浏览器实际动作定位出错时能立即发现是等待不足还是选择器写错脚本跑熟了再切成headlessTrue服务器上无人值守时也不占电脑屏幕。不过有些反爬严格的环境会检测无头模式特征遇到的情况我放在后面的章节里说。4. 商品目录遍历与循环采集实战4.1 静态分页翻页点击按钮与等待 URL 变化首屏只有十几个商品整个目录可能有几百条必须处理分页。不同网站的分页形式五花八门宜家目录页用的是上一页/下一页的静态翻页按钮逻辑上最直接。写翻页循环时一个常见的坏习惯是死等固定时间# 反面教材别这么写 for i in range(10): page.click(text下一页) time.sleep(5) # 固定等待又慢又不稳更好的做法是点击翻页按钮之后等待下一个页面拿数据。具体判断条件可以看 URL 变化也可以等待下一页上的第一个商品卡片重新出现。我用 URL 变化判断最多因为可靠且不依赖页面具体结构current_url page.url page.click(text下一页) page.wait_for_url(f{current_url}pagination2, timeout15000) # 等待新一批商品卡片渲染 page.wait_for_selector(div.product-card, timeout10000)wait_for_url会一直等直到 URL 变成指定值才继续省掉盲目的 sleep也避免了点击后立刻抓取导致重复数据的问题。4.2 懒加载与无限滚动处理越滚越多静态翻页还算温柔真正棘手的是无限滚动。页面拉到底部自动加载新商品根本不给分页按钮。这种场景要用滚动结合卡片数量变化判断。核心逻辑是滚动到某个位置等待新卡片加载如果滚动前后卡片数量没有变化就说明已经滚到尽头prev_count -1 while True: cards page.locator(div.product-card) cur_count cards.count() if cur_count prev_count: print(已到底部共加载, cur_count, 个商品) break prev_count cur_count page.mouse.wheel(0, 2000) page.wait_for_timeout(800)这里有个细节不要一滚到底而是分段滚动每段间隔 1 秒左右给 JS 留出加载时间。如果把滚动速度拉满很容易触达网站的接口限流。4.3 断点续传与去重防止白跑一趟目录采集少则几十页多则上百页中间很容易因为网络波动、浏览器崩溃、触达反爬而中断。如果脚本没有记忆重跑一遍就得回到第一页代价太高。我的方案很简单做一个采集进度文件记录当前已经处理过的商品 URL每次跑之前先加载进内存集合import json from pathlib import Path done_file Path(done_urls.json) done_urls set(json.loads(done_file.read_text())) if done_file.exists() else set() # 每采集一条就把 URL 加入集合并写入文件 def mark_done(url): done_urls.add(url) done_file.write_text(json.dumps(list(done_urls)))这样断点续传时脚本可以先判断某个商品是否已经在集合里在的话直接跳过不在的话才采集。虽然简单但稳得很。5. 数据清洗、存储与增量更新5.1 字段清洗的细节采集到的原始数据不能直接入库。商品名称两头可能带空格或换行价格是¥ 499.00这种带货币符号的字符串不处理就只能存字符串没法做价格排序和区间筛选。我的清洗函数大概长这样def clean_price(text: str) - float | None: 把 ¥ 499.00 转成浮点数 499.0 if not text: return None digits text.replace(¥, ).replace(,, ).strip() try: return float(digits) except ValueError: return None def clean_name(text: str) - str: return .join(text.split())text.split()再join的小技巧很好用能把连续空格、换行、制表符全部规整成单个空格比strip()强很多。价格里的逗号千分位分隔符也别忘了去掉很多站点会显示成¥ 1,299.00。5.2 SQLite 落地建表方案采集规模在几千到几万条这个量级时SQLite 是最合适的选择——零配置、单文件、查询方便。我建表结构如下CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, name TEXT NOT NULL, category TEXT, price_cny REAL, image_url TEXT, crawled_at TEXT DEFAULT (datetime(now, localtime)) );url字段加上UNIQUE约束这是后面增量更新的基础。写入时用INSERT ... ON CONFLICT DO UPDATE语法同一件商品第二次采集到就更新价格和名称而不是新增一条重复记录import sqlite3 conn sqlite3.connect(ikea_catalog.db) def save_product(conn, item): conn.execute( INSERT INTO products (url, name, category, price_cny, image_url) VALUES (?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET name excluded.name, category excluded.category, price_cny excluded.price_cny, image_url excluded.image_url , (item[url], item[name], item.get(category), item.get(price_cny), item.get(image_url))) conn.commit()另外提醒一句导进 Excel 查看时 CSV 编码一定要指定为utf-8-sig否则中文会变成乱码。直接用 SQLite 反而没这个问题。5.3 增量更新策略以商品 URL 为业务主键商品唯一标识用什么名字可能改ID 可能因人而异最稳的是详情页 URL。商品 URL 在站内一般比较稳定且天然唯一。拿它做业务主键就能做到以下效果第一次采集全量入库。第二次采集已存在的 URL 直接更新价格新 URL 插入为新商品已下架商品保留原记录。增量采集脚本里我只用了一段 SQL 判断SELECT url FROM products;把这批 URL 加载进内存集合遍历目录页时发现 URL 已在集合里就把当前页面的价格和名称刷新一下然后继续下一页。这种方式不仅保证数据新鲜也大大降低了对目标站点的重复请求量。6. 反爬应对与常见问题排查实录6.1 无头模式被网站识别怎么办Playwright 默认的 Chromium 在无头模式下会暴露一些特征个别反爬策略能识别出来。表现就是脚本在本地跑得好好的部署到服务器就频繁跳出验证页面。我处理这类问题的三板斧指定真实 User-Agentbrowser.new_context(user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) ...)不要用默认的 HeadlessChrome 标志。设置合理的 viewport默认的 1280x720 太标准了可以设成 1440x900 或其他常用分辨率。去掉自动化遗留痕迹通过page.add_init_script()隐藏navigator.webdriver等特征属性。context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, viewport{width: 1440, height: 900}, ) context.add_init_script( Object.defineProperty(navigator, webdriver, {get: () undefined}) )这套组合应对大部分基础检测够用。更高阶的浏览器指纹检测单纯靠参数已经不够需要结合代理池、指纹伪装等更复杂的方案这里不展开。6.2 验证码与登录态人工介入一次遇到验证码弹窗第一反应不要想着暴力绕过。我的做法是切换到有头模式人工完成一次验证把登录态保存下来后续请求直接复用。# 第一次人工过验证码 context browser.new_context() page context.new_page() page.goto(url) input(请在浏览器中完成验证按回车继续...) context.storage_state(pathstate.json) # 后续直接加载保存的登录态 context browser.new_context(storage_statestate.json)storage_state会把 Cookie、LocalStorage 统一存到 JSON 文件下次新开 Context 时直接恢复。这是 Playwright 提供的最实用的登录态管理方案比手动复制 Cookie 字符串靠谱得多。6.3 请求频率与代理别让服务器压力过高做目录采集不是抢购没必要把请求频率拉到极限。我一般会在每次翻页和滚动之间加入随机延迟既模拟真人操作节奏也降低服务器压力import random import time time.sleep(random.uniform(1.5, 3.5))遇到大规模采集单 IP 很容易触限。稳妥的做法是维护一组代理 IP在创建 Context 时随机分配PROXIES [http://proxy1:port, http://proxy2:port] context browser.new_context(proxy{server: random.choice(PROXIES)})需要强调的是选择代理资源时务必使用合规服务商不要碰不明来源的免费代理安全性和稳定性都没法保证。6.4 常见问题速查表我把这次实操中遇到的高频问题整理成了一张表供直接对照排查问题现象可能原因解决方案商品卡片数量始终是 0页面还没渲染完成就抓取加wait_for_selector等待关键元素翻页后数据重复点击后没有等 URL 变化用wait_for_url等待跳转完成价格变成None价格字段存在多个子元素检查价格 DOM 结构改用last()或精确选择器跑一半被弹出验证码请求频率过高触发防护降低采集频率、增加随机延迟、人工验证保存态中文入库变成乱码数据库或文件编码错误SQLite 无此问题CSV 用utf-8-sig编码无头模式被站点拦截自动化特征被检测设置 UA、viewport、隐藏 webdriver 标记滚动加载到不了底滚动速度太快JS 没跟上减小滚动步长每次滚动后加 1 秒间隔图片链接抓回来是占位图懒加载图片src是占位地址同时检查>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑