一、如何识别它是瑞数

三个特征中两个命中,基本可以确认:

  1. URL 上挂着一个看不懂的长签名参数

    • 已知前缀名:MmEwMD / 96Awsc / FSSDab 等(随站点不同,长度 200+)

    • 例:

      POST /main/xxgk/getYfbListHc?MmEwMD=31Fq002H2opSrYnKJ1lRbfp_fnS1SDELq6Vq...
      
  2. cookie 里两个成对出现的奇怪 key

    • 形如 FSSBBIl1UgzbN7N80SFSSBBIl1UgzbN7N80T(S / T 后缀,同前缀)
    • 一个是会话标识,一个是动态令牌(瑞数 JS 周期性更新)
  3. 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.fetchXMLHttpRequest.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

瑞数 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 丢失。

六、迁移到新瑞数站点的步骤

  1. 嗅探

    curl -sA "Mozilla/5.0" <目标站点列表> | head -c 500

    若是 _$ 开头的 JS 壳子,确认瑞数。

  2. DevTools 抓一次列表请求,记录:

    • URL 上的签名参数名(MmEwMD 之类)
    • cookie 中两个成对的瑞数 cookie 名前缀
    • 业务接口路径与 form-urlencoded 字段
  3. 复制本项目的 cde_spider/api.py,改 API_PATH 与 body 字段

  4. 复制 cde_spider/runner.py,只需改:

    • LIST_URL → 新站点列表页
    • RUISHU_COOKIE_PREFIX → 新站点的 cookie 前缀(从第 2 步)
    • _warm_up() 内的预热请求参数
  5. 业务参数穷举验证语义(见坑 1),不要看抓包就下结论

  6. 单页跑通后再加断点续传 / 翻页循环 / 三类切换等

  7. 节奏建议保守:单浏览器串行 + 1~2s 随机间隔

七、延伸阅读

  • 瑞数 5 / 瑞数 6 的 cookie 命名规则演化:FSSBBIl1UgzxxxxS/T 是瑞数 5 时代,瑞数 6 也沿用
  • 同类思路适用的 VMP 反爬:阿拉丁、顶象、网易易盾、极验(部分模块)
  • Playwright stealth 的更全面方案:playwright_stealth 库,但本项目手写 4 行 JS 已够用