python

闲鱼商品爬虫与多线程链接刷新:MD5签名逆向与浏览器自动化实战

2026-07-06 #python#爬虫#JS逆向

前言

闲鱼(Goofish)作为阿里旗下的二手交易平台,汇聚了大量个人卖家和商品数据。对于二手商品比价、市场行情分析来说,闲鱼的数据极具价值。然而,闲鱼的搜索接口使用 MD5 签名验证,且签名参数与时间戳强绑定,给自动化采集带来了不小的挑战。

另一方面,闲鱼商品链接会定期失效(商品下架或重新发布),需要定期刷新链接保持活跃度。面对大量链接的手动刷新需求,多线程浏览器自动化成了高效的解决方案。

本文基于闲鱼爬虫项目的真实源码,分两大模块讲解:商品搜索爬虫(含 MD5 签名逆向)和多线程链接刷新(含移动端模拟)。

技术背景

闲鱼搜索接口分析

闲鱼商品搜索通过以下 API 实现:

1
POST https://h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/

该接口需要 sign 签名参数,签名算法为 MD5,计算公式为:

1
sign = MD5(token + "&" + timestamp + "&" + appKey + "&" + data)

涉及的库

用途
requests 发送 HTTP 请求
hashlib MD5 签名计算
csv 数据持久化
time 生成时间戳、请求间隔
threading 多线程浏览器自动化
DrissionPage 浏览器自动化控制

实现思路

项目分为两大模块:

1
2
3
4
5
6
7
8
9
10
模块一:商品搜索爬虫
├── 单个搜索(商品搜索单个爬虫.py)
├── 批量搜索(商品搜索爬虫批量.py)—— 含MD5签名
└── 商品详情(商品详情爬虫.py)

模块二:多线程链接刷新
├── 单线程版(闲鱼刷新1.py)
├── 多线程版(多线程闲鱼链接刷新.py)
├── 不保存版(多链接多线程不保存闲鱼链接刷新.py)
└── 移动端模拟版(模拟移动端.py)

核心代码解析

一、商品搜索爬虫

1. 单页搜索:基础版

单个搜索版本直接使用固定的签名参数,适合单次请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import csv
import requests

# 打开CSV文件准备写入
f = open('单个商品搜索数据.csv', mode='w', newline='', encoding='utf-8')
csv_writer = csv.DictWriter(f, fieldnames=[
'价格', '标签', '产品简介', '标题', '昵称', '区域', '标题详情',
])
csv_writer.writeheader()

headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"Referer": "https://www.goofish.com/",
"Cookie": "t=333d89bdd8d3c7f1680d61c314977359; ..."
}

url = "https://h5api.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/"

params = {
"jsv": "2.7.2",
"appKey": "34839810",
"t": "1759795344200", # 时间戳
"sign": "8e4f63f339cc4eaea8dc3a9a778a94a5", # MD5签名
"v": "1.0",
"type": "originaljson",
"accountSite": "xianyu",
"dataType": "json",
"api": "mtop.taobao.idlemtopsearch.pc.search",
}

# 搜索关键词为"python爬虫"
data = {
'data': '{"pageNumber":1,"keyword":"python爬虫","fromFilter":false,"rowsPerPage":30,...}'
}

response = requests.post(url, headers=headers, params=params, data=data)
json_data = response.json()

2. 批量搜索:MD5 签名动态生成

批量搜索的关键突破在于动态生成签名。源码中实现了完整的签名算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import hashlib
import time

def get_sign(page_number):
"""
生成请求签名和相关参数

签名算法:MD5(token + "&" + timestamp + "&" + appKey + "&" + data)
"""
token = '9a7fd05c49b25d5075fab51a234be307' # 从Cookie的_m_h5_tk中提取

# 生成当前时间戳(毫秒)
current_timestamp = int(time.time() * 1000)
j = str(current_timestamp)

h = '34839810' # appKey

# 构造请求数据(包含页码)
c_data = '{"pageNumber":%d,"keyword":"python爬虫","fromFilter":false,"rowsPerPage":30,"sortValue":"","sortField":"","customDistance":"","gps":"","propValueStr":{},"customGps":"","searchReqFromPage":"pcSearch","extraFilterValue":"{}","userPositionJson":"{}"}' % page_number

# 拼接签名字符串
string = token + "&" + j + "&" + h + "&" + c_data

# 计算MD5
sign = hashlib.md5(string.encode('utf-8')).hexdigest()

