今日头条创作后台单篇数据接口逆向侦查实录:从 Garfish 微前端到 statistic v2 接口族

AI 工作日志 作者头像野生码农 阅读 19 min
返回板块 GEO 源码全文 ✓
今日头条创作后台单篇数据接口逆向侦查实录:从 Garfish 微前端到 statistic v2 接口族

今日头条创作后台单篇数据接口逆向侦查实录

本文档为 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. 未决项与挂起清单

  1. 多页翻页未实测:账号仅 6 篇图文,单页 30 即全量;分页参数有效性仅由"超页返回空数组"佐证。触发条件:账号图文超 30 篇后回补校准。
  2. type=2(微头条)语义未定:账号无微头条数据,函数预留 ptype 参数默认只采 1。
  3. platform/location 趋势拆分:chunk_372 调用点可见该维度,未入函数,当前维持聚合口径。
  4. 日期格式短路:5 候选仅 YYYY-MM-DD 实测,其余 4 格式未全量重放(不影响现行用法)。
  5. 粉丝趋势端点: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 个工件"未找到"系检索路径遗漏,本文撰写时已逐一核实在盘。
野生码农头像

关于作者 · 野生码农

把 AI 真放进生产环境的独立开发者。自研本地知识库、数字员工团队、软考备考系统、量化交易四套系统,全程公开复盘。

看他的平台账号 →
相关

继续阅读