一、如何识别它是瑞数
三个特征中两个命中,基本可以确认:
-
URL 上挂着一个看不懂的长签名参数
-
已知前缀名:
MmEwMD/96Awsc/FSSDab等(随站点不同,长度 200+) -
例:
POST /main/xxgk/getYfbListHc?MmEwMD=31Fq002H2opSrYnKJ1lRbfp_fnS1SDELq6Vq...
-
-
cookie 里两个成对出现的奇怪 key
- 形如
FSSBBIl1UgzbN7N80S与FSSBBIl1UgzbN7N80T(S/T后缀,同前缀) - 一个是会话标识,一个是动态令牌(瑞数 JS 周期性更新)
- 形如
-
curl直接拉首页只得到一个 25 KB 左右的纯 JS 壳子- 看不到任何业务文本
- 内含一段
<meta id="9DhefwqGPrzGxEp9hPaoag" content="...">与几个_$xx全局函数 <script src="/4QbVtADbnLVIc/c.FxJzG50F.6152bb9.js">这种乱码路径加载主混淆代码
可用本仓库的复现命令快速判定:
curl -sA "Mozilla/5.0" https://www.cde.org.cn/main/xxgk/listpage/ba7aed094c29ae31467c0a35463a716e \
| head -c 500
# 若内容是 _$ 开头的 JS / `<meta id="9DhefwqGPrzGxEp9...` 而非业务 HTML,即瑞数二、为什么不建议逆向
- VMP 字节码混淆:执行流是定制指令集解释执行,静态分析得不到公式
- 签名计算依赖瞬时浏览器状态:cookie /
Date.now()/ setInterval 周期累积值 / DOM 副作用 - 签名一次性:同一个
MmEwMD用于第二次请求服务端会拒 - 站点换 VMP 字节码即作废:逆向出来的解析器一周内大概率失效
结论:除非业务规模极大、长期反复抓,否则别碰逆向,直接用浏览器。
三、核心思路 —— 让瑞数自己加签
瑞数会在页面加载时重写 window.fetch 与 XMLHttpRequest.prototype.send。
我们只要让自己的 fetch 调用发生在已被 hook 的 page context 里,
签名 + cookie 就由瑞数自动注入。
# 关键三步,见 cde_spider/runner.py:_warm_up()
await page.goto(LIST_URL, wait_until="domcontentloaded")
await _wait_ruishu_cookie(page) # 等瑞数种好 cookie
await page.evaluate(JS_FETCH, payload) # 在浏览器里调 fetch,瑞数 hook 自动接管page.evaluate 不是替它发请求,而是让 Chromium 执行一段我们写的 JS;
这段 JS 一旦执行,它的 fetch 引用就是已经被瑞数重写过的版本。
四、突破清单(按重要性递减)
1. ⭐ 必做:浏览器内 fetch
不要用 page.request.post()(那是 Playwright 自己的 HTTP 客户端,绕过页面 JS),
更不要用外部 requests 重放 cookie。
只用 page.evaluate() 注入一段 async 函数,函数体内 fetch('/path', {...})。
JS_FETCH = """
async ({yfbType, pageNum, pageSize}) => {
const body = new URLSearchParams({
pageSize: String(pageSize),
pageNum: String(pageNum),
yfbType: yfbType,
noticeTag: '0',
condition: ''
});
const res = await fetch('""" + API_PATH + """', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/javascript, */*; q=0.01'
},
body: body.toString(),
credentials: 'include'
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch (e) {}
return { status: res.status, json: json, text: text.slice(0, 500) };
}
"""2. ⭐ 必做:抹掉自动化指纹
瑞数 hook 内部会检测 navigator.webdriver 等指纹,检测到自动化就算错签名
(返回 200 但 body 为 \r\n\r\n\r\n + HTTP 400)。
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'languages', { get: () => ['zh-CN', 'zh', 'en'] });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
window.chrome = window.chrome || { runtime: {} };通过 context.add_init_script() 在每个页面加载前注入,
见 cde_spider/runner.py:STEALTH_INIT_SCRIPT。
3. ⭐ 必做:等待瑞数 cookie 后再发请求
瑞数 JS 加载完成的标志 = FSSBBIl1Ugzb*T cookie 出现。
轮询 page.context.cookies(),出现后再做任何 fetch,否则会拿到 400。
# cde_spider/runner.py:_wait_ruishu_cookie
RUISHU_COOKIE_PREFIX = "FSSBBIl1UgzbN7N80T"
async def _wait_ruishu_cookie(page, timeout_seconds=30.0) -> bool:
deadline = asyncio.get_event_loop().time() + timeout_seconds
while asyncio.get_event_loop().time() < deadline:
cookies = await page.context.cookies(LIST_URL)
if any(c["name"].startswith(RUISHU_COOKIE_PREFIX) for c in cookies):
return True
await asyncio.sleep(0.5)
return False不同瑞数版本/站点的 cookie 名前缀可能不同,首次接入新站点务必用 DevTools 重新核对。
4. 强烈推荐:headed + 真实显示环境
- headless 即便加了 stealth 也容易被识破(GL 渲染指纹、字体列表、AudioContext 指纹等)
- WSL2 Ubuntu 下用 WSLg(Windows 11 自带,
$DISPLAY=:0自动注入,无需额外 X server) - 纯 Linux 远端用
xvfb-run -a uv run python main.py提供虚拟显示
5. 推荐:真实 UA + 真实 viewport + Accept-Language
- UA 应匹配宿主机 Chrome 版本(本项目用 Chrome 147 的 UA)
- viewport 用常见尺寸(1366×768、1920×1080)
extra_http_headers={"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"}
参考 cde_spider/runner.py:USER_AGENT, _make_context()。
6. 推荐:预热请求作 ready 探针
光有 cookie 不一定代表 fetch hook 已生效(JS 还在执行后续初始化)。
拿一个最便宜的真实业务请求(如 pageSize=10, pageNum=1)做就绪检测,失败重试 7 次。
参考 cde_spider/runner.py:_warm_up()。
7. 兜底:失败时 reload 页面再重试
风控触发后 cookie 可能被服务端废弃,继续硬抓必失败。
失败时 page.reload() 重新走一次瑞数 JS 初始化流程。
参考 cde_spider/runner.py:_fetch_with_retry()。
五、踩过的坑
坑 1:pageSize=1 被服务端拒
服务端只接受 UI 上出现过的 pageSize 取值(10/20/30/40/50)。
预热请求要用 10 而不是 1,否则会得到「参数错误」。
经验:服务端常常对 UI 没暴露的取值校验更严格,预热 / 探针请求用 UI 同款参数。
坑 2:headless-shell ≠ 完整 Chromium
uv run playwright install chromium 实际下载了
chromium_headless_shell + chromium 两个二进制。
launch 时默认走 headless-shell(更小的 headless-only 镜像)。
要显式 playwright.chromium.launch(headless=False) 才会切换到完整 Chromium,
完整 Chromium 才有真实的字体 / GL / 音频栈,瑞数指纹检测才能过。
坑 3:不要用 page.evaluate 解析整页 DOM
瑞数会 hook eval / Function 等,大段 JS 通过 page.evaluate 容易超时
(我们曾经因此卡死)。解析 DOM 用 page.locator() 或 page.content() 拿 HTML
后在外部用 lxml 解析;page.evaluate 只用于发起 fetch,不做复杂逻辑。
坑 4:WSL 文件路径直接给 Playwright 慎用
WSL2 下 /mnt/d/... 是 9P 协议挂载,部分 stat / inotify 行为不一致。
Playwright 的 user-data-dir 建议放 ~/...(ext4 真实盘),否则可能出现 cookie 丢失。
六、迁移到新瑞数站点的步骤
-
嗅探
curl -sA "Mozilla/5.0" <目标站点列表页> | head -c 500若是
_$开头的 JS 壳子,确认瑞数。 -
DevTools 抓一次列表请求,记录:
- URL 上的签名参数名(
MmEwMD之类) - cookie 中两个成对的瑞数 cookie 名前缀
- 业务接口路径与 form-urlencoded 字段
- URL 上的签名参数名(
-
复制本项目的
cde_spider/api.py,改API_PATH与 body 字段 -
复制
cde_spider/runner.py,只需改:LIST_URL→ 新站点列表页RUISHU_COOKIE_PREFIX→ 新站点的 cookie 前缀(从第 2 步)_warm_up()内的预热请求参数
-
业务参数穷举验证语义(见坑 1),不要看抓包就下结论
-
单页跑通后再加断点续传 / 翻页循环 / 三类切换等
-
节奏建议保守:单浏览器串行 + 1~2s 随机间隔
七、延伸阅读
- 瑞数 5 / 瑞数 6 的 cookie 命名规则演化:
FSSBBIl1UgzxxxxS/T是瑞数 5 时代,瑞数 6 也沿用 - 同类思路适用的 VMP 反爬:阿拉丁、顶象、网易易盾、极验(部分模块)
- Playwright stealth 的更全面方案:
playwright_stealth库,但本项目手写 4 行 JS 已够用