干爬虫这行的都知道,现在真正让人头疼的不是那些静态页面,而是像电商百亿补贴、秒杀会场这种页面——价格是 JS 动态算出来的,列表是滚动加载的,数据是接口加密返回的。你用requests去请求,拿回来的 HTML 基本就是个空壳,里面除了框架啥都没有。我刚入门那会儿也在这上面栽过跟头,盯着抓包工具里那个返回 JSON 的接口看了半天,却发现直接请求它根本拿不到完整数据,不是报参数错误就是返回乱码。
后来换了思路,干脆不跟接口死磕了,直接上 Playwright 这种浏览器自动化工具。你要什么数据,就让真实浏览器帮你打开页面,等它渲染完了你直接去页面里取。思路说起来就一句话:requests 是打电话问客服,Playwright 是找个真人代购,去商场里把每件商品的标签拍下来给你。这篇文章我把自己在电商动态商品数据采集上的完整经验梳理了一遍,从环境搭建、数据提取方案、入库到常见坑位排查都有,适合刚接触 Playwright 的爬虫新手,也适合用 selenium 一直写不顺、想换工具的老手。
1. 为什么 requests 搞不定,偏偏要上 Playwright
1.1 电商页面早就不再是纯 HTML 了
很多读者可能不知道,现在的电商页面和你十年前看的不一样了。十年前你requests.get一个商品列表页,服务器直接吐出一大段完整的 HTML,商品名称、价格、图片全在里面躺着,你用正则或者 BeautifulSoup 直接就能打捞出数据。
现在呢?页面是个 SPA(单页应用),服务端只返回一个 HTML 骨架和一堆 JS 文件。浏览器加载完骨架之后,再执行 JS,去请求后端接口,拿到 JSON 数据,最后把数据渲染到页面上。你直接curl那个 URL,看到的大概率是<div id="app"></div>这种空壳。
而且电商平台的反爬体系早就不是简单检查 User-Agent 了,它们会在接口层面做签名校验、行为验证。你在浏览器里看到的每个商品价格,都是前端用一串你看不懂的加密参数向服务端换来的。就算你把接口 URL 找出来了,缺了那几个动态生成的参数,也很难直接调用成功。
所以这时候你需要的是一个“能完整执行 JS、能模拟真实用户操作、能等待异步数据渲染完成”的浏览器环境。这就是浏览器自动化工具的核心价值。
1.2 Playwright 到底解决了什么问题
Playwright 是微软开源的一个浏览器自动化框架,支持 Chromium、Firefox、WebKit 三种内核。和 Selenium 相比,它有几个很实用的优势。
第一是自动等待机制。Selenium 里你经常要自己写time.sleep或者WebDriverWait去等元素出现,Playwright 直接提供了wait_for_selector、expect_response这类内置方法,它内部会持续轮询 DOM 树,直到目标元素出现或者超时,不需要手动猜要等多久。
第二是原生支持网络监听。这个功能对爬虫来说太好用了。你可以用page.on("response")监听页面发出的所有 HTTP 请求,直接把 JSON 接口的响应体截获下来。这种方式比解析 DOM 稳定得多,后面我会专门展开讲。
第三是上下文隔离。你可以用browser.new_context()创建多个相互独立的浏览器环境,每个 context 有自己的 Cookie、缓存、User-Agent,很方便做多账号、多环境的切换。
我自己的感受是,用 Playwright 写爬虫,代码量大概只有 Selenium 的三分之二,而且调试成本低。很多在 Selenium 里反复踩的坑——比如元素被遮挡、iframe 定位不到、弹窗挡路——在 Playwright 里都有对应的原生 API 帮你处理。
1.3 适合与不适合用 Playwright 的场景
说句实在话,Playwright 不是万能的,用之前你要先判断场景到底适不适合。
适合的场景有三个。第一,页面数据靠异步加载,直接请求拿不到内容;第二,目标站点有登录验证、滑块、行为检测,你需要用真实浏览器环境来“伪装”成正常用户;第三,你需要模拟复杂的用户操作,比如滚动、点击、切换标签页,才能触发数据加载。
不适合的场景也有三个。第一,目标接口参数简单、无加密,直接用requests加一个合理的User-Agent就能拿到 JSON,那就别多此一举开浏览器了,又慢又耗资源。第二,你需要每天定时跑几百万条数据,浏览器自动化的性能和稳定性都撑不住这个量级,应该考虑排队请求接口或者寻找更合规的数据渠道。第三,目标站点的风控系统很敏锐,对无头浏览器识别得很准,频繁请求容易触发封禁,这时候不管你用什么工具都白搭。
选择工具的底层逻辑是成本:requests最轻,代价是拿不到动态数据;Playwright 最重,但能在动态渲染场景下拿到数据。你要做的是先判断页面复杂度,再决定用什么工具,而不是无条件上重武器。
2. 动手前的准备:环境搭建与浏览器内核
2.1 Python 环境与依赖安装
我的开发环境是 Python 3.10,版本建议至少 3.9 以上,因为 Playwright 的新版本对低版本 Python 支持越来越弱了。
安装过程非常简单,两条命令搞定:
pip install playwright playwright install chromium第一条命令装的是 Python 库,第二条命令会把 Chromium 浏览器内核下载到本地。这里要提醒你一句,首次执行playwright install时,下载量大概是 150MB 左右,如果网络状况一般,建议给 pip 配个国内镜像源:
pip install playwright -i https://pypi.tuna.tsinghua.edu.cn/simple装完先跑一个最简单的小脚本验证环境是否正常:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) page = browser.new_page() page.goto("https://example.com") print(page.title()) browser.close()看到终端输出Example Domain,就说明环境没问题了。headless=False表示有头模式,你能看到一个真实的浏览器窗口打开和关闭;headless=True则是无头模式,适合服务器上跑。
2.2 浏览器内核怎么选
Playwright 支持三种浏览器内核,我建议默认用 Chromium,因为它跟 Chrome 的渲染引擎一致,页面兼容性最好。Firefox 和 WebKit 在自动化场景下渲染效果和 API 行为大致相同,但如果碰到某些依赖 Chrome 特性的页面,就可能出幺蛾子。
另外有一个容易被忽略的小细节:p.chromium.launch()默认用的是一种特殊的自动化 Chromium 版本,和你日常用的 Chrome 不一定完全一样。如果你的脚本在自动化浏览器里没问题,但怀疑目标网站的检测机制会识别navigator.webdriver这类特征,可以考虑用channel="chrome"参数去调用你本机安装的 Chrome:
browser = p.chromium.launch(channel="chrome", headless=False)这样 Playwright 会直接复用你本机的 Chrome 程序,浏览器指纹更接近真实用户。这个不是破解什么高深反爬,只是降低被识别为自动化程序的概率,纯属技术层面的优化。
2.3 给脚本加一个“降速阀”
写爬虫最忌讳的就是脚本跑得像机器一样匀速、稳定、毫秒不差。真实用户不可能每 2 秒点一次页面,也不可能每 30 分钟刷新一次列表,这种机械感最容易触发平台的行为监控。
我习惯在关键操作之间加随机延时:
import random import time def human_delay(min_sec=0.8, max_sec=2.5): time.sleep(random.uniform(min_sec, max_sec))然后在每次点击、滚动、翻页之后调用human_delay()。别小看这个随机延时,它能大幅降低请求频率的规律性。还有一个经验值是单页面的整体执行节奏控制在 8 到 15 秒之间,太慢了太浪费时间,太快了又不符合真人浏览习惯。
3. 核心实现:三招拿下动态商品数据
这一节是整个文章的重头戏。我把自己常用的三种数据提取方案全部列出来,分别应对不同的情况。实际项目中我一般是先看包,能抓接口就不解析 DOM;接口抓不到或者加密太复杂,再退回 DOM 解析。
3.1 方案一:监听网络响应,直接抓 JSON 接口
这是我最推荐的方式,没有之一。原理很简单:浏览器里加载页面时,前端 JS 会主动请求后端接口,拿到 JSON 数据后渲染成 DOM。既然 JSON 数据本来就会经过浏览器,那我们在 Playwright 里监听所有网络响应,把那些接口返回的 JSON 直接截获下来就行。
我经常用一个辅助函数来统一处理响应监听:
from playwright.sync_api import sync_playwright captured_data = [] def handle_response(response): if "/api/" in response.url and response.status == 200: try: json_data = response.json() except Exception: return # 只保留我们关心的数据,避免把乱七八糟的埋点接口也存下来 if "product" in response.url or "goods" in response.url: captured_data.append(json_data) with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.on("response", handle_response) page.goto("https://目标站点/百亿补贴", wait_until="networkidle") # 页面加载完成后,多滚动几次,触发接口重新请求 for _ in range(5): page.mouse.wheel(0, 1200) page.wait_for_timeout(1500) browser.close()这段代码的核心就一个:page.on("response", handle_response)。它会捕获页面里所有的 HTTP 响应,包括 XHR、Fetch、静态资源等。你在handle_response里过滤 URL 关键字,解析 JSON,取你需要的数据。
有读者会问:怎么判断哪个接口是商品数据接口?很简单,打开浏览器开发者工具的Network面板,手动打开目标页面,在筛选框里输入XHR或者Fetch,然后看哪个请求返回的数据里包含商品名称、价格。找到那个请求,记下它的 URL 特征,写进过滤条件里就行。
这个方法比解析 DOM 稳在哪?DOM 结构会因为前端改版而频繁变动,今天class="price"明天就变成class="prc",你的选择器就废了。接口 JSON 的字段结构在正常迭代下会稳定得多,而且返回的数据字段更全,除了页面展示的,还有商品 ID、活动 ID、优惠类型这些利索数据。拿来做数据分析或者二次处理,体验完全不是一个量级。
3.2 方案二:直接解析渲染后的 DOM
如果目标站点的接口做了很强的加密,监听响应也拿不到明文 JSON,那就得退回 DOM 解析方案。这个方案其实也很成熟:用 Playwright 打开页面,等待数据渲染完成后,通过选择器批量提取文本和属性。
核心代码长这样:
with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://目标站点/list", wait_until="load") # 等待商品卡片出现,最多等 10 秒 page.wait_for_selector(".product-card", timeout=10000) # 获取所有商品名称 titles = page.locator(".product-card .title").all_text_contents() # 获取所有价格文本 prices = page.locator(".product-card .price").all_text_contents() # 获取所有商品链接 links = page.locator(".product-card a").evaluate_all( "elements => elements.map(e => e.href)" ) for i in range(len(titles)): print(titles[i], prices[i], links[i])这里有两个容易踩的坑。
第一个是不要用page.inner_text(".product-card")这种把整个卡片文本一把抓的方式。因为你没法保证每个卡片的字段顺序一致,商品名称里如果混了促销标签,数据会很脏。建议一个字段一个选择器,分开提取,最后按索引拼装。
第二个是滚动加载的页面要循环滚动几次。现在的电商列表基本都是无限滚动,页面初始只会加载一屏数据,你得通过滚动触发后续加载:
previous_count = 0 for _ in range(20): page.mouse.wheel(0, 1200) page.wait_for_timeout(1200) current_count = page.locator(".product-card").count() if current_count == previous_count: break previous_count = current_count这段代码的逻辑是每次滚动后统计商品卡片数量,如果连续两次数量没变化,说明已经到底了,直接跳出循环。这样写比较稳,不会出现“滚动次数不够导致漏数据”或者“滚动过多次浪费时间”的问题。
3.3 方案三:接管登录态,带着 Cookie 访问
电商平台很多数据都需要登录才能看完整,比如商品的真实成交价、历史价格、库存数量。这时候每次启动浏览器都扫码登录显然不现实,所以我一般用 Playwright 的storage_state机制,把登录状态持久化保存。
第一步,手动操作一次登录:
with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context() page = context.new_page() page.goto("https://目标站点/login") # 手动扫码或者输入账号登录,登录成功后停留几秒等 Cookie 稳定 page.wait_for_timeout(5000) # 保存登录状态到本地文件 context.storage_state(path="state.json") browser.close()第二步,下次脚本启动时直接加载登录态:
with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context(storage_state="state.json") page = context.new_page() # 现在访问需要登录的页面,直接就是登录状态 page.goto("https://目标站点/cart")为什么用storage_state而不是直接在代码里塞 Cookie?因为storage_state不仅保存了 Cookie,还把localStorage、sessionStorage里的内容也一并保存了。很多站点的登录态有一部分存在localStorage里,你只复制 Cookie 是没用的。这就好比别人给你一个钥匙,但你进小区还要刷门禁卡,而storage_state是把钥匙和门禁卡给你一起打包了。
还要提醒一句,登录状态文件不要提交到 Git 仓库里去,里面带着你的账号凭证,泄露了是可以被别人冒用的。我在项目里都会把state.json加进.gitignore。
3.4 数据清洗与入库:SQLAlchemy 版
数据抓下来只是第一步,后续的存储、去重、分析才是关键。我一般用 SQLAlchemy 入库,因为它能让我用 Python 类定义表结构,不用手写 SQL,后续增删改查也很顺手。
一个简单的商品表定义:
from sqlalchemy import create_engine, Column, String, Float, Integer, DateTime from sqlalchemy.orm import declarative_base, sessionmaker from datetime import datetime Base = declarative_base() class Product(Base): __tablename__ = "products" id = Column(Integer, primary_key=True, autoincrement=True) product_id = Column(String(64), unique=True, index=True) title = Column(String(512)) price = Column(Float) original_price = Column(Float) sales = Column(Integer) shop_name = Column(String(256)) url = Column(String(1024)) created_at = Column(DateTime, default=datetime.now) engine = create_engine("mysql+pymysql://user:password@localhost/ecommerce?charset=utf8mb4") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)插入数据时,利用product_id的唯一约束做去重:
def save_products(products): session = Session() try: for p in products: existing = session.query(Product).filter_by(product_id=p["product_id"]).first() if existing: existing.price = p["price"] existing.sales = p["sales"] else: session.add(Product(**p)) session.commit() except Exception: session.rollback() raise finally: session.close()这个save_products函数走的是“存在则更新,不存在则插入”的逻辑。爬虫跑多轮之后,数据不会无限膨胀,价格和销量始终保持最新状态,这个对后续做价格趋势分析特别有用。
补充一点,如果你只是临时分析、不需要长期存储,直接用csv模块写 CSV 文件就够了,别动不动就上数据库,数据和复杂度不一定匹配。
4. 动态页面三大坑:弹窗、滚动加载与 iframe
4.1 弹窗与浮层:脚本被卡住的第一杀手
电商页面的弹窗是真多。新人首单红包、优惠券领取、签到提醒、地理位置授权,任何一个弹窗糊住页面,你的定位点击就会失准,数据提取也可能拿不到完整内容。
我的处理习惯是先尝试点击关闭按钮,点击不到就按Esc键兜底:
def close_popups(page): # 尝试点击常见关闭按钮 for selector in [".close", ".icon-close", "[aria-label='关闭']", ".pop-close"]: try: if page.locator(selector).first.is_visible(): page.locator(selector).first.click() page.wait_for_timeout(500) return except Exception: continue # 兜底方案:按 Esc page.keyboard.press("Escape")这段代码我会放在每次页面加载完成之后、数据提取之前的缝隙里。有人可能会问,为什么不直接禁用弹窗里的某些元素?因为弹窗是动态渲染出来的,你很难在初始化阶段阻止它加载,关闭按钮先点了再说是最省事的办法。
其实这里还有个小技巧:如果弹窗只在第一次访问时出现,你可以把看到弹窗登录完之后的storage_state保存下来,下次启动时直接用登录态,很多“新手引导型”弹窗就不会出现了。
4.2 无限滚动与列表懒加载:漏数据的万恶之源
列表页的懒加载机制是写爬虫时最容易漏数据的地方。页面从一开始只加载一部分商品,你往下滚,它才去请求下一批。问题在于,你不能确定什么时候滚到底了。
我前面 3.2 节给了一个“统计卡片数量”的方案,这里再补充一个更严谨的做法:通过接口计数来判断。
如果你用的是监听响应方案,可以在handle_response里加一个计数器,每捕获一个商品列表接口的响应就自增一次。当连续多次滚动后这个计数器不再变化,说明没有新的数据请求发出来,基本就到底了。
如果用的是 DOM 解析方案,建议设置一个最大滚动次数上限。假设商品总数 1000 条,每批加载 40 条,按 30 批计算,循环滚 35 次还没到底就有问题了。设置一个上限可以防止脚本死循环滚一整天,这是我在实际项目里踩过坑才补上的保护机制。
4.3 iframe 与多标签页:定位不到元素的元凶
现在有不少电商页面会在主页面里嵌入 iframe 来实现某个独立功能模块,登录框、支付面板、客服聊天这类组件尤其常见。Playwright 针对 iframe 有专门的处理方式:
# 获取 iframe 内部的元素 frame = page.frame_locator("iframe[src*='login']") frame.locator("#username").fill("your_account") frame.locator("#password").fill("your_password") frame.locator(".submit-btn").click()还有一个场景是点击某个链接后,新标签页自动打开,数据在那个新页面里。这时候你要切换 page 对象:
with page.expect_popup() as popup_info: page.click(".open-new-page-button") new_page = popup_info.value new_page.wait_for_load_state() # 之后的操作都在 new_page 上进行如果你不切换 page,继续在新 page 上调用选择器,就会报“元素找不到”的错误。多标签页切换是 Playwright 爬虫里比较容易忽略的坑,报错信息还不明显,得靠经验判断。
4.4 反爬检测的几个信号
做爬虫这行,绕不开反爬这个话题。我不打算教你怎么绕过风控系统,但有些现象你要能识别,知道它发生了,才能做出正确判断。
常见信号包括:页面提示“网络异常”“访问过于频繁”、验证码频繁弹出、数据字段出现乱码、接口返回空数组、同一个 Cookie 访问被限流。一旦脚本出现这些情况,最正确的处理不是继续调参重试,而是立即暂停脚本,降低频率,或者换个时段再跑。
我见过太多新手在触发反爬之后不死心,调头去研究什么高深反爬算法,结果账号被封、IP 被封,最后连正常数据都拿不到。爬虫的本质是“协商式访问”,你太贪婪,服务器就会拒绝你。学会克制频率、控制节奏、非必要不采集,在这个领域反而是一种核心竞争力。
5. 常见问题排查实录
这里分享几个我在实际跑 Playwright 爬虫过程中反复遇到的经典问题,整理成速查表,直接对照排查。
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
wait_for_selector超时 | 页面登录态失效、二次跳转、异步组件加载过慢 | 检查是否被重定向到登录页;增加超时时间到 15 秒;用wait_until="networkidle"替代默认加载状态 |
| 提取到的数据有整段缺失 | 弹窗遮挡、 lazy-load 图片占位、列表未滚动到底 | 先调close_popups()关弹窗;强制滚动到底后再提取;观察浏览器控制台有没有报 JS 错误 |
| 数据重复 | 无限滚动触发了重复请求、分页参数错乱 | 入库前用product_id做唯一性去重;检查是否多次保存了同一个响应 |
| 内存占用越来越大 | 长时间跑一个 browser 实例、context 未释放 | 周期性重启浏览器进程;每个批次任务创建独立 context,执行完context.close() |
| 页面白屏 | 本地缓存脏数据、浏览器版本和站点不兼容 | 开启无痕模式new_context()时不复用 storage_state;升级 Playwright 版本;换channel="chrome" |
| 代码能跑但数据永远为空 | 接口 URL 过滤条件错误、响应是加密 JSON | 在handle_response里把匹配到的 URL 全部打印出来,人工确认数据接口的真实地址 |
我重点说一下第二行的“提取到的数据有整段缺失”这个问题。新手最容易把它当成选择器写错了,疯狂调整class名称。但真正的罪魁祸首往往是图片懒加载:页面只在商品卡片可见时才加载图片和附属数据,你没滚动到那一屏,那部分的 DOM 节点是占位符,根本没有文本内容,你当然提取不到。解决办法就是多滚动,滚到每个商品都真实渲染出来再去提取。
还有那个“内存占用越来越大”的问题,这个是长任务跑久了几乎必然出现的。浏览器内核本身就是内存大户,再加上多个 context、事件监听器、缓存,跑个几小时轻轻松松吃掉你几个 G 内存。我的做法是写一个“每 N 个页面重启一次浏览器”的调度逻辑,在重试框架外层把 browser 关掉再重新launch,虽然听起来粗暴,但实操中比各种精细调优都有效。
6. 最后说几句合规与经验
爬虫写多了以后,我对“能不能爬”这个问题有了更深的理解。技术能力只是前提,边界意识才是决定你能不能长期做这行的关键。
我的原则是:只采集公开可见的数据,只用于个人学习或数据分析,不碰用户隐私、不抓交易记录、不批量下载受版权保护的内容。采集频率控制在对目标站点影响最小的水平,每个任务跑完主动歇一歇,给服务器留出喘息时间。尊重robots.txt,虽然它只是君子协定,技术上一千种办法绕过,但遵守它是从业底线。
实操中还有一个特别值得说的经验:不要执着于让一个脚本一次性解决所有问题。我之前为了抓一个复杂页面的数据,连续调了三天脚本,后来发现把采集拆成“抓列表→抓详情→存库”三个阶段,每个阶段独立跑、中间用数据表拼接,反而简单得多,出了问题也好排查。工程化思维在爬虫领域同样适用,这是很多自学爬虫的朋友容易忽略的。
如果你准备把这套方案用在自己的项目上,我建议从监听接口那个方案开始写,数据结构最干净,后续处理最省事。遇到接口加密再退到 DOM 解析,灵活切换,不要一条路走到黑。最后还是要再说一遍,合法合规使用技术、尊重平台规则,才能让爬虫这条路走得长远。