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(headless=False) 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']", timeout=15000) # 在卡片范围内提取标题 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": f"https://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_email@qq.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一段JSON:
import 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, data=json.dumps(payload), headers=headers) if resp.status_code != 200: raise Exception(f"Webhook推送失败: {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(headless=True) 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( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler("monitor.log", encoding="utf-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] Description=Goofish Monitor Service After=network.target [Service] WorkingDirectory=/opt/goofish_monitor ExecStart=/opt/goofish_monitor/monitor_env/bin/python /opt/goofish_monitor/main.py Restart=always RestartSec=10 Environment=PYTHONUNBUFFERED=1 [Install] WantedBy=multi-user.target有几个细节值得注意:Restart=always保证崩溃自动重启;WorkingDirectory要指定到项目目录,避免相对路径的数据库文件和日志文件找不到;PYTHONUNBUFFERED=1让日志实时输出,不然systemd的journal日志会滞后。
启动后通过systemctl status goofish-monitor查看运行状态,对排错有很大帮助。注意在服务器上没有图形界面,Playwright会以headless模式启动,这就我又又又踩了一次缺系统依赖的坑——所以务必先执行playwright install-deps。
5.2 监控效果评估:宁可多通知,不可漏通知
在我个人的使用习惯里,监控助手的指标只有一个:不要漏掉目标商品。被通知打扰是无所谓的,因为看到不合心意的商品,你最多花两秒划掉。真的漏掉一个心心念念的低价商品,心态会完全不一样。
所以我在通知策略上故意"放水":拿不准的新商品,必须通知;同一商品降价幅度超过5%,必须通知;关键词模糊匹配到的相关商品,先通知再由人工判断。用这套策略跑了几个月,漏单率大概是0,而误报率大概在30%,但误报的商品大多也是同类目下的其他机型,顺手就点掉了,消耗不了多少注意力。
6. 最后再分享几个我个人的经验
整个项目从零散脚本到完整运行,前后花了大概半个月业余时间。踩过最深的坑不是代码写不出来,而是自以为写完了之后,第七天才发现监控脚本已经悄悄挂掉了。后来加了健康检查(每隔12小时用另一个渠道发一条心跳消息)才解决问题。
后续扩展方面,我觉得最实用的是加了关键词白名单/黑名单过滤,比如只监控价格在3000到7000之间、标题里含"国行"或"发票"的商品,过滤掉"只换不出"和"已出"这类无效信息。这个功能改造起来不难,在提取商品信息之后、入数据库之前加一行判断就行。
如果你打算长时间跑,建议材料上做一次备份——数据库文件、登录态文件、配置文件全部打个包传到云盘,服务器宕机后五分钟就能在新的机器上恢复服务,这个投入产出比极高。最后多说一句,监控他人数据时一定要尊重平台规则,控制频率、用于个人合法用途,别给平台服务器添压力,也别碰任何人的隐私数据。合规使用的前提下,这类自动化小工具能给你省下的时间和带来的确定性,是非常值得投入的。