如果你曾经对着拼多多百亿补贴的页面按下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在很多细节上更省事对比项SeleniumPlaywright浏览器驱动需要单独下载、匹配版本比较麻烦一条命令自动下载对应浏览器内核等待机制拿手写time.sleep WebDriverWait内置自动等待操作元素前自动检查可交互状态多页面/多标签需要手动切换handle原生支持多context、多page隔离性好网络拦截与监听支持较弱支持路由拦截、请求监听方便调试截图/视频录制需要额外配置内置截图、录制视频排查问题非常直观当然Playwright也不是没有缺点它的生态相对年轻部分老教程都基于Selenium。但就动态数据提取这个场景来说Playwright的等待机制和浏览器内核管理方式明显更贴合需求这也是我最终选择它的理由。2. 环境搭建和Playwright的基本姿势2.1 三步装好环境环境搭建非常直接核心只有两条命令pip install playwright playwright install chromium第一行是安装Python库第二行是下载Chromium浏览器内核。很多人只执行第一行就开始写代码运行时报错Executable doesnt 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(headlessFalse) # 先不要无头 page browser.new_page() page.goto(https://example.com) page.wait_for_timeout(3000) browser.close()第一次跑建议把headless设成False让浏览器窗口弹出来。这样你能直观看到页面加载过程也能确认浏览器是否正常启动。确认没问题以后再根据需求改成headlessTrue。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_untildomcontentloaded) page.wait_for_selector([class*goods], timeout15000)第一行goto时指定domcontentloaded意思是等到HTML基本解析完成就继续不一定等所有资源加载完。第二行wait_for_selector则专门等待商品卡片那个选择器出现超时15秒还没出现就报错。这是动态数据爬取最稳定的写法——不要等整个页面加载完而是等你要的数据出现。如果你用wait_untilnetworkidle在很多持续加载的页面上会等到超时甚至永远无法返回因为页面不断有新的网络请求。3. 拆解百亿补贴页面的DOM结构和数据定位3.1 先确认页面上的数据字段拿到页面后不要急着写代码先手工打开浏览器用Playwright的headlessFalse模式跑一遍或者直接在浏览器开发者工具里观察。百亿补贴商品卡片通常包含以下字段字段说明示例商品标题商品主文案苹果 iPhone 15 128GB 官方标配补贴价百亿补贴后的价格¥4999原始价/划线价补贴前价格¥5999已拼件数累计拼单量已拼10万件店铺名称商家名称XX数码旗舰店商品链接跳转详情页的URLhttps://...补贴标签百亿补贴角标文字角标把字段清单列清楚后面写提取代码才知道自己要什么。我习惯先写一个简单的调试片段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-testgoods-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(headlessFalse) context browser.new_context() page context.new_page() page.goto(https://mobile.yangkeduo.com/) # 让用户手动扫码/登录 input(请在浏览器中完成登录然后按回车继续...) # 保存登录状态 context.storage_state(pathpdd_state.json) browser.close()下次运行时直接加载这个状态文件context browser.new_context(storage_statepdd_state.json) page context.new_page() page.goto(https://mobile.yangkeduo.com/)要注意的是登录态有有效期过几天可能失效失效后重新跑一次登录流程就行。这个方法只用于你自己账号的学习研究不要拿去买号、批量养号之类的事情。4.2 浏览器指纹的伪装细节反爬系统除了看请求频率还会看浏览器指纹。最容易被识别的参数包括User-Agent、屏幕分辨率、语言、时区等。Playwright创建context的时候可以很方便地设置这些参数context browser.new_context( user_agentMozilla/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}, localezh-CN, timezone_idAsia/Shanghai, color_schemelight, )还有一点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_keyTrue, autoincrementTrue) title Column(String(500), nullableFalse) price Column(Float, default0) sold Column(Integer, default0) shop Column(String(200), default) link Column(String(500), default) created_at Column(DateTime, defaultdatetime.now) engine create_engine(sqlite:///pdd_products.db) Base.metadata.create_all(engine) Session sessionmaker(bindengine)插入数据时我习惯用批量提交而不是一条一条commit这样效率高得多session Session() session.add_all([ Product(title商品A, price4999.0, sold100000, shopXX旗舰店, link...), Product(title商品B, price39.9, sold20000, shopYY专营店, 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。问题来了有些站点对无头浏览器的检测非常灵敏表现为页面一片空白、数据加载不出来或者请求直接跳到验证页。遇到这种情况我的第一反应不是去研究更高级的伪装而是先试着改为headlessFalse跑一次看数据能不能正常加载。如果改回有头模式就恢复正常说明问题大概率出在无头模式的特征被识别了。这时候有几个调整方向使用headlessFalse但把窗口移到后台或者用虚拟显示器工具承载它给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_statepdd_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的截图和录屏功能快速对比新旧页面差异。我个人最推荐的是往价格历史追踪这个方向扩展。百亿补贴的价格会波动如果能持续记录某几件商品的价格变化就能分析到底什么时间入手更划算。这个方向既有趣也完全在合规范围内——数据量小、频率低、只做个人分析。爬虫这项技术本身是中性的关键看你怎么用它。希望这篇实战经验能帮你在自动化提取动态数据这条路上开个好头。