丁香园 BBS API 逆向工程实录

丁香园 BBS API 逆向工程实录

丁香园 BBS API 逆向工程实录

目标: `https://www.dxy.cn/bbs/newweb/pc/board/50` (骨科板块)

工具: Chrome DevTools Protocol + js-reverse MCP + Python

日期: 2026-05-14


1. 侦察 ── 页面架构分析

打开目标 URL,首先确认渲染模式。查看页面源代码发现 HTML 几乎是空的 —— 只有 Next.js 的引导脚本,所有内容由客户端 JavaScript 动态渲染。

关键发现:

框架: Next.js App Router (React Server Components)

渲染: SSR with RSC (React Server Components) 流式传输

板块帖子列表: 无公开 REST API,数据通过 RSC 协议内嵌在页面响应中

用户操作 API: 由独立的 bbsapi.dxy.cn 子域提供,需要签名认证

浏览器 → https://www.dxy.cn/bbs/newweb/pc/board/50?orderType=1&pageNum=1
       Headers: RSC: 1, Accept: text/x-component
       
服务器 → React Flight 序列化数据 (text/plain; charset=utf-8)
         ├── 板块元数据 (帖子总数、关注数、今日更新)
         ├── 热帖列表 (hotPosts)
         └── 帖子列表 (posts) + 分页信息

2. RSC Flight 协议 ── 板块数据抓取

2.1 协议头

Next.js App Router 使用自定义 HTTP 头与服务器通信 RSC 数据:

RSC: 1                          # 告知服务器返回 RSC 负载
Accept: text/x-component        # 告诉服务器客户端期望的响应类型

不加这些头时,服务器返回完整的 HTML 页面;加了之后返回的是 React Flight 序列化格式。

2.2 Flight 数据格式

RSC 响应是一个由换行符分隔的流式 JSON 行协议。每一行是一个 JSON 数组:

["$","$L8","53270735",{"postInfo":{...},"isHotPost":false,"category":"general"}]

格式: [类型标记, 组件引用ID, 帖子ID, 数据对象]

提取帖子列表的正则表达式:

pattern = r'\["\$","\$L\d+","(\d+)",(\{"postInfo":\{[^}]+\},"isHotPost":(?:true|false),"category":"[^"]*"\})\s*\]'

2.3 板块元数据提取

元数据内嵌在 RSC 响应的内联 script 标签中:

# 板块名称
re.search(r'"boardId":"50"[^}]*"shortTitle":"([^"]*)"', content)

# 统计信息
re.search(r'"total":(\d+)', content)       # 总帖子数
re.search(r'"postCount":(\d+)', content)   # 总发帖数
re.search(r'"followCount":(\d+)', content) # 关注数
re.search(r'"todayUpdates":(\d+)', content) # 今日更新

2.4 分页与访问限制

– 每页 30 条帖子

未登录: 只能访问前 ~100 页

带 Cookie: 携带 JUTE_SESSION_ID 可访问全量 6324 页

– 超过限制时 RSC 响应中不包含帖子数据,只显示错误提示

Cookie: JUTE_SESSION_ID=xxxxxxxx

3. API 签名算法 ── 核心逆向

3.1 签名函数定位

通过 Chrome DevTools 在 bbsapi.dxy.cn 请求上设置 XHR 断点,追踪到 webpack chunk 3669-c4d5fab0e6c5d08c.js (模块 18673)。

3.2 签名密钥

静态密钥硬编码在 JavaScript 中:

appSignKey = "4bTogwpz7RzNO2VTFtW7zcfRkAE97ox6ZSgcQi7FgYdqrHqKB7aGqEZ4o7yssa2aEXoV3bQwh12FFgVNlpyYk2Yjm9d2EZGeGu3"

3.3 签名步骤

输入: 请求参数对象 (e.g. {"postId": "53260290"})

Step 1: 添加时间戳和随机数
    params["timestamp"] = Date.now() + (window.__INITIAL_TIMESTAMP_DIFF__ || 0)
    params["noncestr"]  = 8位随机数字字符串

Step 2: 附加签名密钥
    signedParams = { ...params, "appSignKey": SIGN_KEY }

Step 3: 过滤空值,按 key 字母序排序

Step 4: 拼接签名字符串
    signStr = "key1=value1&key2=value2&..."

Step 5: SHA1 哈希
    sign = SHA1(signStr)

3.4 Python 复现

import hashlib, time, random

SIGN_KEY = "4bTogwpz7RzNO2VTFtW7zcfRkAE97ox6ZSgcQi7FgYdqrHqKB7aGqEZ4o7yssa2aEXoV3bQwh12FFgVNlpyYk2Yjm9d2EZGeGu3"

def api_sign(params: dict) -> dict:
    p = dict(params)
    p["timestamp"] = int(time.time() * 1000)
    p["noncestr"]  = "".join(random.choices("0123456789", k=8))
    sp = dict(p)
    sp["appSignKey"] = SIGN_KEY
    keys = sorted(k for k, v in sp.items() if v is not None and v != "")
    p["sign"] = hashlib.sha1(
        "&".join(f"{k}={sp[k]}" for k in keys).encode()
    ).hexdigest()
    return p

