调试台与工具
控制台里有三个页面,可以让你在使用中把这套系统摸清楚:/playground 用表单驱动任意读取接口,并在失败时告诉你问题出在谁身上;/tools 把服务每次请求都要用到的几块能力直接暴露出来;/docs 则是由你正在运行的这个实例自己生成的 API 参考。读完这一页,你应当能发起一次调用、在失败时读懂失败、把那次上游请求原样在本实例之外复现出来,并把一个签名拆开来看。
这三个页面各自解决什么问题
Section titled “这三个页面各自解决什么问题”| 页面 | 路径 | 做什么 | 代价 |
|---|---|---|---|
| 调试台 | /playground |
用表单调用真实的读取接口,展示归一化结果、平台原始响应、失败要素以及可直接复制的代码片段 | 一次真实的上游请求和一个身份,除非命中缓存 |
| 基础工具 | /tools |
计算签名、反解签名、解析链接、批量解析、铸造游客身份 | 除铸造外全部为零;铸造要启动一个浏览器 |
| 接口文档 | /docs |
用控制台当前语言渲染本实例自己的 OpenAPI 文档 | 无 |
它们之所以分开,是因为代价不同。/tools 上除最后一个标签页外,全都是对你输入的字符串做算术:不发网络请求、不消耗身份、也不会在请求日志里留下一行。而 /playground 上的每一次操作都会经过调度器并真实消耗资源。把便宜的东西放在不会意外变贵的地方,正是这种拆分的意义。
三个页面都在登录之后才能访问。外壳、导航与角色见控制台总览。
调试台(/playground)
Section titled “调试台(/playground)”页面铺满整个视口,分为三栏可拖拽面板,而不是一列不断向下滚的卡片:
- 接口 —— 接口目录,下方是本次会话的调用历史。
- 请求 —— 当前所选接口的表单。
- 响应 —— 状态、载荷、失败要素、签名、代码片段。
分隔条的位置按浏览器记忆,存放在 dtk.split.playground 下。默认宽度为 16 / 42 / 42 百分比,且三栏分别不会窄于 170 / 320 / 320 像素。
三栏上方是一条地址栏,实时显示即将调用的方法与 URL(含查询串),随你输入而变化。发起请求按钮就在那里,紧挨着它要调用的 URL。在表单里按回车同样会发送。
响应面板默认折叠,需要时再展开——否则一份很大的原始响应会把它下面的所有内容顶出屏幕。唯一的例外是失败面板,它永远不折叠:调用失败时,它就是你来这个页面的全部理由。
共十项。第一项与平台无关;其余九项在表单顶部带一个平台选择器,路径形如 /api/v1/{platform}/...。
| 条目 | 方法 | 路径 | 调度器接口名 |
|---|---|---|---|
| 解析任意链接 | POST |
/api/v1/parse |
展开链接后才能确定 |
| 作品详情 | GET |
/api/v1/{platform}/video |
{platform}.content_detail |
| 作品评论 | GET |
/api/v1/{platform}/video/comments |
{platform}.comments |
| 评论回复 | GET |
/api/v1/{platform}/video/comments/replies |
{platform}.comment_replies |
| 作者资料 | GET |
/api/v1/{platform}/user |
{platform}.author_profile |
| 作者作品列表 | GET |
/api/v1/{platform}/user/posts |
{platform}.author_posts |
| 作者点赞列表 | GET |
/api/v1/{platform}/user/likes |
{platform}.author_likes |
| 合辑 / 播放列表作品 | GET |
/api/v1/{platform}/mix/posts |
{platform}.mix_posts |
| 作者粉丝列表 | GET |
/api/v1/{platform}/user/followers |
{platform}.author_followers |
| 作者关注列表 | GET |
/api/v1/{platform}/user/following |
{platform}.author_following |
最后两项仅 TikTok 提供。注册表里只有 tiktok.author_followers 与 tiktok.author_following,没有对应的抖音条目,因此在平台选择器停在抖音时点这两项,会在 API 边界就被以 UNSUPPORTED_CONTENT(400)拒掉:没有 request_id,失败面板也匹配不到同名的调度器接口。这种不对称来自平台本身——即使拿一个健康的游客身份去问,抖音对关注关系也只回“未登录”;而一个照样把任务提交下去的路由,只会返回一页空数据,让你以为这位作者没有粉丝。调试台默认停在抖音,所以如果你先点了这两项,看到的就是它。
调度器接口名是令牌桶与熔断器的键,也是失败面板和健康度表格所使用的名字。POST /api/v1/parse 没有固定的接口名,因为 worker 只有在跟随链接之后才知道自己要调哪个接口。
这份目录是手工维护的读取接口清单,并不是 OpenAPI 文档的渲染结果——API 的其余部分(身份、下载、设置、资料库)不在这里。那些请用 /docs 或 REST API 指南。
组装一次请求
Section titled “组装一次请求”字段分为三组。
目标(Target)。 你要问的对象。两个字段互为备选时——url 或 aweme_id、url 或 sec_user_id——必须且只需填其中一个;两个都空时,表单会在两个字段上同时提示。有些接口则是真正的必填项:回复接口的 comment_id、合辑接口的 mix_id。
分页(Paging)。 支持分页的接口上会出现 cursor 与 count。count 在表单和 API 两侧都被限制在 50 以内。
请求(Request)。 每个读取接口都接受的信封参数:
| 参数 | 类型 | 控制台默认值 | 作用 |
|---|---|---|---|
include_raw |
boolean | 开 | 同时返回平台自身未经处理的响应。API 自身的默认值是关;控制台把它打开,是因为对照两者正是这个页面存在的意义。它很大——单个作品动辄数百 KB。 |
wait |
number | 空 | 在同一连接上等待任务这么多秒。留空或填 0 会立即返回 task_id。表单允许 0–30;实例上限由 api.max_wait_seconds(默认 30)决定,超过就是 400。 |
proxy |
text | 空 | 让上游请求走这个出口。除非管理员把 security.request_proxy 设为 public 或 any,否则会被拒绝。 |
identity |
选择器 | 任意 | 只用指定的这一个身份发送,不会换成别的。需要 identity:manage(或 admin)以及 operator 角色。 |
refresh |
boolean | 关 | 忽略响应缓存和正在进行的相同任务,重新向上游请求一次。 |
explain |
boolean | 关 | 报告本次请求实际是怎么发出去的。见下文。 |
identity 做成选择器而不是输入框,是因为它的值是没人会凭记忆敲出来的 UUID,而你真正要选的其实是“用我的哪个账号”。列表里显示完整 id、身份状态,以及它是否已登录。已退休的身份不在列表中——退休会抹掉 cookie,所以它已经没有任何东西可以拿来发送了;在平台相关的接口上,也只会列出该平台的身份。切换平台时会清掉已经失配的绑定,而不是让你把抖音身份发到 TikTok 接口上,再去读那个 400。
如果选择器是空的,说明当前调用方无权列出身份。这在本页面上不是 bug:列出身份和使用身份受同一个 scope 管控。
即使实例的 security.request_proxy 是默认的 deny,proxy 字段依然会显示。这是有意为之——一个明确指出设置项名称的拒绝,比“这个字段根本不存在”更有用。参见配置参考。
在动用 refresh 之前,值得先弄清它意味着什么。有两套机制让重复调用变得免费:任务层会合并到一个正在进行或刚刚完成的相同任务上,取数层会缓存整形之后的响应体。refresh 把两者都关掉,代价是一个身份和一次真实的上游请求。缓存的存活时间取决于请求的对象,且可配置:
| 设置项 | 默认值 | 适用于 |
|---|---|---|
cache.content_ttl |
1800 秒(30 分钟) | 单个作品 |
cache.author_ttl |
900 秒(15 分钟) | 作者主页 |
cache.list_ttl |
300 秒(5 分钟) | 所有分页接口 |
校验优先在前端完成:必填项、二选一分组、数字与取值范围都会在发送之前检查,所以打错字不会有任何代价。
发起请求,以及如何读状态条
Section titled “发起请求,以及如何读状态条”控制台使用你的会话 Cookie 调用。这个 API 以异步为先:不带 wait 的调用会返回 202 和一个 task_id,控制台会替你轮询。HTTP 请求 60 秒超时,任务轮询 180 秒后放弃。请求进行中会出现取消按钮。
结果落定后,响应栏顶部的状态条会给出:
| 项 | 含义 |
|---|---|
HTTP <status> |
最终 HTTP 响应的状态码 |
| 耗时 | 浏览器侧测量,从点击到拿到结果——包含轮询时间 |
| 服务端耗时 | meta.duration_ms,worker 记录的上游调用耗时 |
| 缓存命中 | 结果是否来自响应缓存(仅成功时显示) |
| request_id | 关联 id,可复制。在日志页上就是用它检索 |
两个耗时分开显示是有意的。浏览器耗时长而服务端耗时短,说明时间花在了排队上,而不是花在平台上。
成功时有四个可折叠面板:
- 归一化结果 —— 剥掉所有
raw片段之后的载荷。这是所有调用方看到的稳定契约。 - 原始响应 —— 平台自己的响应,只有设置了
include_raw时才存在。没有时面板会说明,并提供带上原始响应重新请求按钮,一次点击即设置该参数并重新发送。 - 响应元数据 —— 完整的
meta对象。如果载荷是一页数据、带有游标且has_more不为 false,这里会出现获取下一页按钮;它会填好cursor并再次发送,也就是再消耗一次请求。 - 可直接复制的调用示例 —— 见可直接复制的代码片段。
失败面板会自动展开,把五样东西集中在一处:稳定错误码(若错误带有重试间隔则一并显示)、给人看的错误信息及提示、承载这次调用的身份、该接口的熔断状态,以及身份池现状。request_id 就在上方的状态条里。
这些正是区分平台改了与我们没有身份了所需要的事实:
| 你看到的 | 通常意味着 |
|---|---|
有错误码、有身份、熔断为 closed、身份池健康 |
问题出在这一次请求或这个目标上。去读错误码。 |
该接口熔断为 open |
连续失败触发了熔断,这个接口正在被休息。所有接口的熔断状态都列在总览页(/)上,见控制台总览;熔断打开期间该怎么办见身份与代理。 |
身份池 可用 0 |
没有任何身份能承载请求。在有身份恢复之前,调用会以 IDENTITY_POOL_EXHAUSTED 被拒。见身份与代理。 |
| 完全没有 request_id | 请求根本没进入调度器——它在 API 边界就被拒了(scope、参数校验、限流)。 |
承载身份有两个来源。如果这次运行要求了 explain,并且控制台走的是异步路径——没填 wait,或者 wait 到期了——身份会随任务一起立刻返回并显示。否则控制台会拿 request_id 去请求日志里查,而日志会略有延迟;延迟期间面板会如实说明并提供刷新按钮,而不是假装答案不存在。
即使开了 explain,只要表单里填了 wait,走的也是后一种情况。控制台只从它轮询到的那个任务里读取 explain,所以在 wait 窗口内直接同步返回的结果,身份仍然要靠请求日志去查,而签名、cookie 与上游 curl 三个面板则会一直是空的。打算解释的那些调用,请把 wait 留空。
响应下方还有一个接口与身份池面板,即使你一次调用都还没发起也可以看:所选接口的熔断状态(或者全部接口中有多少个已熔断的计数)、成功率、风险率、样本数,以及按状态统计的身份池分布。它每隔数秒轮询一次,因此请求失败的那一刻看到的就是当时的状态。一个赶来排查“为什么什么都不好使”的运维,需要的正是这些,而不是先看响应体。
签名面板列出本次请求实际携带的签名参数,直接从 worker 发出的那个 URL 里读出来。值完整展示,不做省略——看签名的目的就是拿它跟别的东西比对,而半个签名跟什么都比不了。
它会挑出这些参数:
a_bogus、X-Bogus、X-Gnarly、X-Dynosaur、msToken、x-secsdk-web-signature、verifyFp、fp、uifid。
反解这些签名按钮会把整条已签名 URL 连同它的 User-Agent 一起提交给 /api/v1/tools/decode,并就地渲染结果——和反解签名标签页看到的是同一套视图。这是单独的、需要你主动点的一步,而不是自动发生的:大多数调用并不会问这个问题,自动触发就等于为每次调用都多花一个来回。
这个面板需要有一份 explain 才能有内容,因为真正起作用的签名是 worker 针对平台自己的 URL 生成的,而那个 URL 并不属于普通响应的一部分。没有 explain 时,面板会如实说明,并提供重新发起并说明本次请求。
它还需要异步路径。控制台是从它轮询到的那个任务里取出 explain 的,所以一次填了 wait、并在窗口内就完成的调用,面板照样显示“已开启 explain。发起请求后,此面板就会填上内容。”的提示——尽管 explain 确实生效了、也被审计了;它下方的 cookie 与上游 curl 空着也是同一个原因。把 wait 清空再发一次即可。
explain —— 本实例究竟发出了什么请求?
Section titled “explain —— 本实例究竟发出了什么请求?”explain=true 让实例把它发出的那次上游调用按原样描述出来。
返回什么
| 字段 | 含义 |
|---|---|
method |
向上游发送时使用的方法 |
url |
完整的平台 URL,含签名参数 |
headers |
传输层即将发送的全部请求头——签名器贡献的那些,加上该身份自己的 User-Agent、语言与客户端提示 |
cookie_header |
该身份的 cookie,拼成一个可直接粘贴的 Cookie: 请求头 |
identity_id |
承载这次尝试的身份 |
signer |
生成签名的签名器 |
endpoint |
内部接口名,例如 douyin.content_detail |
proxy |
该身份的出口,已脱敏 |
代价。 explain 描述的是一次真实尝试,而命中缓存的响应并没有发起任何尝试——因此 explain 隐含 refresh:它会跳过响应缓存,而且带 explain 的调用永远不会被合并到一个不带 explain 的在途任务上。每一次带 explain 的调用都是一次真实的上游请求,都会消耗一个身份。
谁可以要。 返回值里含有一份活的 cookie,因此它按身份池管理而不是按读取来管控:需要 identity:manage(或 admin)scope 且 具备 operator 角色。一个 douyin:read 的 key 可以请求本实例去使用某份 cookie;但它不能要求本实例把那份 cookie 交给它,而“把你发出的请求给我看”只是绕了个远路的同一件事。
它会被审计。 每一次被接受的 explain 都会向审计日志写入一条 request.explained,记录是谁、针对哪个接口提的要求——绝不记录 cookie 本身。这与身份页展示 cookie 时所做的取舍是同一笔交易。
它在失败时依然可用。 explain 在成功和失败两种结果上都会被保存,因为“请求被拒了,让我看看我们发了什么”正是这个功能存在的理由。失败的任务和成功的任务把它放在完全相同的位置。
对无权查看者会被剥离。 explain 与任务结果一起保存。任何人回读这个任务时,除非该读者本来就有权直接查看那份 cookie,否则 explain 块会被移除。
它在协议上出现在哪。 在响应元数据的 explain 键下——查询任务时是 result_meta.explain;而当你给的 wait 足够长、任务成功的结果直接同步返回时,它在 meta 里。若任务是在 wait 窗口内失败的,返回的就是一个错误信封,而错误信封的 meta 里只有 request_id。explain 依然存在那个任务上,只是必须回头用 GET /api/v1/tasks/{task_id} 去读它的 result_meta.explain。
承载身份与它的 cookie
Section titled “承载身份与它的 cookie”有了 explain,接口与身份池面板就会多出关于眼前这次运行的具体事实:身份 id、签名器、脱敏后的出口,以及那份 cookie。
cookie 藏在显示完整 cookie这一次点击之后,而不是藏在掩码之后。半份 cookie 帮不了任何人,而你已经指名要它了;这一次点击换来的,只是它不会就那么摊在一块共享屏幕上。点击之前,面板只显示一共有多少条 cookie。
把你复制到的东西当作凭证对待。它是某个真实平台账号或游客的活会话,任何拿到它的人都能用,它不该出现在 bug 报告、共享的 Postman 工作区或聊天消息里。参见安全。
上游 curl
Section titled “上游 curl”cookie 下方,控制台会把整个上游请求拼成一行 curl:平台自己的接口、逐字节原样的已签名查询串、传输层原本要发的全部请求头,以及该身份自己的 cookie。
这正是任何人排查一个被拒的调用时最终都会去手工拼的东西,而手工拼恰恰是出错的地方。有三件事必须对齐,否则平台就会拒绝你;而这三件事在这里都是已知的,在此之前则无从猜起:
- 查询串必须逐字节原样发送。 签名覆盖的是它自己的编码器产出的那个查询串。重新编码它——大多数 HTTP 客户端都会热心且无声地这么做——就改变了签名所覆盖的字节。
- User-Agent 必须是参与哈希的那一个。 两个平台都会把 UA 计入签名。“我发的 UA 不是我签名时用的那个”,是手工构造请求时看起来完全正确却失败的最常见原因。
- cookie 必须是该身份自己的。 平台响应的是一个会话。签名正确但不带 cookie,得到的是
200和一个空响应体。
这行 curl 的有效期也很短。签名里带着时钟,cookie 里带着会话,两者都会过期。如果它十分钟前还能用而现在不行了,重新发起一次带 explain 的调用,而不是去调试那行旧命令。
在 Postman 里使用
Section titled “在 Postman 里使用”Postman 的导入器接受原始 cURL 命令——Import → Raw text,粘贴,它就变成一个请求。有两个注意点在这里比在多数 API 上都重要得多:
- 确认查询串没被改动。 任何重新编码查询串的客户端都会破坏签名,而失败的表象是“签名不对”,而不是“客户端不对”。导入之后,把 Postman 里的 URL 和你粘贴的那一行逐字符比对。如果不一致,就把整条 URL 当作一个不透明字符串发送,而不要让它被解析成键值对参数。
- TikTok 无论如何都会拒绝它。 TikTok 会校验 TLS 指纹与 User-Agent 是否自洽。Postman、
curl、httpx以及所有普通 HTTP 客户端都会呈现它们自己的 TLS 指纹,因此一个 Chrome 的 User-Agent 走在非 Chrome 的握手上,无论签名多正确都会被拒。这不是加个请求头能解决的问题——本项目之所以自带浏览器级传输层,原因就在这里。在抖音上,同一行命令通常是可以跑通的。
同样的提醒也适用于 curl 本身:在一台 curl 并非基于浏览器 TLS 配置编译的机器上,用它来确认发出去的是什么没问题;但不要因为它失败就断定签名有错。
可直接复制的代码片段
Section titled “可直接复制的代码片段”可直接复制的调用示例面板会按表单当前的状态生成代码片段,共三种:
| 片段 | 生成内容 |
|---|---|
curl |
curl -X <method>,带 Authorization: Bearer 与 Accept-Language;POST 时另加 Content-Type 和 -d |
| Python | 一段 httpx 调用,含 params、json、请求头与 60 秒超时,最后打印 payload["data"] |
| JavaScript | 一段 fetch 调用,在 payload.success === false 时抛错,否则输出 payload.data |
每个片段里都带着占位符 key dtk_xxxxxxxx_your_key_here。请把它换成 API 密钥页面上真实的 key——控制台自己用的是会话 Cookie,而脚本没有。在浏览器之外,请使用 Authorization: Bearer <key> 或 X-API-Key 请求头。
这些片段调用的是本 API。它们不是上游 curl,也不需要任何 cookie。
本次会话的历史
Section titled “本次会话的历史”接口目录下方是本次会话最近 12 次调用:调了哪个接口、哪个平台、成功与否、耗时多少。点击其中一条会把它的参数填回表单——并且刻意不会重新发送,因为一次会重放上游调用的点击,等于在你没打算的情况下消耗掉一个身份。
历史只保存在内存里,离开页面即消失。一次请求可能携带代理密码或身份 id,这两样都不该存进比标签页活得更久的地方。
基础工具页(/tools)
Section titled “基础工具页(/tools)”五个标签页,背后是五个 API 接口。这些是服务每次请求都要用到的零件,之所以公开出来,是因为这是一个开源项目,会有人在它们之上做东西。除最后一个标签页外,这里的一切都是对你输入内容的纯函数运算:不发网络请求、不消耗身份、不产生请求日志。
有一条提醒值得先说,因为它是所有人第一个撞上的:光有签名是不够的。 两个平台响应的都是一个会话,所以一个正确的签名在不带 cookie 的情况下发出去,得到的是 200 和一个空响应体。签名标签页会告诉你还有什么必须对齐;铸造标签页则是产出那样东西的地方。
POST /api/v1/tools/sign —— 为平台 API 地址计算所需的签名参数。纯算术运算:不发起任何请求,不消耗身份,结果只取决于你提交的内容。
| 字段 | 长度上限 | 说明 |
|---|---|---|
| 平台 | — | douyin 或 tiktok |
| API 地址 | 4096 字符 | 含查询串的完整 API 地址,必须以 http:// 或 https:// 开头 |
| User-Agent | 512 字符 | 两个平台都会把它计入签名。留空则使用默认 Chrome UA,并在响应中回显 |
| msToken | 512 字符 | 仅 TikTok。它会被封进签名,因此必须是请求实际会携带的那个 token——填个假的比不填更糟 |
| Cookie | 8192 字符 | 任意粘贴格式:Cookie: 请求头、DevTools JSON、Netscape 格式皆可。抖音的 x-secsdk-web-signature 是对其中的访客 ID 计算的 |
留空时使用的默认 User-Agent:
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36从示例开始。 两个按钮会用真实浏览器请求填好所有字段——抖音的主页查询和 TikTok 的作品列表。有两样东西被刻意去掉了,而这两处删除本身就是要点:签名参数没有了,因为它们正是这个工具算出来的,粘贴一个过期的签名是拿到“200 + 空响应体”的头号原因;cookie 的值是占位符,因为一份 cookie 就是一个活的登录态。cookie 的名字是真的,而那正是人们看着这个字段时真正想知道的东西。
结果。
- 签名后的地址 —— 可直接发送,并附带反解这个签名按钮,把整条 URL 交给反解标签页。这两个表单互为逆运算,把一个直接喂给另一个是你能用它们做的最有用的事。
- 这个查询串是怎么拼出来的 —— 同一个结果的展开视图,流水线的每一层各占一格,按运行顺序排列。请求被拒时值得读的正是这部分,因为它告诉你缺的是哪一层,而不是丢给你一整条扁平的查询串。
- 新增的参数 —— 签名参数,按算法名归类。
- 签名所需的请求头 —— 这些必须与签名后的查询串一起发送。缺少它们时,抖音会拒绝受签名保护的接口。
- User-Agent —— 回显。请用这个字符串原样发送请求。
各层:
| 层 | 贡献了什么 |
|---|---|
business |
你放在 URL 里的参数,但不含 msToken——无论它写在哪里,它都是会话值,会被算进下一层 |
session |
msToken,以及说明其来源的 ms_token_source:url、cookies、generated 或 absent |
signature |
签名本体。seal_param 指出真正起签名作用的那个参数,roles 则逐参数说明同一件事 |
websign |
仅抖音:对访客 ID 计算的 x-secsdk-web-signature,附带盐值与 md5 原文 |
上表里有两处不对称是平台自己造成的,不是本项目的选择,而且都属于读者容易去“修正”的那类东西:
- 只有抖音会出现
generated。 URL 里没有msToken时,抖音的签名器会自己造一个。TikTok 的签名器从不这么做,因为 TikTok 会校验带上的 token,却接受不带 token——伪造的 token 会验证失败,缺失的 token 反而没事。没有 token 时,TikTok 的session层会以no_ms_token_supplied标记为未执行,并发送一个空的msToken。 - 在 TikTok 上,
algorithm和真正的签名参数不是同一个。algorithm报的是X-Bogus,因为那是这套方案在协议上的稳定名称;但 TikTok 网页端在 HTTP 请求上发的X-Bogus是常量1。X-Gnarly才是覆盖查询串的签名,X-Dynosaur是它所覆盖的环境上报。只按algorithm画出来的图,指向的恰好是那个什么都没签的参数,所以才有seal_param来指向正确的那个。
signature 层有两样东西不会展示给你,以及原因:
input_reconstructible是false,并且会一直是。签名是对其自身编码器产出的查询串计算的,而最终发送的字符串由另一个编码器构建,因此对某些输入而言,被签名的字节并不是被发送的字节。一个对大多数查询都正确的原文,会被当作算法文档来引用,那比不提供更糟。websign的原文会展示,但只在重新计算并与旁边的签名核对通过之后。无法验证的原文会被丢弃而不是发布,理由同上。
如果你提交的 cookie 里没有 uifid,websign 层会出现并以 no_uifid_cookie 标记为未执行。这不是无关紧要的细节:它就是 headers 为空的原因,也是受签名保护的抖音接口会以 uifid 为由拒绝该请求的原因。请到身份标签页铸造一份。
无论工具返回什么,旁边那份清单依然成立。请用回显的那个 User-Agent 原样发送请求;使用与之自洽的 TLS 指纹;并且带上 cookie。本项目在 2026-09-08 用这个接口自己的输出实测过:三者齐备时,返回 2537 字节和预期的主页数据;完全不带 cookie 时,200 和一个空响应体。
POST /api/v1/tools/decode —— 与旁边那个标签页互为逆运算,也是本项目自己逆向这些算法而不是照搬别人移植版的理由:自己拥有的实现才讲得清楚。同样是纯算术运算。
粘贴一条完整的已签名 URL,其中每个签名参数都会按它实际封住的那段字符串被拆开来读。这是最有用的形式:只有从完整 URL 出发,才能确切知道被覆盖的字符串是什么,也只有那时校验才跑得起来。单个值,或者一对 name=value,同样可以。值的长度上限是 8192 字符。
即使答案看起来显而易见,也请把 User-Agent 字段填上。两个平台都会把 UA 计入签名,而这正是你弄清“正在发送的 UA 是不是签名时用的那个”的办法。
按什么参数解读默认保持自动,此时会按其他参数都不具备的特征来识别。形态不明确时可以自己指定:a_bogus、X-Bogus、X-Gnarly、X-Dynosaur、msToken、x-secsdk-web-signature、verifyFp。
这里的「反解」指什么
Section titled “这里的「反解」指什么”三种截然不同的东西看起来都像同一种表格行,而把第二种当成第一种,正是这套设计要防止的错误。每个字段都会声明自己属于哪一种。
| 类型 | 界面显示 | 含义 |
|---|---|---|
plain |
已还原 | 直接从载荷里原样读出。aid、page_id、版本号、计数器、随机数 |
time |
时钟 | 一个时钟,同时给出原始数字与 ISO-8601 时刻 |
environment |
环境上报 | SDK 对它自认为所处页面的描述——屏幕尺寸、各种标志位、canvas 与 WebGL 哈希 |
digest |
哈希 | 一个哈希,或哈希的某几个字节。输入不可还原;见校验部分 |
checksum |
校验和 | 内部冗余,在这里被重新计算——这正是证明反解正确的东西 |
opaque |
未解释 | 每次抓包里都存在,但含义尚未确定。如实命名,不做臆测 |
所以如实的总结是:可还原的值会原样返回,哈希是被校验而不是被倒推,而下发型的值底下根本没有明文。 msToken 和访客令牌由平台签发或随机生成;对它们,反解器报的是 not_computed,而不是报告“没能找出本来就不存在的东西”。
对于任何哈希,反解器做的是校验而不是猜测。给它一个候选值——一条带查询串的 URL、一个 User-Agent——每项校验会返回下列之一:
| 状态 | 含义 |
|---|---|
match |
你给的候选值就是这个签名封住的输入。被覆盖的确切原文会展示出来,因为它复现出了旁边那个值 |
differs |
不是。正是这一项把“我的请求被拒了”变成“我正在发送的签名是对另一个 URL 计算的” |
not_supplied |
你没有提供候选值。这不是失败,展示方式也刻意与 differs 不同 |
每项校验还会给出有多少位的证据,好让 “match” 不会比它应有的把握更自信:
| 参数 | 每项校验的位数 | 说明 |
|---|---|---|
a_bogus |
24 | 每条链 3 个 SM3 字节,分别覆盖 query、body 与 User-Agent |
X-Bogus |
16 | 双重 md5 的两个字节 |
X-Dynosaur |
32 | 覆盖 query、User-Agent 与 body 的 32 位哈希 |
X-Gnarly |
128 | query、body 与 User-Agent 的完整 md5 |
x-secsdk-web-signature |
128 | 重建原文的完整 md5 |
每个参数能读出什么
Section titled “每个参数能读出什么”| 参数 | 平台 | 返回内容 |
|---|---|---|
a_bogus |
抖音 | 头部 magic、SDK 版本、两个时钟、aid、page_id、双周计数、浏览器信息串、环境与探测标志位、调用桶与陷阱位——外加三条摘要链 |
X-Bogus |
抖音 | 常量前导、精确到秒的时间戳、canvas 常量、三组 16 位摘要以及一个内部校验和。会标注它已被 a_bogus 取代 |
X-Bogus = 1 |
TikTok | 报告为一个什么都没签的常量,而不是格式错误——TikTok 网页端发的确实就是这个字面量 |
X-Gnarly |
TikTok | 十六个字段:两个校验和、env 与 ub 码、query/body/User-Agent 的 md5、时间戳、随机数、SDK 与 SCM 版本、调用序号 |
X-Dynosaur |
TikTok | 二十五个字段:各类版本号、计数器、页面标识、WebGL/canvas/设备哈希、时间戳、四个 32 位哈希 |
msToken |
两者皆有 | 只有长度、padding 与字母表。它是被签发的,不是算出来的。长度还能判断它属于谁——一个抖音 token 放进 TikTok 请求里会短 20 个字符,在有人去数之前看起来就跟签名 bug 一模一样 |
x-secsdk-web-signature |
抖音 | 摘要本身;当访客 ID 与时间戳与它同行时,还会给出盐值和它的 md5 所覆盖的确切原文 |
verifyFp、fp、s_v_web_id |
抖音 | 前缀、以 base36 毫秒表示的生成时间,以及随机尾部。生成时间远早于携带它的请求,正是一份躺在文件里很久的 cookie 的形态 |
结果下方的注记会说明验证了什么。checksum_verified 表示内部折叠校验和的每一项输入本身都是载荷中的字段,且全部对得上,因此整个布局都正确还原了。noise_not_recoverable 表示有些字节是每次调用随机抽取的、不携带任何含义——同一毫秒内生成的两个签名大部分字节都不同,含义却完全一样。
当一个值完全读不出来时,原因会说明为什么:malformed(并附上未通过的结构检查)、one_way、not_computed 或 unknown_parameter。
GET /api/v1/tools/parse-url —— 不发起任何请求,直接判断一条链接指向什么。它接受分享链接,也接受里面埋着链接的整段剪贴板文本——后者才是两个 App 实际放进剪贴板的东西。
它会报告平台、链接指向的资源类型、ID、存在时的 handle、规范化 URL、它是否是受支持的目标,以及是否需要展开。
具体拿到哪个 ID 因平台而异,因为接口本身就不同:抖音主页链接给出 sec_user_id,TikTok 的则给出 @handle,因为那才是 TikTok 用户详情接口接受的东西。两者都放在 resource_id 下返回。
短链(v.douyin.com、vm.tiktok.com)必须实际跟随跳转才能解析,而跟随跳转是一次这个接口承诺不做的网络调用。它们会带着 needs_expansion: true 且没有 ID 返回;请改为提交到 POST /api/v1/parse——也就是调试台目录里的第一项。
POST /api/v1/tools/parse-batch —— 对粘贴进来的一列内容做同样的识别,因为“一次一条”并不符合人们手上真正有的东西:从表格里拷出来的一列,或者某次抓取的输出。
每行一项粘贴进去。空行和重复项会被丢弃。一行是“分享文案 + 里面夹着一条 URL”时,得到的是那条 URL,而不是四个碎片;一行里有多条 URL 时,会得到多条。
| 限制 | 数值 |
|---|---|
| 返回条目数 | 1000 |
| 接受的文本长度 | 524288 字符 |
如果识别出的去重条目多于返回条目,结果里会带上 truncated,页面会告诉你实际解析了多少条,而不是让你自己去数。
每一行会被归入下列之一:
| 类型 | 含义 |
|---|---|
link |
已识别的链接,带平台、资源类型与 ID |
short_link |
已识别的链接,但要知道目标需要一次网络调用 |
content_id |
一个能对上的裸作品 ID |
bad_id |
一串数字,但不可能是作品 ID |
unknown |
两者都不是 |
两个平台生成的作品 ID,其高 32 位就是签发时的 Unix 秒,所以表格里还会显示每个 ID 的签发时间——它是从 ID 自身读出来的,而不是请求来的。这个时间等于或略早于发布时间,绝不会晚于它。
这项检查无法区分“从未存在过的作品”和“已被删除的作品”:看起来合理的 ID 就是看起来合理。它能做的是拒掉那些根本不可能是 ID 的文本——而这部分正是值得免费做掉的,因为另一种做法是发起一次上游请求、消耗一个身份,去换回同一个结论。
复制这些 ID 会把所有已识别的 ID 逐行放进剪贴板,跳过 bad_id 行。
POST /api/v1/tools/identity —— 驱动真实浏览器访问平台,把平台下发的 cookie 交还给你。这是无法用计算得到的那一半:TikTok 的 msToken 和抖音的 UIFID_TEMP 是平台签发给一个真正加载过其页面的浏览器的。没有任何算法能产出它们,这也正是“光有签名不够”的原因。
它的代价,直说:
- 数十秒。 需要启动浏览器、加载页面,并等待其脚本把 cookie 设置完毕。
identity:managescope。 普通读取 key 不带这个权限。- 一个
browser-rpc服务。 没有配置DTK_BROWSER_RPC_URL时,调用会失败并指出这个设置项。browser-rpc是整个栈里最重的容器,属于可选的 compose profile;见安装与部署。
可选的代理字段让你通过指定出口铸造,而且值得设置:在一个地址铸造、却从另一个地址使用的 cookie,正是两个平台都在找的那种不一致。它与其他所有代理参数一样,要过同一道 security.request_proxy 关卡——工具接口不能成为绕过该设置的通道。
不做任何存储。 cookie 返回给你之后即被遗忘。若要把它加入本实例自己的身份池,请使用身份页或 POST /api/v1/admin/identities/mint——见身份与代理。
返回内容包括 cookie、铸造时所用的浏览器指纹(User-Agent、浏览器系列与主版本号、平台、屏幕、语言、时区),以及它被看到的出口地址。请整套一起使用。cookie 与另一个 User-Agent 搭配使用的身份,比没有身份更弱。
各标签页的代价
Section titled “各标签页的代价”| 标签页 | 接口 | 所需 scope | 网络 | 消耗身份 | 耗时 |
|---|---|---|---|---|---|
| 计算签名 | POST /api/v1/tools/sign |
douyin:read 或 tiktok:read |
无 | 无 | 瞬时 |
| 反解签名 | POST /api/v1/tools/decode |
douyin:read 或 tiktok:read |
无 | 无 | 瞬时 |
| 解析链接 | GET /api/v1/tools/parse-url |
douyin:read 或 tiktok:read |
无 | 无 | 瞬时 |
| 批量解析 | POST /api/v1/tools/parse-batch |
douyin:read 或 tiktok:read |
无 | 无 | 瞬时 |
| 铸造身份 | POST /api/v1/tools/identity |
identity:manage |
一次真实浏览器会话 | 交给你一份新铸造的 cookie | 数十秒 |
这五个接口与本 API 上的其他接口一样,都受限流约束。
接口文档页(/docs)
Section titled “接口文档页(/docs)”内嵌的参考文档
Section titled “内嵌的参考文档”/docs 是控制台自己的参考页。它把 Swagger UI 渲染在控制台外壳内部,指向 /openapi.json?lang=<language>(语言与控制台当前设置一致),并用控制台自己的设计令牌重新配色,免得它看起来像硬拼上去的另一个产品。这层覆盖只涉及颜色、字体与圆角——控件本身的行为保持原样。
组件上方有一张卡片,展示文档地址(可复制)、它声明了多少个路径与操作,以及它是用哪种语言生成的。如果它显示文档里没有任何接口,那属于部署问题而不是数据为空:api 容器没有注册任何路由。
接口默认全部折叠、模型不展开、显示请求耗时,授权信息在刷新后保留。Try it out 处于启用状态,且组件会带上浏览器凭据,所以你在登录控制台的情况下从 /docs 发起的调用,是由你的会话 Cookie 鉴权的——不需要粘贴任何 key。这也意味着它们是真实调用:在平台接口上点一次 Try it out,消耗身份的方式与调试台完全相同。
Swagger UI 的静态资源从公共 CDN 加载(cdn.jsdelivr.net/npm/[email protected]),超时 15 秒。在离线部署或 CDN 被屏蔽的环境下加载会失败,这属于预期而非故障:页面会如实说明,并提供服务端渲染的页面、原始文档以及重试按钮,而不是留下一个空白框。
/swagger 与 /redoc
Section titled “/swagger 与 /redoc”API 还提供自己的文档页面,与控制台互不依赖:
| 路径 | 是什么 |
|---|---|
/swagger |
服务端渲染的 Swagger UI,启用 Try it out,所有分组默认折叠 |
/redoc |
ReDoc,只读的三栏式参考文档 |
/openapi.json |
原始 OpenAPI 文档 |
这两个页面会把自己的 ?lang= 转发给 schema 地址,所以 /swagger?lang=zh 拿到的是中文文档。语言按这个顺序解析:?lang=、Accept-Language、实例的 api.default_language。不受支持的 ?lang= 取值会被忽略而不是报错,解析继续走请求头。支持的取值是 en 与 zh。
控制台刻意不占用 /swagger。/docs 是控制台页面,需要会话;/swagger 保持免凭据,供没有控制台账号的调用方使用——对一个公开实例而言,那是绝大多数使用者。两者同时占用一个路径,会导致你看到哪个页面取决于你是怎么进来的:客户端跳转给你控制台,刷新给你裸文档——而展开一个分组之后,人们做的恰恰就是刷新。
由于 /swagger、/redoc 与 /openapi.json 不需要会话,任何能访问到你的实例的人都能读到它的接口面。对一个以“被别人的程序调用”为目的的服务来说,这是有意为之,但在你把端口暴露出去之前,这是一个需要权衡的事实。参见安全。
在裸页面上完成鉴权
Section titled “在裸页面上完成鉴权”文档声明了两种真实存在的鉴权方式,所以 Authorize 按钮是可用的:
| 方式 | 位置 | 值 |
|---|---|---|
X-API-Key |
请求头 | API 密钥页面上创建的 key |
dtk_session |
Cookie | 登录控制台后自动设置,浏览器会自动带上 |
安全声明是逐接口而不是全局的,因此公开接口不会被错误地标成需要 key。
文档只对响应信封——success、data、error、meta——做类型声明,而不声明每个接口 data 的具体形状。这是一个刻意的取舍:响应是以字典方式组装的,在 schema 里再声明一遍只会让两者逐渐分叉。生成式客户端真正需要的,是那部分永远不变的结构。
用脚本调用同样的东西
Section titled “用脚本调用同样的东西”这些页面上的一切都是普通的 API 接口。先准备一个具备相应 scope 的 key——见用户与 API 密钥——如果你的实例不在默认的回环地址上,请相应调整主机地址。
export DTK_KEY='dtk_...your key...'export DTK_URL='http://127.0.0.1:8000'识别一条链接,不消耗任何东西:
curl -s -G "$DTK_URL/api/v1/tools/parse-url" \ --data-urlencode 'url=https://www.douyin.com/video/7300000000000000000' \ -H "X-API-Key: $DTK_KEY"给粘贴进来的一列链接和 ID 分门别类:
curl -s -X POST "$DTK_URL/api/v1/tools/parse-batch" \ -H "X-API-Key: $DTK_KEY" \ -H 'Content-Type: application/json' \ -d '{"text":"7300000000000000000\nhttps://www.douyin.com/video/7300000000000000001"}'为一个地址计算签名:
curl -s -X POST "$DTK_URL/api/v1/tools/sign" \ -H "X-API-Key: $DTK_KEY" \ -H 'Content-Type: application/json' \ -d '{"platform":"douyin","url":"https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&aweme_id=7300000000000000000"}'反解一个签名,并用一个候选 User-Agent 与它比对:
curl -s -X POST "$DTK_URL/api/v1/tools/decode" \ -H "X-API-Key: $DTK_KEY" \ -H 'Content-Type: application/json' \ -d '{"value":"https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=6383&a_bogus=...","user_agent":"Mozilla/5.0 ..."}'铸造一个游客身份并自己留着——需要 identity:manage,耗时数十秒:
curl -s -X POST "$DTK_URL/api/v1/tools/identity" \ -H "X-API-Key: $DTK_KEY" \ -H 'Content-Type: application/json' \ -d '{"platform":"douyin"}'让一个数据接口说明自己发出的请求——需要 identity:manage 与 operator 角色,会跳过缓存,并被审计:
curl -s -G "$DTK_URL/api/v1/douyin/video" \ --data-urlencode 'url=https://www.douyin.com/video/7300000000000000000' \ --data-urlencode 'wait=30' \ --data-urlencode 'explain=true' \ -H "X-API-Key: $DTK_KEY"当调用在 wait 之内成功时,说明内容位于响应元数据的 explain 键下。其余所有情况都要回头去看任务:wait 不足以让任务完成时,你拿到的是一个 task_id;而任务在窗口内失败时,返回的是一个错误信封,它的 meta 里只有 request_id。这两种情况都请用 GET /api/v1/tasks/{task_id} 读取 result_meta.explain。
完整的请求与响应契约见 REST API 指南。在终端里做同样的事,见命令行参考。