打开闲鱼搜“大疆御3”,有人挂4800,别急着拍——成交记录里同款上周刚以3900结标。这种差价不是个例,而是常态。
耳机、相机、键盘、无人机……越是非标品,越容易出现“标价≠心理价”。卖家急着回血、懒得设置拍卖、描述写得含糊,都会让价格失真。
价格数字里的秘密,不止是数学题
二手交易里,标价更像是“广告位”,成交价才是“真实底”。当买家不愿反复私聊砍价,或者对行情没概念时,系统默认的“立即购买”按钮就会把犹豫写进价格里。再加上平台流量分发更倾向低价爆款,中位标价被不断压低,历史成交却未必同步下行,于是同一SKU在不同时段出现可观落差。
一页二十条,翻到一百页已经眼花,何况还要逐条记价格。我们最终选了两条路:PC端用requests加lxml抓列表与详情,手机端用在安卓上滑动加载更多,顺便把“我想要”人数和留言时间一并拿下。两类数据合并后,能把“当前标价—最近成交—发布时长”串成一条完整时间线。
先把每个商品的“最低近三月成交均值”算出来,再与当下标价做差值百分比。大于设定阈值且发布超过两天仍有浏览无收藏的,列入候选池;接着用关键词黑名单筛掉已知瑕疵高发型号,最后按周转速度排序,优先处理那些同城可面交、配件齐全的标的。下面是最基础的价格字段提取示意:
import re, json
def parse_price(text):
m = re.search(r'¥(\d+)', text)
return int(m.group(1)) if m else None
下一步就是把结果丢进简单的Excel透视表,看哪些类目长期留有空间。别小看这一步,它能让你少走很多弯路。
从零搭环境:手机、驱动和那根线
讲道理,这套东西第一次配的时候我也烦过——不是驱动装不上,就是手机连了电脑没反应。但踩完一遍发现,其实就三个环节:手机开调试、Python装几个库、一行命令验证通不通。
手机方面,随便一台安卓就行,版本别太旧,Android 7以上基本都ok。设置里打开“开发者选项”,勾上“USB调试”,插线时记得点“允许这台计算机调试”。如果手边没实体机,夜神或者雷电模拟器也能凑合,就是滑动手感差点,抓闲鱼那种长列表滚动偶尔会卡。
电脑上装Python 3.8+,然后打开终端敲这几行:
是核心,它通过ADB往手机里推一个agent进程,之后你的Python代码就能直接操控屏幕——点哪里、滑动、读取控件文本,全包了。weditor是可视化辅助,能在浏览器里看到手机屏幕的控件树,方便定位元素。
装完跑一次初始化:
如果手机已经连上且开了调试,这句会把agent推到手机里。等看到终端输出“success”,基本就成了。
验证一下能不能控制设备,写两行:
import uiautomator2 as u2
d = u2.connect() # 自动识别当前唯一设备
print(d.info)
能打出手机的型号、分辨率、Android版本,说明你已经有了一只“远程手”。
常见翻车点:驱动没装。Windows下先检查有没有ADB,终端输adb devices看看能不能列出设备。如果列表为空,去安装一下Google USB Driver,或者在设备管理器里手动给手机装驱动。别用杂牌线,数据线不是充电线——这个我吃过亏,换了根原装线立马识别。
还有一点:有些手机第一次连上后弹出“是否允许RSA密钥指纹”,要点确定,不然adb devices里会显示“unauthorized”。
环境搭好,接下来就是打开闲鱼,模拟手指滑动翻页了。那个代码其实不长,但控制好时机是门学问——滑太快被风控,滑太慢一天也抓不完几页。后面会专门讲怎么调这个节奏。
环境就绪后,我先把脚本写成“能跑就行”,再去调节奏。目标是把“搜索—翻页—抓取—清洗”串成一条线,别让每一步打断下一步。
写爬虫代码:把闲鱼页面当“会动的表格”来读
手机连好,d = u2.connect() 拿到设备对象,先从启动闲鱼开始:d.app_start("com.taobao.idlefish")。等首页出来,我通常点掉可能出现的弹窗,再把焦点拉到搜索框——用 d(className="android.widget.EditText", textContains="请输入宝贝名字").click() 进搜索,接着 set_text 写入关键词,比如“iPhone 13 二手”,最后 send_keyevent(66) 回车。
结果页一出来,我就把它当一个大容器:每个商品卡片里装标题、标价、还有最关键的“成交”标记。这里最容易踩的是延迟渲染——列表滚动后才真正填充数据,所以读取之前我会等一会儿:d.implicitly_wait(3.0),再去找 d(resourceId="...", className="android.widget.ListView") 里的子元素。
# 示例:在搜索结果里逐个读卡片(资源名按你抓的实际情况填写)
for card in d(resourceId="com.taobao.idlefish:id/card_item", className="android.widget.RelativeLayout"):
title = card(resourceId="com.taobao.idlefish:id/title", className="android.widget.TextView").get_text()
price_txt = card(resourceId="com.taobao.idlefish:id/price", className="android.widget.TextView").get_text()
# 有的条目带“成交”标签,有的没有;有就直接取它的文本
sold = card(resourceId="com.taobao.idlefish:id/sale_tag", className="android.widget.TextView").get_text()
if not sold:
sold = None
print(title, price_txt, sold)
闲鱼并不总把成交价单独放一个字段,很多时候它混在副标题或标签里。我的写法是优先找“成交”这个节点;找不到就不强求,先把标价拿下来再说。价格我会统一洗成只保留数字和小数点的字符串,再用 Decimal 做转换,避免“¥”、“元”、“万”这些杂音。
如果你要更稳,可以把同一卡片里多个文本都拿一遍:标题、标价、标签、描述,都丢进一个字典,后面再按规则挑。这样即使页面改了一点布局,也不至于整段垮掉。
一页刷完,下一步是“继续往下拽”。我用 d.scroll(direction="up", times=1, steps=20) 往上滑一次,然后 sleep(1.5) 给图片和接口留响应时间。steps 设细一点,times 不贪多,连续滑太多容易被风控判定不像人。
为了可控,我习惯定个上限:每类目抓到 50 条就停,或者抓到没新数据为止。判断方式很简单——新一批卡片和上一批标题几乎一样,说明已经重复了。
爬到的东西总会带噪声:标题里有表情、价格写着“面议”、成交价缺失。我把清洗拆成两步:先删无效行,再统一格式。
- 剔除“仅显示视频/同城/信用极好”这类筛选入口的伪卡片,常见特征是标题短且不含具体型号。
- 价格统一成
Decimal('1234.56');遇到“面议”“私聊”就用None占位,别硬转。
最后把这批记录写进 CSV:df.to_csv('idlefish_items_20250411.csv', index=False)。文件名带上日期和类目,后面做透视表才不会乱。
价差模型:到底什么算“被低估”
数据洗干净了,接下来才是真正动脑子的事——到底什么算“被低估”?
起初我以为很简单:标价100,成交80,那就是亏了呗。直到我蹲了三天数据,发现闲鱼的标价逻辑远没那么直白。有人标99新iPhone只挂2000,但成交价能到2800,因为真正成交的时候买家加价抢了——这不算被低估,这叫钓鱼引流。
我定义了一个指标叫价差率,公式非常简单:
价差率 = (成交价 - 标价) / 标价 × 100%
负值越大(绝对值越大),说明标价低于实际成交价越多。比如标价500、成交650,价差率就是 (650-500)/500 = 30%——正值,说明卖家卖贵了。反过来,标价800、成交600,价差率 -25%。
前者是溢价,后者才是折价,也就是我们想找的“低估”。
但阈值设在多少?我试了三个版本:
- 第一版:价差率 < -10% 就标记。结果拉出来一堆“面议”改价的案例,噪声太多。
- 第二版:提到 -20%。筛出来确实都是真实折价货,但数量太少,一天也就几条。
- 第三版:
价差率 < -15% 且 成交价 > 100。这个阈值平衡了召回率和准确率,实测在3C类目上准确度还行,但在服饰类目翻车了——因为服饰标价本来就虚高,成交价波动大,15%的阈值抓到的全是“清仓甩卖”而非“搬砖机会”。
所以我后面加了一个类目修正系数:不同二级类目用不同的阈值。数码产品用 -15%,家居日用放宽到 -25%,奢侈品反而收紧到 -10%——因为假货太多,折价太猛反而可疑。
单靠价差率不够。我踩过一个坑:一台标价3000、成交2400的相机,价差率 -20%,按规则该冲。结果点进去一看,成色描述是“有磕碰、镜头霉丝”,卖家信誉只有两颗星。最后没敢买,回头查那台机器在别家挂2800都没卖出去。
所以我在模型里加了三个辅助字段:
- 商品成色:从标题/描述里提取“99新”“轻微划痕”“拆修过”等关键词,转成一个枚举值(全新/近新/良好/一般/较差)。价差率再低,成色是“较差”的就自动降权。
- 卖家信誉:闲鱼页面上有“信用极好”“信用良好”“信用中等”三档,我直接用正则从页面取。信用中等以下的商品,即使价差率漂亮也先挂黄牌。
- 重复上架检测:同一个商品标题+图片hash在7天内出现超过3次,说明是卖家反复挂,这种多半有猫腻,直接跳过。
这三个维度最后加权成一个信心分,0到100。只有信心分超过60且价差率达标的商品,才进入最终候选池。
模型跑出来的结果,我习惯直接吐成Excel。字段这样排:
商品标题 | 标价 | 成交价 | 价差率 | 成色 | 卖家信誉 | 信心分 | 链接
富士X-T30 II | 4500.00 | 3600.00 | -20.00% | 近新 | 信用极好 | 82 | https://...
用 pandas.DataFrame.to_excel() 写出去,样式用 openpyxl 条件格式化——价差率小于 -15% 的标绿,信心分低于60的标红。这样打开一眼就能扫到哪些值得点进去细看。
调阈值才是最折磨人的,跟写代码关系不大。每个类目都得拉历史数据回测,手动翻几页对照着看,改三四轮才勉强稳下来。等跑顺了之后倒是省心——每天自动跑一次,五分钟出一份报告,比我自己在那刷闲鱼高效太多。
阈值调完,我拿三类实物做首轮压测:3C数码、相机镜头、家居小家电。现实不会按PPT出牌,页面一打开全是坑。
实战:数码、镜头、小家电,三类商品的价差路线
脚本夜里跑,给我吐出一台成色“近新”的次旗舰,标价1500,历史成交却飘在1800上下。价差率-20%,信心分78,卖家信用极好,还带发票。我当场点进去复核:同城自提、保修还剩两个月、图片EXIF没被清。把平台手续费和运费险算完,单台毛利卡在300元左右。第二天联系买家当面验机,屏幕无老化、按键清脆,交易完成。这类机会在数码里最稳,流动性快,回款周期短。
另一条记录更刺激:一只热门定焦标价低于市场均价约25%,价差漂亮得离谱。我顺手查同款均价,再对序列号、脚架孔磨损、镜片有无霉丝。描述写着“轻微雾”,这三个字在老手眼里就是刀。我把成色从“近新”下调到“一般”,信心分立刻跌破60,直接移出候选池。后来群里有人同款挂2800迟迟不出,印证判断。镜头类目水深,文案里的形容词必须转译成可量化指标,不然利润表会负数。
白电和小家电曲线完全不同。标价与成交价差异多在-5%到-12%,不够惊艳,却稳定。我把阈值放宽到-10%,配合“包邮”“未拆封”“发票在”的组合条件,一天能筛出十几单。每台利润几十块,靠走量把总收益拉起来。唯一要盯的是售后:返修率一旦抬头,利润瞬间蒸发。于是我给每个SKU加了“退货率预估”列,超过15%就自动降权。
import re, math, datetime
def parse_price(text):
# 提取“¥1,299.00”“1299元”等多种格式
match = re.search(r'¥?(\d{1,3}(?:,\d{3})*\.?\d*)', text.replace(',',''))
if not match: return None
return float(match.group(1).replace(',',''))
def net_profit(tag, sold, platform_fee=0.05, shipping=8):
margin = sold * (1 - platform_fee) - tag - shipping
return round(margin, 2)
最后几行日志提醒我别膨胀:虚拟商品、账号风控、物流成本是三条红线。虚拟类直接黑名单;同一卖家短时间大量下单会被限流;快递体积重超标时,立方仓计费会把利润吃干净。把这些坑写进配置后,系统才算勉强能“自己活下去”。
反爬博弈:代理、延时和那个凌晨的报警
系统跑通之后,最头疼的不是价差模型,而是闲鱼的反爬。起初我以为调慢请求就行,结果第三天账号就被限制了,登录直接弹出滑块验证。
我搭了一个代理池,混着拨号VPS和付费隧道,每个请求随机从池子里挑。延时那块用了正态分布,均值3秒、标准差1.2秒——不是均匀间隔,那太像机器了。实测下来,单账号日采集量压在800条以内比较安全,超过就容易触发风控。
但光靠延时不够。闲鱼会在请求链里植入设备指纹,用Python的requests直接调接口,UA和TLS握手特征一眼就被识别。后来我换了curl_cffi配合tls_client,指纹模拟才算过关。
滑块验证码我踩过两次坑。第一次用第三方打码平台,成本高且延迟不稳定,高峰时段要等十几秒。后来改成本地OCR + 轨迹模拟:用opencv计算缺口偏移量,再用贝塞尔曲线生成鼠标轨迹——速度、加速度、回抖都要有,不然直接判机器。成功率大约在75%左右,够用了。
设备指纹那块更绕。闲鱼会把浏览器Canvas、WebGL、AudioContext等参数打包成hash,哪怕IP和UA一致,指纹变了也要重新登录。我的做法是用fingerprint做浏览器指纹伪装,再配合session持久化,避免每次都重新认证。
早期数据存在CSV里,跑了两周发现文件越来越大,查询也慢。后来迁到MySQL,建了张items表,字段包括item_id、price、sold_price、category、crawled_at。增量更新靠updated_at解决,每天抓一次,只更新价格变动的记录。
有一次我没设唯一索引,同一条商品被抓了三次,价差日志直接翻了倍。修完之后在item_id上加唯一约束,再配合updated_at字段做时间窗口过滤,重复率降到零。
凌晨2点,crontab准时把Python脚本叫醒。这脚本就干三件事——先拉还没碰过的商品列表,再爬数据,最后把价差算好写进表里。每一步都会失败重试,最多三次,再不行就直接写日志报警,不装死。
后来数据量大了,单机跑不动,就上了Airflow。DAG分三个Task:fetch_items、、push_alert,失败自动重试,还能看到历史执行情况。其实小规模用crontab就够了,Airflow有点重,但胜在可视化和依赖管理。
系统跑了两个月,只崩了一回——凌晨三点闲鱼换了接口参数,早上醒来才看到报警。爬起来改代码、换字段、重新跑,半小时就恢复了。说白了,反爬这东西就是来回折腾,谁也不知道对方哪天半夜又改个参数字段。但只要留了预警和降级方案,崩了也能立马兜住。
评论