return j, c_data, sign

签名逆向过程:源码注释中记录了一个真实的调试经历——开发者发现抓包时间戳与当前系统时间相差16小时(抓包时与运行时的时差),导致签名失效。将时间戳调整为当前时间后,请求成功。这说明 sign 与时间戳强绑定,必须使用实时时间戳。

循环爬取多页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
for pageNumber in range(1, 8):
print(f'正在采集第{pageNumber}页的数据内容')
j, c_data, sign = get_sign(pageNumber)

params = {
"jsv": "2.7.2",
"appKey": "34839810",
"t": j, # 动态时间戳
"sign": sign, # 动态签名
"v": "1.0",
"type": "originaljson",
"accountSite": "xianyu",
"dataType": "json",
"api": "mtop.taobao.idlemtopsearch.pc.search",
}

data = {'data': c_data}
response = requests.post(url, headers=headers, params=params, data=data)
json_data = response.json()
resultList = json_data['data']['resultList']

3. 商品数据提取

从返回的嵌套 JSON 中提取商品信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
for item in resultList:
try:
main = item['data']['item']['main']
fishTags_keys = main['exContent']['fishTags'].keys()

# 提取商品标签
if 'r2' in fishTags_keys:
tagname = ''.join([i['utParams']['args']['content']
for i in main['exContent']['fishTags']['r2']['tagList']])
else:
tagname = '无'

dit = {
'价格': main['clickParam']['args']['price'],
'标签': main['clickParam']['args']['tagname'],
'产品简介': tagname,
'标题': main['exContent']['detailParams']['title'],
'昵称': main['exContent']['userNickName'],
'区域': main['exContent']['area'],
'标题详情': main['exContent']['title'],
}
csv_writer.writerow(dit)
except KeyError as e:
print(f"商品缺少字段{e},跳过")
except Exception as e:
print(f"未预期的错误: {type(e).__name__}: {e}")

异常处理:闲鱼返回的数据结构不完全统一,有些商品可能缺少 fishTags 等字段。使用 try-except 捕获 KeyError 可以跳过异常数据,保证程序不中断。

4. 商品详情爬虫

获取单个商品详情通过另一个 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
url = "https://h5api.m.goofish.com/h5/mtop.taobao.idle.pc.detail/1.0/"

data_form = {
'data': '{"itemId": "963567908521"}' # 商品ID
}

params = {
"jsv": "2.7.2",
"appKey": "34839810",
"t": "1759793614723",
"sign": "2f84c8f1628ea3253f8343def6720c7c",
"api": "mtop.taobao.idle.pc.detail",
"accountSite": "xianyu",
"dataType": "json",
}

response = requests.post(url, headers=headers, params=params, data=data_form)
resjson = response.json()

二、多线程链接刷新

1. 单线程版

单线程版本使用 DrissionPage 控制浏览器,逐个访问链接并定时刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import time
import random
from DrissionPage import ChromiumPage

page = ChromiumPage()

urls = [
"https://www.goofish.com/item?id=978882160967&spm=widle.12011849.0.0&ut_sk=",
"https://www.goofish.com/item?id=976990295214&spm=widle.12011849.0.0&ut_sk=",
"https://www.goofish.com/item?id=976991963358&spm=widle.12011849.0.0&ut_sk="
]

max_refreshes_per_url = 100 # 每个URL最大刷新次数
min_interval = 3 # 最小刷新间隔(秒)
max_interval = 10 # 最大刷新间隔(秒)

for url_index, url in enumerate(urls):
page.get(url)
time.sleep(5) # 等待页面加载

refresh_count = 0
while refresh_count < max_refreshes_per_url:
refresh_count += 1
page.refresh() # 刷新页面

# 随机等待,模拟人类行为
wait_time = random.randint(min_interval, max_interval)
time.sleep(wait_time)

print(f"URL {url_index + 1} - 第 {refresh_count} 次刷新完成,等待 {wait_time} 秒")

page.quit()

2. 多线程版

多线程版本为每个 URL 创建独立的浏览器实例,并发刷新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import threading

browser_instances = {}
browser_lock = threading.Lock()

def refresh_url(url, url_index):
"""为单个URL创建浏览器实例并刷新"""
page = ChromiumPage() # 每个线程独立浏览器

with browser_lock:
browser_instances[url_index] = page

try:
page.get(url)
time.sleep(5)

