
丁香园 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 签名、数据解析的完整实现。







暂无评论内容