用Playwright打造闲鱼商品监控助手:异步渲染、定时抓取与实时通知

📅 发布时间:2026/10/21 8:40:29
用Playwright打造闲鱼商品监控助手:异步渲染、定时抓取与实时通知
1. 整体设计与技术选型思路1.1 为什么需要做这样一个监控助手闲鱼这类二手交易平台有一个很典型的特点商品时效性极强。真正的好价、稀缺型号、低价配件往往挂出来几个小时甚至几十分钟就被拍走了。人工盯页面最大的问题不是懒而是你根本不知道商品什么时候上架。我一开始是每隔半小时手动刷新一次搜索结果后来发现真要蹲的东西半小时这个窗口足够别人完成三次刷新-看到-下单的操作了。所以这个项目的核心需求其实就一句话把盯着页面这件事交给程序人来负责判断要不要买。为了实现这个目标程序需要做三件事——定时去闲鱼搜索页抓取新上架的商品、把抓到的数据和之前的数据做对比、发现有新商品时立刻推送到手机上。整个链路听起来不复杂但真正落地的时候会遇到不少细节问题比如登录态怎么保持、页面结构变了怎么办、怎么避免重复通知轰炸自己。这篇文章会把我在模拟项目X里踩过的坑和最终跑通的方案完整拆出来。1.2 为什么选Playwright而不是requests或Selenium先聊聊技术选型。很多人第一反应是用requests直接请求闲鱼的接口毕竟浏览器自动化慢、吃资源、还容易被识别。这个思路理论上可行但实操层面问题很大闲鱼的Web端接口有签名和加密参数请求头里缺一个值或者加密逻辑变了结果就是空数据。除非你专门做逆向否则requests直连这条路对普通开发者来说维护成本高到离谱。Selenium也能做但它在现代Web面前有几个明显的毛病启动慢、依赖WebDriver版本匹配、对异步渲染页面的等待机制不够友好。Playwright解决的就是这些问题——它内置浏览器内核管理、自动等待机制、还有一套非常优雅的定位API。更重要的是Playwright的page.goto()配合wait_for_selector()可以很稳地等到闲鱼的商品卡片渲染完成再动手抓取不会出现页面还没加载完就去抓DOM这种经典悲剧。实际用下来我的感受是Playwright的稳定性相比Selenium是降维打击。同一个选择器在Selenium里偶尔会因为页面异步加载丢元素Playwright内置的auto-wait会自动重试到元素可交互为止。再加上Playwright的代码生成器可以录制定位器开发调试体验好不少。1.3 监控架构的整体设计整个系统的架构可以拆成四层调度层定时触发抓取任务这里用APScheduler的AsyncIOScheduler支持cron表达式。采集层Playwright启动浏览器内核加载登录态Cookie访问搜索页并滚动到底触发懒加载。解析层用Playwright的locator定位商品卡片提取标题、价格、链接、发货地、卖家信息写入SQLite数据库做去重。通知层新商品ID入库后触发推送走SMTP邮件或企业微信Webhook机器人手机上能被叫醒。这种分层的好处是每一层都可以单独替换。比如通知层今天用邮件觉得自己没被吵醒想换成短信服务或者自定义Webhook只需要改一个函数其他部分完全不用动。我最初写这个项目时是抓取和通知写在一个函数里结果每次调通知格式都要在抓取逻辑里找位置后来才痛下决心拆开。这里有一个很重要的设计决策需要讲清楚为什么不能用while True死循环 sleep来做定时因为监控脚本一旦跑起来就是长时间挂机死循环写起来简单但你没法精细控制每天几点跑、周末停不停、重试策略怎么安排。APScheduler这类调度框架天然支持这些场景而且挂了之后还能自动恢复任务状态。2. 环境准备与核心关键点2.1 基础环境搭建这个项目对Python版本的要求不高3.9以上就能跑得很舒服。我建议直接用Python 3.10或更高版本主要是因为类型注解和异步语法支持得更完整。首先创建一个虚拟环境python -m venv monitor_env source monitor_env/bin/activate # Windows下用 monitor_env\Scripts\activate然后安装项目依赖pip install playwright apscheduler requests playwright install chromium这里有个容易忽略的细节playwright install chromium这一步是必须的它会下载一个独立的Chromium内核。很多人装完playwright库就直接跑了结果报错找不到浏览器二进制文件。另外如果你在Linux服务器上跑还需要先装一遍操作系统级的依赖playwright install-deps chromium这个命令会拉取Chromium运行时需要的动态链接库缺了它启动浏览器时会报类似libnss3.so: cannot open shared object file的错误。我最初把脚本丢到一台轻量服务器上跑就栽在这个坑里代码完全一样本地能跑服务器上就是起不来。2.2 登录态保持为什么不能只靠游客访问闲鱼的搜索结果页游客身份是可以打开的但问题有三个第一游客身份能看到的商品数量明显少于登录用户有些卖家设置了仅粉丝可见或者平台做了策略限制第二游客身份触发滑块验证的频率高得多第三如果你需要监控的是我想要列表里某类商品的降价那就必须登录后才能看到状态变化。登录态保持这一步我推荐用扫码登录 Cookie持久化的方式。流程是这样的手动启动一个非headless的浏览器窗口打开闲鱼登录页。用手机闲鱼APP扫码登录。登录成功后从浏览器上下文里拿到Cookie字典序列化成JSON存到本地。import json import asyncio from playwright.async_api import async_playwright async def save_login_state(): async with async_playwright() as p: browser await p.chromium.launch(headlessFalse) context await browser.new_context() page await context.new_page() await page.goto(https://www.goofish.com/) input(扫码登录完成后按回车继续...) # 保存storage_state包含localStorage和cookies await context.storage_state(path./state.json) await browser.close() if __name__ __main__: asyncio.run(save_login_state())用storage_state而不是手动提取Cookie字典好处是它把localStorage也一并保存了。闲鱼的风控系统会检查一些本地存储的标识字段只保存Cookie的话换一台机器跑大概率还是会触发验证码。我实测下来用storage_state完整保存登录状态连续跑三天都不会掉登录。2.3 页面元素的定位与等待策略定位闲鱼商品卡片核心问题是它的页面结构。闲鱼新版Web端也就是goofish域名走的是React渲染DOM结构经常变但商品卡片有几个稳定的特征图片容器用了lazyload懒加载、标题元素有固定的class前缀、价格字段有一个唯一的类名。所以最稳的定位方式是组合定位先按大容器找再在容器内按文本定位。我当前用的定位器长这样# 等待商品卡片区块出现 await page.wait_for_selector(div[class*item-card], timeout15000) # 在卡片范围内提取标题 loc_card page.locator(div[class*item-card])Playwright的locator是惰性求值的它不会在创建的时候立刻去查DOM而是在调用操作时才去匹配。这个设计配合wait_for_selector效果很好能自然规避页面还在加载时的空列表报错。关于滚动懒加载闲鱼的搜索列表是一次性加载前面若干条然后滚动到底部才加载更多。如果只看第一页前20条会漏掉不少商品。所以抓取逻辑里要模拟滚动每次滚动一屏等待1.5秒再继续滚直到页面底部出现没有更多了或者滚动后卡片数不再增加。3. 核心代码实现与运行机制3.1 搜索URL的构造与遍历策略闲鱼的搜索URL结构相对稳定核心参数就是q搜索关键词和sortType排序方式。构造URL时用urllib.parse.quote处理关键词编码避免中文乱码import urllib.parse base_url https://www.goofish.com/search params { q: 索尼a7m3, sortType: default # default为综合排序newly为最新发布 } url base_url ? urllib.parse.urlencode(params)这里有一个非常重要的监控思路监控最新发布排序还是综合排序我的建议是抓两个排序的结果做并集去重。因为最新发布能最快看到新上架但闲鱼这个排序不稳定偶尔会出现排序错乱而综合排序会漏掉一些新发布的冷门商品。两个都抓去重后取并集能最大限度降低漏单率。扩展监控关键词时建议维护一个关键词列表每个关键词跑一遍完整流程间隔不要太短关键词之间错开1-2秒避免瞬间发起多个请求被风控。3.2 商品数据的提取与结构化定位到商品卡片后需要提取的数据项包括商品标题、商品价格、商品链接、发货地、卖家昵称。逐个讲一下提取时要注意的细节。标题提取容易踩坑的在于闲鱼会把想要人数、浏览量混在文案里或者标题被截断。我的处理是只取title属性或者特定的class文本然后做一轮清洗去掉emoji、去掉连续空白、截断到50个字符。价格提取反而简单闲鱼的价格标签特征非常明显有独立的class文本格式是¥XX开头。商品链接的提取要特别留意不能用href直接拼。闲鱼的跳转链接通常带一堆追踪参数我在实践里只保留id字段再拼接规范链接这样后续查重和去重都方便。async def extract_item(card): title_loc card.locator(div[class*title]).first price_loc card.locator(div[class*price]).first link_loc card.locator(a).first title (await title_loc.inner_text()).strip() price_str (await price_loc.inner_text()).strip().replace(¥, ).replace(,, ) href await link_loc.get_attribute(href) item_id extract_id_from_href(href) return { item_id: item_id, title: clean_title(title), price: float(price_str) if price_str.replace(., ).isdigit() else -1, url: fhttps://www.goofish.com/item?id{item_id} }3.3 SQLite去重为什么要用数据库而不是内存列表去重逻辑看起来很简单——用列表把所有见过的item_id存起来每次新抓到的判断一下在不在里面。这个方案在小规模验证时没毛病但长期监控就会出现一个问题程序重启后内存列表清空所有已见过的商品会被当成新品重新通知一遍你就等着手机被刷屏吧。所以我用了SQLite做持久化去重核心表结构如下CREATE TABLE IF NOT EXISTS seen_items ( item_id TEXT PRIMARY KEY, title TEXT NOT NULL, price REAL NOT NULL DEFAULT 0, first_seen_at TEXT DEFAULT (datetime(now, localtime)), notified INTEGER DEFAULT 0 );notified字段非常重要。如果你一次抓到了10个新商品通知推送时有几条失败了这10条里只有成功的才标记为1没成功的在下一轮还能补推。这个机制弥补了通知发送失败导致漏消息的问题。3.4 实时通知邮件和服务端推送通知模块我实现了两种渠道。第一种是SMTP邮件适合不想引入第三方依赖的场景每个Python环境都能发代码也不复杂import smtplib from email.mime.text import MIMEText from email.header import Header def send_mail(subject, body, to_addr): smtp_host smtp.qq.com smtp_port 465 username your_emailqq.com password your_auth_code msg MIMEText(body, plain, utf-8) msg[Subject] Header(subject, utf-8) msg[From] username msg[To] to_addr with smtplib.SMTP_SSL(smtp_host, smtp_port) as server: server.login(username, password) server.send_message(msg)QQ邮箱、网易邮箱都支持SMTP但要注意不是用登录密码登录SMTP而是用邮箱后台生成的授权码。第一次发邮件失败90%的原因是密码填成了登录密码。第二种是企业微信Webhook机器人这个更爽手机上直接能收到卡片消息还能带图片链接。Webhook原理很简单就是向固定URL POST一段JSONimport requests import json def send_wecom_webhook(webhook_url, title, price, item_url): payload { msgtype: text, text: { content: f新商品监控到啦!\n标题: {title}\n价格: ¥{price}\n链接: {item_url} } } headers {Content-Type: application/json} resp requests.post(webhook_url, datajson.dumps(payload), headersheaders) if resp.status_code ! 200: raise Exception(fWebhook推送失败: {resp.text})企业微信机器人在手机端的效果比邮件强不少——邮件的推送延迟经常有几分钟而Webhook基本秒达。如果你没有企业微信也可以用钉钉机器人原理一样改一下msgtype字段就行。Server酱ServerChan这类第三方推送平台也能用注册后拿一个SendKey直接GET请求就能推送也很方便。4. 实操过程中的坑与排查技巧4.1 风控与验证码怎么避免被拦截Playwright自动化最大的敌人就是风控。闲鱼会检测浏览器指纹特征比如navigator.webdriver这个属性在Playwright控制的浏览器里是true虽然语文层面我们不做任何绕过手段但降低请求频率、模拟人类的浏览节奏是合规使用页面功能的基本要求。我用的是以下策略实测能平稳运行好几天不触发验证码控制频率。单次抓取任务间隔不少于3分钟两次搜索关键词之间暂停1-2秒模拟人浏览页面的节奏。不要每30秒就去刷新一次搜索页这个频率对个人使用完全没有必要——闲鱼新上架商品的密度通常不会高到分钟级变化。随机延时。Playwright里可以这样设置import random await page.wait_for_timeout(random.uniform(800, 1600))每次动作之间加一个随机的等待时间规律性就弱很多。固定sleep(2)这种写法如果日志被对方拿去分析一眼就能看出是程序。偶尔用非headless模式。headless模式无头浏览器虽然省资源但在风控的眼里没有界面本身就是一个特征。我日常跑监控服务器上没有图形界面就只能headless但我会让它在遇到滑块验证时自动截图保存方便人工介入处理。遇到滑块验证时我的自动处理方案是检测到验证码特征后立刻暂停任务发一条通知让人来处理而不是硬着头皮自动拖滑块。一方面自动滑块的成功率不稳定另一方面频繁触发验证码本身就是一种风险信号。暂停任务 通知人工是最不消耗信任度的方案。4.2 选择器和DOM结构变化页面改版后的应对闲鱼的Web端迭代非常频繁基本两三个月就会改一次DOM结构。最惨的一次是我写好的监控跑了两个星期某天早上发现一条通知都没有了打开浏览器一看商品卡片的外层class全换了原本的item-card变成了card-wrapper。应对这个问题我在代码里抽了一个parse_config字典所有定位器都放在配置里不要散落在代码各处parse_config { card_selector: div[class*item-card], div[class*card-wrapper], title_selector: div[class*title], price_selector: div[class*price], link_selector: a[href*item?id], max_scroll_rounds: 8, }CSS选择器里用逗号支持多个备用规则这样页面改版后我只需要在配置里加一个新的class关键词不用改主逻辑。另外Playwright的get_by_text和get_by_role这类语义化定位方式比纯class定位更抗改版建议多用。4.3 漏单问题为什么监控到的商品比实际少漏单排查是我调试时间最长的模块。后来发现漏单的原因主要有三个第一个是滚动懒加载导致的断层。监滚速度太快新内容还没渲染出来滚动就已经过了下面一屏的商品直接被跳过。解决方法是每次滚动后检查卡片数量是否增加如果连续两次滚动卡片数量不变说明已经到底或者内容没加载出来。第二个是筛选条件冲突。有些卖家上架时会设置只对关注的人可见或者定向出物这类商品在搜索结果里本身就不会出现这属于规则限制不是程序能解决的。第三个是多关键词覆盖不全。搜索索尼a7m3和索尼 a7m3的结果集差异很大词里的空格、机型缩写、别名都影响到召回率。我在关键词优化上做了一版用词根扩展法比如索尼a7m3扩展为[a7m3, a7三代, sony a7m3]取并集去重。4.4 长期运行的稳定性内存、日志与自我恢复监控脚本是7x24小时跑的稳定性问题会随运行时长逐渐暴露。最开始我发现脚本跑两三天后内存占用从300MB涨到1.5GB重启之后又恢复。这是典型的浏览器上下文累积问题尤其是Playwright在同一个context里访问了大量页面内部缓存和渲染进程不销毁。解决方式有两个方向。第一个是定时重启浏览器上下文比如每个小时关掉旧的browser重新Launch一个代价是登录态需要重新加载storage_state实现很简单async def rotate_browser(): await browser.close() browser await p.chromium.launch(headlessTrue) context await browser.new_context(storage_state./state.json) page await context.new_page()第二个是控制浏览器实例的复用。在同一个context里连续goto几十个页面后显式调用context.clear_cookies()会清除cookies不可取应该定期context.close()再新建。我实测下来每小时换一个context内存能稳定在500MB以内。另外日志记录一定要带上时间戳和任务名称import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, handlers[ logging.FileHandler(monitor.log, encodingutf-8), logging.StreamHandler() ] )长时间运行的任务如果日志里连这一次抓到了几条商品都没有出了问题就只能瞎猜。4.5 通知风暴与去重如何避免被自己的监控吵死通知去重是另一个容易被忽略的环节。如果同一件商品第一轮抓到了推送了下一轮抓取时因为某些原因item_id解析失败或者URL多一个参数就可能导致同一件商品被当成两条通知发出去。我在通知发送前加了一道幂等检查async def send_notification_if_new(item): # 检查是否已经通知过 row db.execute( SELECT notified FROM seen_items WHERE item_id ?, (item[item_id],) ).fetchone() if row and row[0] 1: return # 发送通知... send_wecom_webhook(WEBHOOK_URL, item[title], item[price], item[url]) # 通知成功后更新状态 db.execute( UPDATE seen_items SET notified 1 WHERE item_id ?, (item[item_id],) )这里我用的是先查后写的逻辑避免并发任务重复触发。如果你用了多进程或多线程同时抓取还需要给数据库操作加上锁或者依赖SQLite的INSERT OR IGNORE保证幂等。5. 在服务器上部署与日常运维5.1 部署到Linux服务器本地跑通后最终要放到服务器上常驻运行。这里建议用systemd管理进程比nohup可靠得多因为systemd会在进程意外退出时自动拉起来。服务文件的核心配置[Unit] DescriptionGoofish Monitor Service Afternetwork.target [Service] WorkingDirectory/opt/goofish_monitor ExecStart/opt/goofish_monitor/monitor_env/bin/python /opt/goofish_monitor/main.py Restartalways RestartSec10 EnvironmentPYTHONUNBUFFERED1 [Install] WantedBymulti-user.target有几个细节值得注意Restartalways保证崩溃自动重启WorkingDirectory要指定到项目目录避免相对路径的数据库文件和日志文件找不到PYTHONUNBUFFERED1让日志实时输出不然systemd的journal日志会滞后。启动后通过systemctl status goofish-monitor查看运行状态对排错有很大帮助。注意在服务器上没有图形界面Playwright会以headless模式启动这就我又又又踩了一次缺系统依赖的坑——所以务必先执行playwright install-deps。5.2 监控效果评估宁可多通知不可漏通知在我个人的使用习惯里监控助手的指标只有一个不要漏掉目标商品。被通知打扰是无所谓的因为看到不合心意的商品你最多花两秒划掉。真的漏掉一个心心念念的低价商品心态会完全不一样。所以我在通知策略上故意放水拿不准的新商品必须通知同一商品降价幅度超过5%必须通知关键词模糊匹配到的相关商品先通知再由人工判断。用这套策略跑了几个月漏单率大概是0而误报率大概在30%但误报的商品大多也是同类目下的其他机型顺手就点掉了消耗不了多少注意力。6. 最后再分享几个我个人的经验整个项目从零散脚本到完整运行前后花了大概半个月业余时间。踩过最深的坑不是代码写不出来而是自以为写完了之后第七天才发现监控脚本已经悄悄挂掉了。后来加了健康检查每隔12小时用另一个渠道发一条心跳消息才解决问题。后续扩展方面我觉得最实用的是加了关键词白名单/黑名单过滤比如只监控价格在3000到7000之间、标题里含国行或发票的商品过滤掉只换不出和已出这类无效信息。这个功能改造起来不难在提取商品信息之后、入数据库之前加一行判断就行。如果你打算长时间跑建议材料上做一次备份——数据库文件、登录态文件、配置文件全部打个包传到云盘服务器宕机后五分钟就能在新的机器上恢复服务这个投入产出比极高。最后多说一句监控他人数据时一定要尊重平台规则控制频率、用于个人合法用途别给平台服务器添压力也别碰任何人的隐私数据。合规使用的前提下这类自动化小工具能给你省下的时间和带来的确定性是非常值得投入的。