今日头条创作后台单篇数据接口逆向侦查实录
本文档为 S4 技术文档向复盘:完整记录 2026-08-31 对头条创作后台「单篇分析」页 (
https://mp.toutiao.com/profile_v4/analysis/works-single/article)背后接口族的侦查过程, 包括页面架构、JS bundle 逆向路径、接口参数规格、防护机制与实测结论。 所有数字均可溯源至D:/code/thinking/tools/yinliu_data/recon/落盘工件。 适用边界:只采自有账号登录后可见的数据。
0. 结论速览
| 项 | 结论 |
|---|---|
| 接口基座 | https://mp.toutiao.com/mp/agw/statistic/v2/item/ |
| 认证方式 | 登录 Cookie 请求头,无签名参数、无 token 计算 |
| 核心接口 | list(列表+全量统计)、info(单篇详情)、traffic_analyse(流量日趋势)、interaction_analyse(互动日趋势) |
| 请求方法 | 全部 GET,query string 传参 |
| 防护 | argus JS 挑战只拦裸 urllib;curl_cffi impersonate="chrome"(TLS 指纹伪装)实测放行 |
| 趋势数据窗口 | 流量 8 个月、互动 1 年,按作品发布日判窗,超窗返回 code:1200 |
| 固化产物 | tools/yinliu_data/platforms.py:304 fetch_toutiao_works_single(),已挂入每日采集链 |
1. 侦查流程(四阶段)
Phase 0:不开浏览器,先拿页面情报
用 curl_cffi(impersonate=chrome)带 Cookie 直接请求分析页 URL:
- 页面 HTML:HTTP 200,63428 字节——直连成功,没有被拦。这一步同时证伪了旧结论"mp.toutiao.com 对非浏览器一律回 argus 挑战"(该结论 2026-08-09 基于裸 urllib 实测,本次收窄为:argus 只拦 urllib 级指纹,拦不住 Chrome TLS 指纹)。
- HTML 中识别出 Garfish 微前端结构:入口脚本仅 393 字节,是一个加载器,真正的子应用是
@mp/analysis,版本1.0.0.923,通过 remote-entry(sub_vmok-remote-entry.04bd07f2.js,226203 字节)挂载。 - 主 bundle(
toutiao_index_bundle.js,230798 字节)+ 9 个按需 chunk 全部 200 下载落盘。
Phase 1:浏览器抓包(本案例跳过)
按作战手册,浏览器抓包用于补 Phase 0 缺的数据点。本次 bundle 已完整落盘,直接进入 Phase 2。事后复盘确认这是路线失误:后台接口的实参(参数名和取值)用浏览器抓包 5 分钟就能看到,纯逆向 chunk 猜参数花了多得多的时间。教训已固化进作战手册:bundle 用来定位接口族和字段名,实参以抓包为准。
Phase 2:扒 chunk 定位接口族
对落盘的 chunk 做正则扫描:
# tools/yinliu_data/recon/toutiao_phase2_358.py
for m in re.finditer(r'/mp/agw/statistic/v2/item/[a-z_]+', t):
命中集中在 chunk_358.js(18793 字节),扫描出 item 维度完整端点清单(12 个):
list info next traffic_analyse interaction_analyse traffic_source
income_analyse income_source fans_trends freestyle_analyse user_property
+ export_* 导出变体(chunk_155.js / chunk_358.js,对应页面"导出 Excel"按钮)
Phase 3:实测重放(每个结论必须脱离浏览器重放)
| 重放项 | 结果 | 证据 |
|---|---|---|
item/list 第 1 页 |
200 / 6407 字节,code:0,total_count=6,字段全量非空壳 |
toutiao_p3_item_list_GET_p1_t1.json |
item/list 第 2 页 |
200 / 62 字节,空数组(超页返回空;账号仅 6 篇,单页即全量,多页翻页结构性无法实测) | toutiao_p3_item_list_GET_p2_t1.json |
item/info |
200 / 1392 字节,code:0;同一篇 impression 两次请求 11737→11739,证明是实时计数 |
toutiao_p3_info.json |
traffic_analyse(窗内近文) |
200 / 7834 字节,code:0,daily_stats 23 条日序列 |
toutiao_p3_traffic_fromto_dash.json |
interaction_analyse(窗内近文) |
200 / 3056 字节,code:0,daily_interactions 23 条 |
toutiao_p3_interaction_fromto_dash.json |
| 超窗老文(2024-05 发布)重打 | code:1200,收缩查询窗到近 30 天仍 1200——证实按发布日判窗,不可绕过 |
toutiao_p3_*_old_30d.json |
| 缺日期参数的新文请求 | code:1105 |
toutiao_p3_traffic_min.json |
2. JS 侧机制(逆向细节)
2.1 微前端加载链
profile_v4/analysis/works-single/article (HTML, 63428B)
└─ Garfish 加载器 (393B)
└─ @mp/analysis:1.0.0.923 子应用
├─ sub_vmok-remote-entry.04bd07f2.js (模块联邦入口)
├─ 主 bundle (230798B)
└─ webpack chunk 按需加载:chunk_10/108/151/155/177/358/372/394/476
子应用以 webpack 5 模块联邦 + JSONP 方式注册:(window["@mp/analysis:1.0.0.923"]||[]).push([[358],{...}])。接口定义集中在 chunk_358,页面交互逻辑(筛选、趋势图)在 chunk_372。
2.2 请求层:StatisticAPI 类 + ferryFetch
chunk_358 中定义了一个 API 类,每个方法拼 URI 后交给 ferryFetch:
// chunk_358.js 反混淆关键结构(模块 47258)
var s = {All:0, PgcArticle:1, Video:2, Weitoutiao:3, Wenda:4,
ShortVideo:5, Audio:6, PgcGallery:7, Invalid:8}; // type 枚举
// ...
GetItemTrafficAnalyse: function(t, e) {
var r = (0, i.OW)(t); // ← 查询串序列化器
var n = "".concat(this.uriPrefix,
"/mp/agw/statistic/v2/item/traffic_analyse").concat(r);
return (0, a.ferryFetch)(n, {method: i.EJ, headers: i.b3}, e);
// i.EJ = "GET"; i.b3 = {"Content-Type": "application/json"}
}
所有 statistic 接口都是 GET,参数全部走 query string,没有请求体、没有签名头。这是本次侦查最有价值的发现:头条创作后台的 statistic v2 接口族不做参数签名,防护完全前置在 TLS/JS 挑战层。
2.3 查询串序列化算法(模块 95273 的 OW 函数)
这是 chunk_358 里唯一称得上"算法"的部分,规则:
function OW(t, e) {
var r = [];
function a(t, e) {
var i = Object.prototype.toString.call(t);
if ("[object Array]" === i)
t.forEach(function(t, r) { a(t, e + "[" + r + "]") }); // 数组 → k[0]=v&k[1]=v
else if ("[object Object]" === i)
for (var n in t) a(t[n], e + "[" + n + "]"); // 对象 → k[key]=v
else if ("[object Date]" === i)
r.push(e + "=" + t.toISOString()); // Date → ISO 串
else if ("[object Null]" === i)
r.push(e + "="); // null → 空值
else if ("[object Undefined]" !== i)
r.push(e + "=" + encodeURIComponent(t)); // 其余 → URI 编码
}
for (var i in t) a(t[i], i);
var n = r.join("&");
return n && !e ? "?" + n : n;
}
等价于 PHP 风格的 http_build_query:数组/对象用方括号下标展开,Date 转 ISO,普通值 encodeURIComponent。Python 侧 requests/curl_cffi 的 params= dict 与之兼容(本次实测的参数全是平铺标量,没触发括号展开分支)。
2.4 实参定位:chunk_372 的调用现场
趋势接口的参数名不是猜的,是从 getTrafficData 的真实调用点挖出来的(toutiao_phase3_callctx.py 定位):
// chunk_372.js 调用现场
getTrafficData({item_id: a, type: t, from: y, to: h,
platform: n, location: i[0]}, d.N.detail)
即趋势接口实参为 item_id / type / from / to 四个必带,platform / location 是筛选维度(分发平台/展现位置,可不传走聚合口径),第二参数 dimension="detail"。此前按最小参数试打返回 code:1105(缺日期),又试了 dimension 的 whole / detail / readDetail 三值均被排除——缺的不是 dimension,是 from/to。
2.5 日期格式试错
from/to 的格式用 toutiao_phase3_trend_dates.py 跑了 5 个候选(时间戳秒/毫秒、YYYY-MM-DD、YYYY/MM/DD、ISO),只有 YYYY-MM-DD 返回 code:0 出数。首个候选通过即短路,其余格式未全量实测(已在报告自我批评节标注)。
3. 接口规格
3.1 GET /mp/agw/statistic/v2/item/list — 单篇列表+全量统计
参数:type(1=图文,2=微头条[语义未实测,账号无数据])、page_num(从 1 起)、page_size(30)。
响应:{code:0, total_count:N, item_datas:[...]},每篇结构:
item_id, title, create_time
item_stat:
consume_data: impression_count / go_detail_count / fans_impression_count
consume_detail: click_rate / read_complete_rate / read_duration
interaction_data: digg_count / comment_count / share_count / repin_count
3.2 GET .../item/info — 单篇详情
参数:type、item_id。响应 code:0 + 该篇统计快照,计数器实时变动(实测 impression 11737→11739),固化函数里用作列表字段的刷新校验,失败不阻断。
3.3 GET .../item/traffic_analyse — 流量日趋势
参数:type、item_id、from、to(YYYY-MM-DD)。响应 code:0 + daily_stats 日序列。超窗(发布日距今 >8 个月)返回 code:1200。
3.4 GET .../item/interaction_analyse — 互动日趋势
参数同上。响应 code:0 + daily_interactions 日序列。窗口 1 年,超窗同样 code:1200。
3.5 错误码表(实测)
| code | 含义 | 触发场景 |
|---|---|---|
| 0 | 成功 | — |
| 1105 | 缺必要参数 | 趋势接口缺 from/to |
| 1200 | 数据超窗 | 作品发布日超出流量 8 个月/互动 1 年窗口,缩查询窗不可绕过 |
4. 防护档案
- argus JS 挑战:裸 urllib 请求 mp.toutiao.com 会被回挑战页(2026-08-09 实测);
curl_cffi impersonate="chrome"的 Chrome TLS 指纹对 statistic v2 接口族实测放行(2026-08-31,本页 HTML 与 4 个接口全部 200)。 - 无参数签名:全接口族无 X-Bogus/argus 类签名参数,query 直传。
- 会话失效表现:Cookie 过期预期返回 HTTP 401/403 或非 JSON 响应(挑战页),固化函数对这两种情况分别抛
PermissionError/RuntimeError并降级为cookie_invalid状态,不会静默吞掉。 - 频控:未做压力探测;固化函数分页与趋势请求间
sleep(1)保守节流。
5. 固化产物与复现
采集函数已固化:tools/yinliu_data/platforms.py:304 fetch_toutiao_works_single(cookie, ptype=1),挂入 fetch_toutiao 主路线(接口优先,xlsx 导出/插件代抓/urllib 依次兜底),每日 07:10 的 YinliuDailyChain 自动覆盖。实跑验证:
from cookies import load_cookie_header
from platforms import fetch_toutiao_works_single
cookie, _ = load_cookie_header('toutiao') # .yinliu_local/cookies/toutiao.txt
r = fetch_toutiao_works_single(cookie)
# status: ok | 6 篇 | 趋势 traffic 通 4 / interaction 通 4 / 超窗降级 4 发
实测样本(2026-08-31,自有账号):「梅园的荷花开了」展现 11739 / 阅读 1015 / 赞 14 / 评 2 / 分享 9 / 藏 9 / 点击率 8.65% / 完读率 56.04% / 平均阅读时长 22s / 粉丝展现 14。
6. 未决项与挂起清单
- 多页翻页未实测:账号仅 6 篇图文,单页 30 即全量;分页参数有效性仅由"超页返回空数组"佐证。触发条件:账号图文超 30 篇后回补校准。
- type=2(微头条)语义未定:账号无微头条数据,函数预留
ptype参数默认只采 1。 - platform/location 趋势拆分:chunk_372 调用点可见该维度,未入函数,当前维持聚合口径。
- 日期格式短路:5 候选仅
YYYY-MM-DD实测,其余 4 格式未全量重放(不影响现行用法)。 - 粉丝趋势端点:
fans_trends已在 chunk_358 定位,未实测(本单边界外)。
7. 方法论沉淀(可检索)
- 后台接口侦查顺序:curl 带 Cookie 直连页面 → 识别微前端/框架签名 → 落盘全部 bundle/chunk → 正则扫端点路径 → 调用点反查实参(别猜参数名) → 脱离浏览器逐接口重放 → 三档结论(YES/PARTIAL/NO),未验证不入库。
- 路线教训:实参定位优先浏览器抓包,纯逆向 chunk 是兜底手段——本次顺序颠倒,Phase 2 耗时虚高。
- 防护定性要收窄:"平台有反爬"不是结论,要精确到"哪层指纹拦、哪层放行":头条 = argus 拦 urllib,放 Chrome TLS 指纹,接口层零签名。
- 趋势类数据有生命周期:8 个月/1 年窗口按日发布日流失,接入越早历史越全,采集链必须日跑不能攒。
结案备注
- 侦查工件全部落盘
tools/yinliu_data/recon/(页面 HTML、bundle、9 chunk、12 个脚本、16 份响应 JSON),可供复核。 - 本单经 CrewAI pachong 团队三单交付(侦查 → Phase 3 重放 → 固化收尾),pinkong 品控终验通过;品控曾标注 4 个工件"未找到"系检索路径遗漏,本文撰写时已逐一核实在盘。