refresh_count = 0
while refresh_count < max_refreshes_per_url:
refresh_count += 1
page.refresh()
wait_time = random.randint(min_interval, max_interval)
time.sleep(wait_time)
print(f"[线程 {url_index + 1}] 第 {refresh_count} 次刷新完成")

return True
except Exception as e:
print(f"[线程 {url_index + 1}] 发生错误: {e}")
return False

if __name__ == "__main__":
threads = []
for i, url in enumerate(urls):
thread = threading.Thread(target=refresh_url, args=(url, i))
threads.append(thread)
thread.start()
time.sleep(2) # 错开启动时间

for thread in threads:
thread.join()

# 统一关闭所有浏览器
with browser_lock:
for index, browser in browser_instances.items():
browser.quit()

关键设计:

  • 每个线程独立浏览器实例:避免多线程操作同一浏览器导致冲突
  • browser_lock:保护 browser_instances 字典的线程安全
  • time.sleep(2):线程启动间隔2秒,避免同时启动多个浏览器

3. 移动端模拟版

移动端版本通过修改 User-Agent 和窗口大小,模拟手机访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from DrissionPage import ChromiumPage, ChromiumOptions

def create_mobile_chromium_options():
"""创建移动端浏览器配置"""
co = ChromiumOptions()
# 使用iPhone用户代理
co.set_user_agent('Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1')
# 设置iPhone 12 Pro分辨率
co.set_argument('--window-size=390,844')
co.set_argument('--device-scale-factor=3')
co.set_argument('--use-mobile-user-agent')
return co

# 创建移动端浏览器
co = create_mobile_chromium_options()
page = ChromiumPage(co)

移动端模拟的意义在于:某些闲鱼链接(如 m.tb.cn 短链)只在移动端可访问,PC端访问会跳转或失效。

运行效果

商品搜索爬虫输出:

1
2
3
4
5
6
正在采集第1页的数据内容
当前时间戳: 1759801941772
{'价格': '299', '标签': '包邮', '产品简介': 'Python爬虫开发实战', '标题': 'Python爬虫书籍', '昵称': '二手书店', '区域': '广东深圳', '标题详情': '...'}
{'价格': '1500', '标签': '九成新', '产品简介': 'Python入门到精通', '标题': 'Python编程教材', '昵称': '技术宅', '区域': '北京海淀', '标题详情': '...'}
...
正在采集第2页的数据内容

多线程链接刷新输出:

1
2
3
4
5
6
7
8
9
10
11
[线程 1] 开始处理URL: https://www.goofish.com/item?id=978882160967...
[线程 1] 页面标题: 闲鱼商品
[线程 2] 开始处理URL: https://www.goofish.com/item?id=976990295214...
[线程 1] 第 1 次刷新完成,等待 9 秒
[线程 2] 页面标题: 闲鱼商品
[线程 1] 第 2 次刷新完成,等待 6 秒
...
所有URL处理完成,正在关闭浏览器...
[浏览器 1] 已关闭
[浏览器 2] 已关闭
所有浏览器已关闭

总结与优化方向

本文基于闲鱼爬虫项目的真实源码,完整讲解了两大模块:商品搜索(MD5 签名逆向 + 数据提取)和多线程链接刷新(浏览器自动化 + 移动端模拟)。

核心知识点回顾:

  1. MD5 签名逆向sign = MD5(token + "&" + timestamp + "&" + appKey + "&" + data),关键在于 token 的提取和时间戳的实时性
  2. 多线程浏览器自动化:每个线程创建独立浏览器实例,通过 threading.Lock 管理共享资源
  3. 移动端模拟:通过修改 UA 和窗口大小模拟手机访问

优化方向:

  1. Token 自动提取:当前 token 硬编码在代码中,可从 Cookie 的 _m_h5_tk 字段自动提取
  2. 代理 IP 池:多线程刷新时容易被识别,搭配代理 IP 降低风险
  3. 刷新策略优化:根据商品活跃度动态调整刷新频率,活跃商品多刷,冷门商品少刷
  4. 异常恢复:浏览器崩溃后自动重启,保证长时间运行的稳定性
  5. 数据持久化:搜索结果存入数据库,支持历史价格对比和趋势分析

闲鱼爬虫的核心难点在于 MD5 签名的逆向和时间戳的处理。理解了签名算法后,就能灵活应对阿里系平台(淘宝、天猫、闲鱼)的类似签名机制。

评论
分享