3.5 API 路由规则

JavaScript 中的路由封装函数将前端路径映射到 API 子域:

// 前端调用: /pc/post-view/detail
// 实际请求: GET https://bbsapi.dxy.cn/pc/post-view/detail?timestamp=...&noncestr=...&sign=...

function v({url, method, parameters}) {
    url = url.replace(/^\/pc/, "");    // 去掉 /pc 前缀
    url = "https://bbsapi.dxy.cn/pc" + url;
    if (method === "GET") {
        // 签名参数拼接到 query string
        url += "?" + signed_query_string;
    } else {
        // POST 时签名也在 query string,body 为 JSON
    }
}

4. API 端点枚举

通过 webpack 源码分析和网络请求监控,发现以下端点:

4.1 用户相关

方法 路径 说明
GET `/loginInfo` 登录状态检查
GET `/user/message/getHead` 用户消息概要
GET `/board/user-follow-list` 用户关注板块列表
GET `/user/labelInfo` 用户标签信息

4.2 内容相关

方法 路径 说明
GET `/post-view/detail` 帖子正文详情
GET `/post-view/reply/page` 帖子回复列表 (评论)
GET `/search/hot-keywords` 搜索热词
GET `/board/hot-post-info` 板块热帖信息

4.3 操作相关

方法 路径 说明
POST `/board/follow` 关注/取消关注板块
POST `/draft/save` 保存草稿
GET `/draft/find` 查找草稿
GET `/special/follow` 专题关注

4.4 安全相关

方法 路径 说明
GET `/safe/geetest-v4/validate` 极验 V4 人机验证
GET `/plugin/role/exists` 角色/权限检查

4.5 板块帖子列表

不存在公开 REST API。 尝试了 30+ 种路径变体 (/board/list, /board/post/page, /board/posts 等) 均返回 "请求方法不存在"

板块帖子列表仅通过 Next.js RSC SSR 渲染,属于服务端内部调用,不暴露给客户端 API。


5. 请求头要求

# bbsapi.dxy.cn
headers = {
    "Referer": "https://www.dxy.cn/",
    "Cookie": "JUTE_SESSION_ID=...",   # 可选,提升权限
}

# RSC 页面
headers = {
    "RSC": "1",
    "Accept": "text/x-component",
    "User-Agent": "Mozilla/5.0",
    "Referer": "https://www.dxy.cn/",
}

6. 抓取结果

6.1 骨科板块 (boardId=50) 数据概览

指标 数值
总帖子数 189,718
总页数 6,324
板块发帖总数 435,497
关注人数 682,372
今日更新 166

6.2 示例热帖

ID 标题
53260290 一天净整髓内钉了,这几天整的够够了
53263172 十五分钟,椎体成形,效果立竿见影。
53260600 劈三角肌入路Philos锁定钢板固定87岁肱骨近端骨折
53259305 庆大霉素应用后的手坏死一例

6.3 输出文件

dxy_board_50_posts.json — 91 条帖子 + 热帖 + 元数据 (59 KB)


7. 技术总结

7.1 攻击面分析

┌──────────────────────────────────────────────────┐
│                   www.dxy.cn                       │
│  ┌────────────────────────────────────────────┐  │
│  │  Next.js App Router (SSR)                   │  │
│  │  /bbs/newweb/pc/board/{id}                  │  │
│  │    → RSC Flight 序列化数据 (无签名)          │  │
│  │    → 含板块帖子列表 + 元数据                 │  │
│  │    → 分页限制: 未登录 ~100页                 │  │
│  └────────────────────────────────────────────┘  │
│                                                    │
│  ┌────────────────────────────────────────────┐  │
│  │  bbsapi.dxy.cn                              │  │
│  │    → SHA1 签名认证 (静态密钥)                │  │
│  │    → 帖子详情 / 回复 / 用户操作              │  │
│  │    → 不提供板块帖子列表接口                   │  │
│  └────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

7.2 关键突破点

1. RSC 协议: 发现 Next.js 的 RSC: 1 头可以获取纯数据结构,绕过了 HTML 解析

2. 签名密钥: 密钥硬编码在前端 webpack chunk 中,没有任何混淆或保护

3. 时间戳偏移: window.__INITIAL_TIMESTAMP_DIFF__ 允许服务器和客户端时间同步,复现时可以忽略

4. Cookie 权限: JUTE_SESSION_ID 决定了可访问的页面深度

7.3 安全建议 (给丁香园)

appSignKey 不应硬编码在前端代码中;签名应由后端服务生成

– 板块帖子列表应考虑添加速率限制和访问控制

– RSC 协议暴露了比普通 HTML 更结构化的数据,降低了抓取门槛


附录: 完整代码

parse_all.py —— 包含 RSC 抓取、API 签名、数据解析的完整实现。

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容