如果你曾经对着拼多多百亿补贴的页面按下F12,大概率会和我一样愣住:商品标题、价格、已拼件数全都在页面上,但切到"查看网页源代码",却只剩一堆看不懂的script标签。这个频道的核心数据几乎全靠JavaScript动态渲染,传统的requests抓包方案到了这里直接失效。我从去年开始尝试用Playwright做浏览器自动化,几轮实测下来,发现它确实是最适合啃这块骨头的东西。这篇文章就记录我完整跑通"自动化提取百亿补贴动态商品数据"的过程——从环境搭建、DOM定位、登录态处理,到数据清洗入库,以及那些网上不太会写明白的坑。如果你是刚入门的Python爬虫爱好者,或者已经用惯了Selenium但想换个更顺手的自动化工具,这篇应该能帮你少走不少弯路。
1. 为什么最终选择Playwright来做这件事
1.1 拼多多页面不是普通的"静态网页"
很多新手以为爬虫就是"requests拿HTML、正则抠数据",这套思路放在早年静态网页时代完全成立,但遇到百亿补贴这种页面就废了。原因在于页面数据不是服务端直接拼好的,而是先返回一个空壳HTML,再由浏览器执行JavaScript、异步请求接口、动态创建DOM节点。你直接请求页面URL,得到的HTML里只有一堆启动脚本和固定的框架标签,真正的商品卡片一个都找不到。
这里可以用一个比喻:服务端只给你交付了一套毛坯房,墙体结构(页面框架)有了,但水电、家具、灯光(商品数据)全是后面装修师傅(JavaScript)进场才装上。requests这个工具站在毛坯房门口往里看,自然什么都拿不到。而Playwright这类浏览器自动化工具,等于直接把整支装修队一起带进去——它会让真实浏览器完整执行页面脚本,最终把所有渲染完成的内容呈现在你面前。这个差异是选择工具的核心出发点。
1.2 requests/BeautifulSoup方案为什么在这里失灵
即便你去抓接口地址,试图绕过页面直接请求JSON数据,依然会撞上三堵墙:
第一堵墙:接口地址动态变化。页面里的商品列表可能对应一个接口,但这个URL路径和参数会随时间变化,写死在代码里隔几天就失效。
第二堵墙:签名参数。平台通常会对请求参数做签名校验,比如带上时间戳、加密串、设备信息等。你手动造出来的请求很容易因为缺少某个签名而被拒绝。
第三堵墙:行为检测。就算你成功模拟了接口,平台后端的风控也能看出这不是人类操作——请求频率、头信息、访问路径都太规律了。
所以我不建议走"逆接口"路线,维护成本高且容易触发法律和平台风控风险。相比之下,Playwright让真实浏览器去操作页面,从技术链路看更像一个普通用户。
1.3 Playwright和Selenium的取舍对比
不少朋友问我:我之前用的是Selenium,要不要换Playwright?我的实际体会是,如果只写简单脚本,两者都能跑;但Playwright在很多细节上更省事:
| 对比项 | Selenium | Playwright |
|---|---|---|
| 浏览器驱动 | 需要单独下载、匹配版本,比较麻烦 | 一条命令自动下载对应浏览器内核 |
| 等待机制 | 拿手写time.sleep + WebDriverWait | 内置自动等待,操作元素前自动检查可交互状态 |
| 多页面/多标签 | 需要手动切换handle | 原生支持多context、多page,隔离性好 |
| 网络拦截与监听 | 支持较弱 | 支持路由拦截、请求监听,方便调试 |
| 截图/视频录制 | 需要额外配置 | 内置截图、录制视频,排查问题非常直观 |
当然Playwright也不是没有缺点,它的生态相对年轻,部分老教程都基于Selenium。但就"动态数据提取"这个场景来说,Playwright的等待机制和浏览器内核管理方式明显更贴合需求,这也是我最终选择它的理由。
2. 环境搭建和Playwright的基本姿势
2.1 三步装好环境
环境搭建非常直接,核心只有两条命令:
pip install playwright playwright install chromium第一行是安装Python库,第二行是下载Chromium浏览器内核。很多人只执行第一行就开始写代码,运行时报错"Executable doesn't exist",就是漏了第二行。这里要解释一下:Playwright是一个驱动库,它本身不带浏览器,需要单独下载配套内核才能工作。如果你想用Firefox或WebKit,也可以换成playwright install firefox或playwright install webkit,但我实测下来Chromium兼容性最好。
装完以后可以跑一个最小验证脚本:
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") page.wait_for_timeout(3000) browser.close()第一次跑建议把headless设成False,让浏览器窗口弹出来。这样你能直观看到页面加载过程,也能确认浏览器是否正常启动。确认没问题以后,再根据需求改成headless=True。
2.2 同步API和异步API怎么选
Playwright提供两套API:同步版(sync_api)和异步版(async_api)。两者功能完全一样,区别在于写法:
# 同步版 from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch() page = browser.new_page() page.goto("https://example.com") ...# 异步版 import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: browser = await p.chromium.launch() page = await browser.new_page() await page.goto("https://example.com") ... asyncio.run(main())我的建议很直接:新手和中小型爬虫用同步API。理由有三个:第一,同步代码直观,出问题容易定位;第二,爬虫天然是"启动浏览器→访问页面→拿数据→关浏览器"的线性流程,不需要并发;第三,同步阻塞反而天然限制了请求频率,不容易触发风控。只有当你要同时维护几十上百个页面、确实需要高并发时,才值得上异步版。
2.3 等待机制是动态数据提取的灵魂
使用Playwright动态提取数据时,最核心的概念就是"等待"。你打开百亿补贴页面后,商品数据不是瞬间出现的,而是异步加载的。如果你立刻去查DOM,很可能什么都查不到。
Playwright有两种等待方式:显式等待和自动等待。自动等待的意思是,当你调用click、fill、text_content这类操作时,Playwright会先自动检查元素是否存在、是否可见、是否可交互,满足条件才执行操作。这比Selenium的裸操作稳得多。
但自动等待只针对你指定的元素,页面加载本身还需要显式控制。我常用的套路是:
page.goto(url, wait_until="domcontentloaded") page.wait_for_selector("[class*=goods]", timeout=15000)第一行goto时指定domcontentloaded,意思是等到HTML基本解析完成就继续,不一定等所有资源加载完。第二行wait_for_selector则专门等待商品卡片那个选择器出现,超时15秒还没出现就报错。这是动态数据爬取最稳定的写法——不要等整个页面加载完,而是等你要的数据出现。如果你用wait_until="networkidle",在很多持续加载的页面上会等到超时甚至永远无法返回,因为页面不断有新的网络请求。
3. 拆解百亿补贴页面的DOM结构和数据定位
3.1 先确认页面上的数据字段
拿到页面后不要急着写代码,先手工打开浏览器,用Playwright的headless=False模式跑一遍,或者直接在浏览器开发者工具里观察。百亿补贴商品卡片通常包含以下字段:
| 字段 | 说明 | 示例 |
|---|---|---|
| 商品标题 | 商品主文案 | 苹果 iPhone 15 128GB 官方标配 |
| 补贴价 | 百亿补贴后的价格 | ¥4999 |
| 原始价/划线价 | 补贴前价格 | ¥5999 |
| 已拼件数 | 累计拼单量 | 已拼10万+件 |
| 店铺名称 | 商家名称 | XX数码旗舰店 |
| 商品链接 | 跳转详情页的URL | https://... |
| 补贴标签 | "百亿补贴"角标 | 文字角标 |
把字段清单列清楚,后面写提取代码才知道自己要什么。我习惯先写一个简单的调试片段:
page.goto(url) page.wait_for_selector("[class*=goods]") print(page.locator("body").inner_text()) # 打印页面全部文字先看看页面上文字长什么样,再决定怎么定位元素。有些字段在页面里是"券后价"、"已拼xxx件"这种带前后缀的文本,光看不处理会直接污染数据。
3.2 选择器的定位思路:CSS属性优先,XPath兜底
定位元素是动态爬虫里最花时间的环节。百亿补贴页面的DOM结构会随版本更新变化,今天能用的class名明天可能就改了。我的经验是:优先选择稳定的>cards = page.locator("div[data-test='goods-item']") count = cards.count() print(f"找到 {count} 个商品卡片") for i in range(count): card = cards.nth(i) title = card.locator(".goods-title").inner_text() price = card.locator(".price").inner_text() sold = card.locator(".sold-count").inner_text() print(title, price, sold)
如果找不到>sold_ele = page.locator("//span[contains(text(), '已拼')]").first
这种定位方式不依赖具体class名,只要页面里有"已拼"两个字的文案就能命中。缺点是速度略慢,但爬虫场景完全够用。
这里还有一个实操细节:不要一次性把所有卡片的句柄都存进列表再遍历。因为页面滚动后,之前获取的元素可能被重新渲染,句柄就会失效。正确的做法是每次需要遍历时重新调用page.locator来获取最新结果。
3.3 滚动加载与懒加载的处理
百亿补贴商品列表是典型的懒加载页面——你往下滚动,页面才会继续请求下一批商品。如果只抓第一屏,可能只有十来个商品,这显然不够。
模拟滚动的方式有两种。第一种是鼠标滚轮:
for step in range(10): page.mouse.wheel(0, 800) # 向下滚800像素 page.wait_for_timeout(1000) # 停1秒,等新内容渲染第二种是JS滚动:
page.evaluate("window.scrollBy(0, 800)") page.wait_for_timeout(1000)两种方式效果接近,但我个人更推荐mouse.wheel,因为它模仿了真实鼠标操作,在风控层面更自然。滚动节奏务必克制:每滚一屏停1到2秒,整体速度保持在一个正常人类翻页浏览的水平。如果滚动太快,页面可能直接停止加载新商品,或者触发风控返回验证页面。
滚动加载还有一个判断技巧:重复滚动几次后,对比当前页面上的商品卡片数量是否还在增长。如果连续几次数量都不变,说明已经到底了,可以停止。
4. 登录态持久化和反反爬的实操取舍
4.1 用storage_state保存登录状态
百亿补贴的部分信息只有登录后才能看全,比如完整的补贴价、优惠券状态。每次都走扫码登录太烦,Playwright提供了一个很实用的机制:storage_state。它可以把当前context里的cookie、localStorage等状态保存成JSON文件,下次启动时直接加载。
第一次登录时,我建议写成半自动模式:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=False) context = browser.new_context() page = context.new_page() page.goto("https://mobile.yangkeduo.com/") # 让用户手动扫码/登录 input("请在浏览器中完成登录,然后按回车继续...") # 保存登录状态 context.storage_state(path="pdd_state.json") browser.close()下次运行时,直接加载这个状态文件:
context = browser.new_context(storage_state="pdd_state.json") page = context.new_page() page.goto("https://mobile.yangkeduo.com/")要注意的是,登录态有有效期,过几天可能失效,失效后重新跑一次登录流程就行。这个方法只用于你自己账号的学习研究,不要拿去买号、批量养号之类的事情。
4.2 浏览器指纹的伪装细节
反爬系统除了看请求频率,还会看浏览器指纹。最容易被识别的参数包括User-Agent、屏幕分辨率、语言、时区等。Playwright创建context的时候可以很方便地设置这些参数:
context = browser.new_context( 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", viewport={"width": 1280, "height": 720}, locale="zh-CN", timezone_id="Asia/Shanghai", color_scheme="light", )还有一点:Playwright的页面里navigator.webdriver默认已经被处理过,但为了更稳,可以加一段初始化脚本,把webdriver属性遮掉:
context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); """)这只是让浏览器环境更接近普通用户,不是绕过什么安全机制。我的原则是:只做正常浏览器能完成的自动化操作,不破解签名、不识别验证码。这样既能把技术链路控制在合理范围内,也避免把自己置于不必要的风险中。
4.3 遇到验证码和风控的正确姿势
坦白说,任何平台都可能弹出验证码。弹出验证码时,我从来不写自动识别逻辑,原因很简单:识别验证码这个动作本身就属于绕过平台的访问控制措施,技术上有争议,法律上风险高。我的处理方式是:脚本里检测到验证码特征(比如出现"请完成验证"字样)就停止,留出时间让操作者手动处理,或者直接放弃这一批数据。
风控出现前通常有信号,比如页面连续几次返回空数据、响应时间突然变长、页面上的内容被替换成异常文案。一旦发现这些信号,我建议立即降速。最直接的做法是随机睡眠:
import random import time # 每次操作之间随机休息1~4秒 time.sleep(random.uniform(1, 4))这个随机区间不是玄学,它模拟的是人类切换商品、阅读标题、犹豫要不要点进去的真实节奏。固定间隔1秒反而更容易被识别——正常人类不会像机器一样精准卡点。
5. 商品数据清洗和结构化存储
5.1 价格、销量等字段的清洗逻辑
从页面上直接抓到的文本基本都不能直接用。比如价格可能长这样:"¥12.9起"、"券后价¥8.8"、"到手价4999元"。已拼件数可能是"已拼10万+件"。这些字符串必须先清洗成结构化数据。
我的清洗函数大致长这样:
import re def clean_price(raw: str) -> float: """从价格字符串中提取数字,返回float。""" if not raw: return 0.0 text = raw.replace(",", "").replace(" ", "") match = re.search(r"\d+(\.\d+)?", text) return float(match.group()) if match else 0.0 def clean_sold(raw: str) -> int: """从销量字符串中提取整数。支持单位:件、万+。""" if not raw: return 0 text = raw.replace("已拼", "").replace("件", "").replace("+", "").strip() match = re.search(r"(\d+(\.\d+)?)", text) if not match: return 0 if "万" in text: return int(float(match.group(1)) * 10000) return int(float(match.group(1)))这里有几个实际问题:价格里的逗号是千位分隔符,必须去掉,不然正则匹配会出错;销量里"10万+"的意思是100000以上,"+"号保留与否影响后续统计口径,我习惯统一把"10万+"转成100000,并单独用一个字段记录单位。
5.2 用SQLAlchemy建表并入库
清洗完的数据需要一个地方存。我最常用的组合是SQLite + SQLAlchemy,零配置、单文件、可迁移。先建模型:
from datetime import datetime from sqlalchemy import create_engine, Column, String, Float, Integer, DateTime from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Product(Base): __tablename__ = "pdd_products" id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(500), nullable=False) price = Column(Float, default=0) sold = Column(Integer, default=0) shop = Column(String(200), default="") link = Column(String(500), default="") created_at = Column(DateTime, default=datetime.now) engine = create_engine("sqlite:///pdd_products.db") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)插入数据时,我习惯用批量提交而不是一条一条commit,这样效率高得多:
session = Session() session.add_all([ Product(title="商品A", price=4999.0, sold=100000, shop="XX旗舰店", link="..."), Product(title="商品B", price=39.9, sold=20000, shop="YY专营店", link="..."), ]) session.commit() session.close()这里要提醒一句:SQLAlchemy默认连接SQLite时,多个线程不能共享同一个session。爬虫脚本一般单线程跑,问题不大;如果你后面改成多线程,就要给每个线程单独创建session。
5.3 断点续爬和去重设计
爬虫跑久了难免中断。网络波动、页面改版、内存不足,任何一个都可能让脚本挂掉。如果没有断点续爬机制,重跑一遍会把重复数据再入库一遍。
我的做法是用商品链接当作自然唯一键。入库前先查一下这条链接是否已存在:
from sqlalchemy import select def product_exists(session, link: str) -> bool: stmt = select(Product.id).where(Product.link == link) return session.execute(stmt).first() is not None在提取循环里,拿到一个商品链接后就先判断,已存在的直接跳过,不存在才清洗入库。同时我会定期把访问过的URL集合写到本地文件,或者直接用数据库本身记录,这样即使脚本中断,下次启动也能从断点继续。
另外建议每条数据提取完以后,把原始页面片段或者截图保存一份到本地目录。这样后续如果发现数据有误,还能回去对照页面实际内容,不用重新跑一遍全流程。我用的是Playwright自带的element.screenshot和page.screenshot,实测很稳。
6. 阶段性踩坑记录:超时、内存泄漏和选择器失效
6.1 等待时间不是越长越好,页面可能动态重载
第一个坑就是显式等待设得太长。wait_for_selector超时时间设成60秒,看起来稳妥,实际上很多页面在3秒内就渲染完成,多等的时间纯粹浪费。另一个更隐蔽的问题是,有些页面在你滚动后会把旧DOM节点整块替换掉——你之前拿到的element handle就变成了"僵尸引用",再操作就会报错Element is not attached to the DOM。
解决方案是:滚动之后不要继续使用旧的卡片句柄,而是重新执行一次page.locator重新查询。每轮循环只做"查询→提取→丢弃句柄"这一件事,不要把一个句柄保存太长时间。
6.2 headless模式被识别时怎么办
爬虫跑在服务器上,通常会把headless设成True。问题来了:有些站点对无头浏览器的检测非常灵敏,表现为页面一片空白、数据加载不出来,或者请求直接跳到验证页。
遇到这种情况,我的第一反应不是去研究"更高级的伪装",而是先试着改为headless=False跑一次,看数据能不能正常加载。如果改回有头模式就恢复正常,说明问题大概率出在无头模式的特征被识别了。这时候有几个调整方向:
- 使用
headless=False,但把窗口移到后台,或者用虚拟显示器工具承载它; - 给context设置更完整真实的指纹参数(UA、分辨率、时区等);
- 减少单次任务量,缩短每次运行的持续时间,避免长时间高频访问。
我不建议去研究什么"完美过检测"方案,那样既费时间又容易越界。更务实的做法是接受一部分页面拿不到数据,做小批量、低频率采集。
6.3 长时间运行内存上涨的处理
Playwright脚本如果设计成"一个浏览器实例跑几千个页面",内存大概率会一路飙升。这个问题在爬虫场景里很常见,尤其开着视频录制或截图功能时更明显。
我在实际项目里的处理思路是:每处理完一批商品(比如50个),就关闭旧的context,重新创建一个新的context和page。这样浏览器进程会定期回收,内存不会无限增长。伪代码结构大致如下:
with sync_playwright() as p: browser = p.chromium.launch() for batch in range(1, 20): context = browser.new_context(storage_state="pdd_state.json") page = context.new_page() try: # 爬取一批数据 pass finally: context.close() # 释放资源 browser.close()还有一点容易被忽略:脚本崩溃或被人为中断时,浏览器进程可能没被正常关闭,残留一堆后台进程占内存。我的习惯是在主流程里用try/finally保证browser.close()一定执行,同时在写日志时记录每个阶段的状态,方便排查到底哪一步出了问题。
6.4 日志和错误恢复是不可省的一环
很多爬虫脚本从头到尾没有一行日志,出了问题只能干瞪眼。我强烈建议从一开始就用logging模块记录关键节点:打开了哪个页面、找到了几件商品、哪一条数据入库失败、失败原因是什么。这样哪怕脚本半夜崩了,第二天打开日志就能快速定位,不用重新人工跑一遍去复现问题。
如果任务比较复杂,还可以把单步操作拆成函数,用pytest写几个冒烟测试用例。比如测试clean_price能不能正确处理"¥12.9起",测试clean_sold能不能正确解析"已拼10万+件"。这些测试用例的价值在页面改版、数据库结构调整的时候会体现得非常明显。
7. 关于合规和边界,我必须说两句
7.1 爬虫的合法前提
聊到这里,我还得说点实在话。爬虫技术和"能不能爬某个平台"是两个问题。技术上跑得通,不代表做事就合理。就我个人而言,做这类数据采集只围绕三个前提展开:
- 只采集公开可访问的数据,不借助任何绕过登录、绕过验证的方式去拿非公开内容;
- 仅用于个人学习、技术验证或非商业用途,不做二次倒卖、不当竞品情报工具;
- 严格遵守平台的服务条款和相关法律法规,遇到明确的禁止爬取声明立即停止。
这个边界一定要心里有数。标题里写的是"Python爬虫实战",那是技术层面的实战,不是鼓励你去跟平台的风控系统硬刚。
7.2 我在实际项目里的几条原则
具体到执行层面,我自己给自己定了几条硬规矩,也建议你做类似的动作:
- 单次任务时长不超过一个小时,跑完即停,不搞7x24小时挂机;
- 请求频率始终控制在"像人翻手机"的级别,绝不用并发轰炸;
- 不识别验证码、不绕过签名、不模拟真人行为去做注册、下单等操作;
- 看到页面出现风控提示,马上停止,过一段时间再试。
这几条原则不是从教程里抄来的,是踩过坑以后总结出来的。最直接的收益是:我的脚本存活时间明显变长,封IP、封账号这类糟心事几乎绝迹,而且心理上踏实得多。
7.3 这个脚本还能怎么扩展
最后聊一下扩展方向。这套技术验证完毕后,如果你仍对动态数据提取有兴趣,可以把思路进一步拓宽:
- 结合pytest组织自动化测试用例,把选择器验证、数据清洗逻辑纳入回归体系;
- 用SQLAlchemy搭配更完整的字段设计,把商品快照、价格变化历史存起来,做价格走势分析;
- 把Playwright和调度工具结合,每天固定时间运行一次,形成定时数据采集任务;
- 页面结构变化时,用Playwright的截图和录屏功能快速对比新旧页面差异。
我个人最推荐的是往"价格历史追踪"这个方向扩展。百亿补贴的价格会波动,如果能持续记录某几件商品的价格变化,就能分析到底什么时间入手更划算。这个方向既有趣,也完全在合规范围内——数据量小、频率低、只做个人分析。爬虫这项技术本身是中性的,关键看你怎么用它。希望这篇实战经验能帮你在自动化提取动态数据这条路上开个好头。