跳转到内容

REST API 指南

本文讲的是如何从程序里调用 HTTP API:怎么认证、统一信封长什么样、异步任务模型怎么工作、怎么翻页、以及你的代码必须处理哪些错误。读完之后,你应该能写出一个客户端:提交任务、取回结果,并在实例开始拒绝你时正确地退避。

它是生成式接口参考的补充,而不是重复。/swagger/redoc/openapi.json 列出了每一个接口、每一个参数和每一条约束,而且它们是由处理请求的同一份代码生成的,永远不会过期。本文解释的是接口清单说不清的部分:一个 202 到底意味着什么、什么时候该重试、以及某个参数会让你付出什么代价。

位置 提供什么 是否需要凭据
/swagger 覆盖全部接口的 Swagger UI,带 “Try it out” 查看无需;真正调用需要 key
/redoc 同一份文档,偏阅读的排版 查看无需
/openapi.json OpenAPI 文档本身,用于生成客户端代码 无需
/docs 控制台自带的接口参考页,带主题、嵌在控制台外壳里 查看无需
本文 如何在程序里用好上面这些

/docs 同样不需要会话:未登录时它会以控制台外壳的未登录状态渲染,并读取与其余几项相同的公开 /openapi.json

以上四个都支持 ?lang=(见响应语言),所以 /swagger?lang=zh 会渲染中文文档。

compose 栈默认把 API 发布在 127.0.0.1:8000(用 .env 里的 DTK_BIND_HOSTDTK_BIND_PORT 修改,见安装与部署)。如果你还没有跑起来的实例、也还没有 key,快速开始大约十分钟就能把两样都准备好。下面所有示例都基于:

终端窗口
export DTK_BASE_URL=http://127.0.0.1:8000
export DTK_API_KEY=dtk_0a1b2c3d4e5f_REPLACE_WITH_YOUR_KEY

路径布局:

前缀 内容
/api/v1/… 程序调用的一切:内容、任务、工具、归档、下载、管理
/api/setup/… 仅用于首次初始化;出于必要对匿名调用方开放,由一次性 setup 令牌把守,并且永远不能被写进 api.public_endpoints
/healthz/readyz 进程探针,在版本化接口之外,也不走信封
/mcp/ MCP 端点,末尾的斜杠要保留——/mcp 会以 307 Temporary Redirect 跳到它。见 MCP 与 AI 客户端

路径里的 v1 是这份契约的版本号。其中还有两样东西同样被当作契约、只增不改也不改名:错误码信封的键datameta 里将来可能出现新字段,所以解析时请宽松一些——只读你需要的键,忽略其余的。

当前运行版本由 GET /api/v1/system/status 给出,返回 versioncommituptime_secondssettings_version、各组件健康状况、身份池统计和存储占用。它需要凭据,因为暴露组件版本号和行数已经超出了一个匿名探针该知道的范围。

除非运营方主动开放,否则每个接口都需要凭据。有少数几个路由天生匿名、且无法被关闭——登录、登出、/api/setup 下的两个路由,以及 GET /api/v1/ios/shortcut(快捷指令在还没有地方存放 API key 的时候就要问它)。完整清单见从未上锁的路由/healthz/readyz 同样不需要认证,而且根本不在 API 文档里。程序用 API key 认证,两种请求头形式任选其一——两者等价,同时存在时先读 Authorization

终端窗口
curl -sS "$DTK_BASE_URL/api/v1/auth/me" -H "Authorization: Bearer $DTK_API_KEY"
curl -sS "$DTK_BASE_URL/api/v1/auth/me" -H "X-API-Key: $DTK_API_KEY"

GET /api/v1/auth/me 是检查一个 key 是否有效、以及它能做什么的最省事的办法:它返回账号、角色、该凭据携带的权限范围、viaapi_keysession),以及这个 key 自己的 rate_limit_per_min

还有第三种形式,但它不是给程序用的:控制台用 POST /api/v1/auth/login 登录并拿到 dtk_session cookie。程序请用 key——key 带权限范围、可以单独撤销,也不会随浏览器会话过期。

一个 key 形如 dtk_<12 位十六进制>_<随机串>。它由 POST /api/v1/admin/api-keys 生成,并且只在那一次响应里出现一次;服务端只保存前缀(用于展示)和 SHA-256 摘要(用于校验),所以谁也读不回来——管理员也不行。撤销在下一次请求即刻生效,因为认证每次都会读那一行。参见用户与 API 密钥

权限范围 能到达
douyin:read 抖音内容接口,以及对抖音链接的 /parse
tiktok:read TikTok 内容接口,以及对 TikTok 链接的 /parse
archive:read GET /api/v1/archive…——本实例已经存下来的内容
archive:export GET /api/v1/archive/export——一次调用拿走整个集合
media:read 下载记录与已存文件
media:write 发起、置顶和取消下载
identity:manage 身份池,以及 identityexplain 这两个请求参数
admin 全部

有两条规则值得单独说,因为它们常让人意外:

  • 即使 key 的所有者是管理员,key 依然只受自身权限范围的约束。 在自部署实例上几乎所有 key 都属于管理员账号;一个纯 douyin:read 的 key 仍然够不到身份管理,也够不到 archive:export
  • 读取任务结果所需的权限范围,与创建它时相同。 GET /api/v1/tasks/{task_id} 会按任务提交时的接口去校验权限范围,所以低权限 key 拿到一个任务 ID 也读不出高权限的结果。

部分操作在权限范围之外还要求角色demo < viewer < operator < admin)——identityexplain 都至少需要 operator。角色属于凭据所归属的账号。?proxy= 不在其列:它完全不做权限范围或角色检查,只由 security.request_proxy 这个设置把守,见下文的 ?proxy=<url>

运营方可以在 api.public_endpoints 里逐条列出接口(写作 GET /api/v1/{platform}/video,与 API 文档里的路径写法完全一致),这些接口就不再需要凭据。admin、auth 和 setup 路径无论设置怎么写都永远不能被开放。匿名调用方只带 douyin:readtiktok:read,并且按客户端地址而不是按 key 计量。

发送一个错误的凭据和不发凭据不是一回事:即使在已开放的接口上,被拒绝的 key 也会得到 UNAUTHENTICATED,而不会被悄悄降级成匿名。这样「你的 key 过期了」会变成一个你看得见的错误,而不是「key 能用,只是看到的东西变少了」。

每一个 JSON 响应——无论成功还是失败——都有同样的四个顶层键。

{
"success": true,
"data": {},
"error": null,
"meta": { "request_id": "b6f0f2c6-4a5f-4a0e-9a1f-2b7c2b6f1f21" }
}
含义
success truefalse。请按它分支,而不是只看 HTTP 状态码
data 接口自己的载荷。successfalse 时恒为 null
error 成功时为 null。否则是 {code, message},另可带 retry_afterdetails
meta 恒带 request_id。可能带 cachedduration_mscursor 以及接口特有的附加字段

请按 error.code 分支,永远不要按 error.message code 是稳定枚举、永不翻译;message 会按调用方的语言渲染,是写给人看的。

提交一个链接会返回 202 和一个任务 ID:

终端窗口
curl -sS -X POST "$DTK_BASE_URL/api/v1/parse" \
-H "X-API-Key: $DTK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://www.douyin.com/video/7123456789012345678"}'
{
"success": true,
"data": { "task_id": "0f2f1b7c-3f9e-4b8a-9a11-0a6d2b0e51c3", "state": "queued" },
"error": null,
"meta": { "request_id": "b6f0f2c6-4a5f-4a0e-9a1f-2b7c2b6f1f21" }
}

发一个不在白名单里的 URL,同样的信封会装着错误,HTTP 状态码是 400

终端窗口
curl -sS -X POST "$DTK_BASE_URL/api/v1/parse" \
-H "X-API-Key: $DTK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://evil.example/video/1"}'
{
"success": false,
"data": null,
"error": {
"code": "INVALID_URL",
"message": "The URL was not recognized as a supported Douyin or TikTok link.",
"details": { "reason": "host_not_allowed" }
},
"meta": { "request_id": "1d5b1a7e-90c7-4f2e-9b3c-6b2f0f9a8d44" }
}

message 这里是英文,因为这条命令没有指定语言,走的是实例默认的 en;加上 ?lang=zh 就会渲染成中文。)

details 按名字组织,形状随 code 而变——出错的字段、拒绝的接口、没找到的 ID。它里面永远不会有凭据。

每个响应都带两个可以记进日志的头:

响应头 含义
X-Request-ID 关联 ID,同时会写进请求日志
X-Response-Time-Ms 本进程在这个请求上花的毫秒数

报 bug 时请附上 X-Request-ID,运营方能在控制台的日志页找到对应那一行。meta.request_id 通常是同一个值——但有一个例外值得知道:由已完成任务渲染出来的结果(即等到了结果的 ?wait= 调用),它的 meta.request_idworker 自己的抓取请求 ID,因为 worker 的元数据被合并进了这里。响应头则始终是 HTTP 关联 ID。

POST 请求体必须以 application/json 发送。裸的 curl -d '{…}' 发的是 application/x-www-form-urlencoded,请求体解析不出来——本文每个示例都带 -H 'Content-Type: application/json' 就是这个原因。解析失败会返回 INVALID_PARAMdetails.fields 里给出字段路径和原因;你发的值故意不会被回显,因为对一个解析失败的请求体来说,那个「值」就是整个请求。

请求体上限为 1 MiB。超出会得到 HTTP 413,code 为 INVALID_PARAM,并带上 details.limit_bytes

从平台抓一次数据,要真实消耗一次上游请求和一个池化身份,而且可能要好几秒。所以数据类接口默认是异步的:先校验、把工作排队,然后立刻返回 202 和一个任务 ID。API 容器本身从不与平台通信。

POST /api/v1/parse -> 202 {"task_id": "...", "state": "queued"}
GET /api/v1/tasks/{task_id} -> 200 {"state": "done", "data": {...}}

取结果有三种方式,区别只在于「由谁来等」。

反复 GET /api/v1/tasks/{task_id},直到 statedonefailed。它可以放心轮询、也可以放心重复调用:任务不变,答案就不变。载荷如下:

字段 何时出现 含义
task_id 总是 你轮询的那个 ID
state 总是 queuedrunningdonefailed
endpoint 总是 提交的是什么,例如 parsedouyin.content_detail
created_atfinished_at 总是 ISO-8601;未结束前 finished_atnull
data done 结果载荷,嵌在信封的 data 里面
result_meta 结束后 cachedduration_msendpointplatformcursor,以及需要权限才可见的附加项
error failed {code, message, retryable},另可带 retry_afterdetails

注意这层嵌套:任务完成后结果在 body.data.data,不是 body.data。另外注意:失败的任务依然是 HTTP 200、success: true——读取任务这个 HTTP 调用成功了;失败在它里面,在 data.statedata.error 里。这与下面的 ?wait= 恰好相反,这个差别很容易绊倒人。

任务存下来的 error 对象带一个明确的 retryable 布尔值,这样读结果的 agent 不必自己背下那张「不可重试」的表。

结果会过期。 载荷保留 retention.task_result_hours(默认 24 小时)。之后行还在、状态和时间戳还在,但载荷被清空,查询会返回 TASK_NOT_FOUND。这意味着重新提交,而不是继续轮询。

给任何提交类接口加上 ?wait=10,连接就会被保持住直到任务有结果,最多等这么多秒。这就是把调用变成同步的方式,它是为那些根本没法轮询的客户端准备的:iOS 快捷指令、一行 shell 命令、表格软件。

结果 状态码 响应体
在时限内完成 200 结果在 data 里,与任务接口返回的完全一致
你等待期间任务失败了 该错误自己的状态码(如 503 正常的失败信封,带该错误的 code
时限内没完成 202 {"task_id": "...", "state": "running"}
超过本实例上限 400 INVALID_PARAM,带 details.max
省略,或传 0 202 立刻返回,与不等待相同
传负数 400 INVALID_PARAM

等待超时后返回的 202 不是错误,也没有丢任何东西。 工作还在跑,稍后用同一个任务 ID 就能取到。把这个提前返回的 202 当成失败的客户端,会把已经排好队的工作再提交一遍,白白多花一次身份,最后拿到同样的答案。

上限是 api.max_wait_seconds,默认 30 秒。超过它的值是被拒绝而不是悄悄缩短——这是刻意的,因为一个要求阻塞五分钟的调用方必须知道自己做不到,否则它会把提前返回的 202 读成失败。当前上限会以 maximum 的形式发布在 OpenAPI 文档的该参数上。

内部机制不因此改变:无论哪种方式,工作都走同一个队列。?wait= 决定的只是谁来持有这个连接。对批处理作业、或任何有自己事件循环的程序来说它都是错误选择——你是在用一条长期占用的 HTTP 连接,换掉自己写一个循环。

3. 让服务端回调你 —— callback_url

Section titled “3. 让服务端回调你 —— callback_url”

POST /api/v1/parsePOST /api/v1/tasks/batch 的请求体接受 callback_url。任务完成后实例会往那里 POST 一条通知,于是既不用轮询也不用阻塞。

这是一个目的地由调用方指定的出站请求——最典型的 SSRF 形态——所以它被层层设限:

  • 除非管理员打开 security.enable_task_webhook(默认关闭),否则一律拒绝。在关闭的实例上传这个参数,会得到针对 callback_urlINVALID_PARAM
  • URL 必须是 https,主机不能是回环、私有或链路本地地址。提交时校验一次,投递时再校验一次,因为设置可能被关掉,而排队中的任务可能活得比「被接受的那一刻」更久。
  • 不跟随重定向,且校验证书。

通知说的是「发生了什么」,不是「抓到了什么」——结果可能有好几兆,而把它 POST 给第三方是一个没人会靠在输入框里填个 URL 来做出的数据流决策:

{
"event": "task.completed",
"task_id": "0f2f1b7c-3f9e-4b8a-9a11-0a6d2b0e51c3",
"endpoint": "parse",
"state": "done",
"sent_at": "2026-09-10T04:15:12.883921+00:00"
}

失败时 "event""task.failed",并多一个 error,里面是 code 和被截断的 message。真正的结果请用 GET /api/v1/tasks/{task_id} 去取。

属性
X-Dtk-Event 请求头 task.completedtask.failed
X-Dtk-Signature 请求头 配置了 security.webhook_secret 时为 sha256=<hex>,对发出的原始字节做 HMAC-SHA256
超时 每次尝试 10 秒
尝试次数 3 次,退避 2 秒、8 秒
提前放弃的情况 429 外的任何 4xx

校验签名时请对原始响应体字节做,而不是对反序列化后再序列化的对象做——键顺序和分隔符会不同,本来正确的校验也会失败。没有配置密钥的话,接收方无从区分一条真通知和任何猜到了 URL 的人。

投递永远不影响任务本身。一个宕机、缓慢或恶意的 webhook 端点不能把一次成功的抓取变成失败的任务;每一次投递失败都只记日志然后咽掉。

GET /api/v1/tasks/{task_id}/events 是一条 server-sent events 流,控制台用它替代同时轮询大量行。

事件 载荷
state 不含结果的任务载荷,状态每次变化时发送
result 任务变为 donefailed 后的完整载荷
end {task_id},紧跟在 result 之后
timeout 先到达截止时间时发送 {task_id, state}
error 行消失时发送 {code: "TASK_NOT_FOUND", message}

?timeout= 把流限制在 1 到 300 秒之间,默认 300;每约 15 秒发一个注释帧,免得中间的代理掐掉一条安静的连接。鉴权在流打开之前完成。需要特权的 explain不会出现在这条流上——请用普通的 GET 取一次,那里的权限范围检查就挨着读取本身。

POST /api/v1/tasks/batch 最多接受 50 条,且总是返回 202。提交是批量的,跟踪不是——每一条都有自己的任务 ID 和自己的命运,所以一条坏链接不会把整个请求糊成一个错误:

{
"success": true,
"data": {
"items": [
{ "url": "https://v.douyin.com/abc123/", "task_id": "0f2f…", "state": "queued" },
{ "url": "https://evil.example/x", "task_id": null,
"error": { "code": "INVALID_URL",
"message": "The URL was not recognized as a supported Douyin or TikTok link.",
"details": { "reason": "host_not_allowed" } } }
],
"submitted": 1,
"rejected": 1
},
"error": null,
"meta": { "request_id": "" }
}

逐条的错误也会按你请求的语言渲染,和这个 API 返回的其他错误一样。/tasks/batch 不接受 ?wait=:请逐个轮询任务 ID,或者提供 callback_url

一旦 Redis 队列达到 sched.queue_max(默认 500),提交会被拒绝而不是继续排队:503,code 为 QUEUE_FULL,带 Retry-After 响应头,details.queued 给出当前深度。retry_aftersched.max_wait_seconds(默认 10)。让调用方干等着,比直接告诉它稍后再来更糟。

有两个刻意的例外:能合并到已在队列中的相同任务上的请求会被放行(它并不增加积压);运营方手动触发的维护作业豁免——因为队列一深就拒绝跑自检,等于恰好在最需要这个工具的时候把它收走。媒体下载豁免,否则某个运营方的批量下载会把实例上所有读请求饿死。

列表类接口用不透明游标翻页。你把上一页给你的东西原样发回去,别的什么都不用做。

参数 出现在 默认值 上限
cursor 平台列表、归档列表 首页 512 字符
count 平台列表 20 50
limit GET /api/v1/archive 50 200

平台列表的结果会在两个地方带上游标,因为有两类读者想要两种形状:

{
"success": true,
"data": { "items": [], "cursor": "1757462400000", "has_more": true },
"error": null,
"meta": {
"request_id": "",
"cached": false,
"duration_ms": 812,
"cursor": { "next": "1757462400000", "has_more": true },
"task_id": "",
"endpoint": "douyin.author_posts",
"platform": "douyin"
}
}

如果你走的是轮询而不是等待,同样的值在 data.data.cursordata.result_meta.cursor.next

规则:

  • 游标是不透明的。 抖音按毫秒级 max_cursor 时间戳翻页,TikTok 按偏移量翻页;两者都被字符串化进同一个字段,调用方不需要知道是哪一种。不要解析它、不要给它加一、也不要自己构造。
  • 没有游标、或 has_more: false,就是最后一页。 到此为止,不要再把上一个游标发一遍。
  • 游标属于产生它的那次查询。翻到一半改 count 或任何过滤条件、还复用旧游标,属于未定义行为。

count 超过上限会在校验阶段被拒绝,而不是被悄悄裁掉(OpenAPI schema 里声明了 maximum: 50)。想要更多的调用方应该翻页;想一次拿走全部的调用方,正是应该被放慢的那个。

也不是所有地方都用游标。GET /api/v1/downloads 和各管理列表用 limit + offset 并返回 total(默认 100,上限 500),因为它们读的是本地数据库;GET /api/v1/admin/logs/requests 用一个有界的 minutes 窗口加 limit,所以不存在任何一组参数能不带时间窗就打到日志表上。

错误码在任何语言下都一样,旁边那句话不一样。单次请求的解析顺序:

  1. ?lang= 查询参数——enzh
  2. Accept-Language 请求头——按主子标签匹配,所以 zh-CNzh-Hanszh-TW 都归到 zh* 归到配置的默认值
  3. api.default_language(默认 en
终端窗口
curl -sS "$DTK_BASE_URL/api/v1/parse?lang=zh" -X POST \
-H "X-API-Key: $DTK_API_KEY" -H 'Content-Type: application/json' \
-d '{"url": "not a link"}'

不支持的 ?lang= 值会被忽略而不是报错,协商继续走请求头——所以 ?lang=fr 仍然会尊重 Accept-Language: zh。两种语言都对不上的,回落到英文,而不是用一种本构建没有的语言半吊子地服务你。

这条规则同样适用于 OpenAPI 文档(/openapi.json?lang=zh/swagger?lang=zh),也适用于任务里存下来的错误:worker 存的是 code 和它的参数,句子是在你读取的时候才拼出来的,所以同一个失败任务既能正确回答中文控制台,也能正确回答英文 agent。

每一次失败都是同一个信封,success: false。请按 error.code 分支。

Code HTTP 可重试 含义
INVALID_URL 400 不是受支持的抖音或 TikTok 链接,或主机不在白名单里
UNSUPPORTED_CONTENT 400 该平台不提供这个操作;details.supported 列出哪些平台提供
INVALID_PARAM 400 某个参数不对,details 指出字段。请求体超过 1 MiB 上限也用它(HTTP 状态为 413)
UNAUTHENTICATED 401 没有凭据,或凭据被拒绝
FORBIDDEN_SCOPE 403 凭据缺少所需权限范围或角色;details.required 说明缺哪个
CONTENT_PRIVATE 403 私密内容,或已被作者删除
NOT_FOUND 404 没有这个资源
TASK_NOT_FOUND 404 没有这个任务,或结果已过期。就这个标志的含义而言它可重试——但过期的结果不会因为重试而回来,请重新提交
METHOD_NOT_ALLOWED 405 这个路径不支持该方法;Allow 头列出支持的方法
SETUP_ALREADY_DONE 409 本实例已经初始化过了
CANCELLED 409 有人主动取消了这个任务
UNSUPPORTED_MEDIA_TYPE 415 请求体的媒体类型不是本接口能读的
RATE_LIMITED 429 请求过于频繁;请遵守 retry_after
SETUP_TOKEN_INVALID 403 首次初始化令牌错误或已用掉
NOT_CONFIGURED 501 本部署从来就没有这个可选组件
INTERNAL 500 意料之外;报 bug 时请附上 X-Request-ID
UPSTREAM_RISK_CONTROL 502 平台判定该请求为自动化流量,相关身份进入冷却
UPSTREAM_CHANGED 502 平台响应结构与解析器不再匹配,details.path 指出字段。请反馈
SIGNING_FAILED 502 签名失败,算法可能已失效
IDENTITY_POOL_EXHAUSTED 503 没有可用身份;retry_after 是恢复时间的估计
ENDPOINT_CIRCUIT_OPEN 503 该接口因连续失败被熔断暂停
QUEUE_FULL 503 队列已达上限
DOWNLOADER_UNAVAILABLE 503 媒体边车进程在跑但没有响应

NOT_CONFIGUREDDOWNLOADER_UNAVAILABLE 看着像,其实不是一回事:前者说明这个部署压根就没装这个组件,等多久都没用;后者说明一个确实存在的服务挂了。

会自行恢复的错误码,会在 error 里带 retry_after(秒),同时在标准的 Retry-After 响应头里带同一个值。至少等这么久。你实际会遇到的是 RATE_LIMITEDQUEUE_FULLIDENTITY_POOL_EXHAUSTEDENDPOINT_CIRCUIT_OPENUPSTREAM_RISK_CONTROL

retryable 布尔值出现在任务存下来的错误里(GET /api/v1/tasks/{task_id}data.error.retryable),以及 OpenAPI 的错误 schema 里。顶层信封的错误只带 code,而上面那张表就是这个 code 的含义。按 code 分支的客户端不需要别的;而读任务结果的 agent 能直接拿到这个标志,不必自己背表。

其余的大体上可以「记日志然后停下」,但下面这四种决策必须写进你的代码:

  1. 退避后重试RATE_LIMITEDQUEUE_FULLIDENTITY_POOL_EXHAUSTEDENDPOINT_CIRCUIT_OPENUPSTREAM_RISK_CONTROLSIGNING_FAILEDINTERNALDOWNLOADER_UNAVAILABLE。有 retry_after 就遵守它,没有就用指数退避。
  2. 绝不重发同一个请求INVALID_URLUNSUPPORTED_CONTENTINVALID_PARAMNOT_FOUNDCONTENT_PRIVATEMETHOD_NOT_ALLOWEDUNSUPPORTED_MEDIA_TYPECANCELLEDNOT_CONFIGUREDUPSTREAM_CHANGED。在这些上面打转,只是烧身份去换同一句话。
  3. 修凭据UNAUTHENTICATEDFORBIDDEN_SCOPE。读 details.required——它写明了你缺的权限范围或角色。
  4. 重新提交工作TASK_NOT_FOUND。结果窗口已经过去,这个任务 ID 死了。

按凭据计的一分钟固定窗口。它是防滥用,不是计量也不是计费:唯一目的是别让一个失控脚本把身份池抽干。

响应头 含义
X-RateLimit-Limit 当前窗口允许的请求数
X-RateLimit-Remaining 还剩多少
X-RateLimit-Reset 窗口翻页的 Unix 时间戳(秒)

限额取 key 创建时设定的 rate_limit(1 到 100000 之间),否则取 api.default_rate_limit_per_min(默认 120)。如果这个实例默认值本身被设成 0 或更小,那么所有没有单独限额的调用方都不再计数,这三个头也不会出现。超限返回 429,code 为 RATE_LIMITED,带 details.limiterror.retry_afterRetry-After 头。

桶的键:带 key 时按 API key,控制台会话按用户,已开放接口上的匿名调用按客户端地址。最后这一种在你依赖它之前有个必须知道的注意事项:在 Docker 发布端口的用户态代理后面,所有请求看起来都来自网桥网关,所以除非运营方用 DTK_FORWARDED_ALLOW_IPS 声明了反向代理,匿名桶会退化成整个互联网共用一个桶。对一个防滥用计数器来说,往更严的方向退化是对的,但这意味着一个忙碌的匿名调用方可能把别人挡在门外。参见安全

请读这些头并在撞上 429 之前主动放慢,而不是靠试探去找上限。

重复调用:任务合并、缓存与 ?refresh=

Section titled “重复调用:任务合并、缓存与 ?refresh=”

有两套互相独立的机制让重复请求变便宜,而且都是默认开启的。

任务合并(coalescing)。 短时间内到达的两个完全相同的请求会被合并到同一个任务上,而不是跑两遍——一百个调用方问同一个视频,只花身份池一次上游请求。这个占位声明存活 90 秒。已经失败的任务永远不会被合并进来:在剩余窗口里重放一次失败,会掩盖掉一次本可能成功的重试。

响应缓存。 整形后的结果会按请求内容缓存不同时长:

设置 默认值 适用于
cache.content_ttl 1800 秒(30 分钟) 单条作品
cache.author_ttl 900 秒(15 分钟) 作者资料
cache.list_ttl 300 秒(5 分钟) 任何分页内容

meta.cached 告诉你拿到的是哪一种。条目会自行过期,而且 Redis 的上限设在容器限制之下,所以缓存不会无限增长。

?refresh=true 会同时关掉这两者。 它忽略任何缓存的或在途的答案,重新去上游要一次。新答案仍然会被写入缓存——「别读缓存」和「别留下这份」是两个不同的请求,而你只提了第一个。它要真的花掉一个身份和一次上游请求,所以它是用来确认「有没有变」的,不是每次调用都该带的。

这两套机制也解释了为什么同一个请求的两种写法会合并:?url=https://www.douyin.com/video/7123…?aweme_id=7123… 会被提取成同样的参数,拿到同一个任务 ID。

在归一化结果之外,附上平台自己那份未经处理的原始载荷。默认关闭,开之前值得先了解:

  • 单条作品就已经很大——一条作品的原始载荷有几百 KB。
  • 分页结果里是逐条附带的,所以它会成倍放大响应体,以及任何存储它的东西。
  • 它是缓存键的一部分,所以同一个东西的 raw 与非 raw 请求,是两条缓存记录、两次上游调用。

POST /api/v1/parsePOST /api/v1/tasks/batch 的每一条 item 上它是请求体字段;在 GET 接口上它是查询参数。

这两个改变的是请求怎么发出去,而不是它在问什么,并且都有权限门槛。

用指定的那一个身份发请求,不用别的。它存在的场景是:你从自己已登录的浏览器里导入的一份 Cookie 罐——那些内容只有那个会话看得见,所以换一个身份不是把答案变差,而是把问题换掉了。

  • 需要 identity:manage 权限范围以及至少 operator 角色。
  • 提交时就会检查存在性、是否已退休、平台是否匹配,所以打错的 uuid 会立刻得到一个指明字段的 400404,而不是排队、执行、然后失败的任务。已退休的身份会被明确拒绝——退休会抹掉密文,已经没有 jar 可以拿来签名了。
  • 被指定身份的请求既不读也不写响应缓存,也永远不会被合并到未指定身份的任务上。
  • 它只有一次传输尝试,而不是三次。重试之所以有意义,是因为下一次会落到另一个身份、走另一个出口;指定了身份,三次尝试只会把这一个身份的令牌桶抽干。
  • 身份 ID 会在结果元数据里以 identity_id 回显——而且只在你自己指定过的时候。

参见身份与代理

让上游请求走提供的出口,替换掉该身份自己的出口。

  • 除非管理员把 security.request_proxy 设为 publicany,否则一律拒绝。默认是 deny,而且无法识别的值也按 deny 处理——配置里的一个笔误不该成为打开网络的那件事。
  • public 只接受公网可路由的目的地;any 连回环和私有网段也接受,只有在每一个持有 API key 的人都已经被信任可以访问该实例所在网络时才说得通。
  • 协议:httphttpssocks5socks5h。最长 512 字符。请写成完整 URL,例如 http://host:port
  • 拒绝时返回 INVALID_PARAMdetails.reasonrequest_proxy_disabledtoo_longscheme_missingscheme_not_supportedhost_missingport_invalidhost_not_public 之一。你发的值永远不会被回显或写日志,因为一个被拒的代理 URL 恰恰是最可能刚粘贴了真实凭据的时刻。
  • 功能被关闭时是拒绝而不是忽略。悄悄丢掉这个参数,会让请求从实例自己的地址发出去,而你以为它走了你的代理。

代价是真实存在的,不是白送的选项:这个身份的 cookie 是在某个地址后面签发的,现在却从另一个地址出示,这正是平台看得见的那种自相矛盾。两个通过不同代理请求同一条作品的调用方,问的不是同一个问题,所以永远不会被合并。

把请求实际发出去的样子还给你——签好名的 URL、请求头,以及该身份的 Cookie 罐——这样它可以在本实例之外被重放。它是「平台拒绝了我们,我需要看看我们到底发了什么」的排查工具。

  • 需要 identity:manage 以及 operator 角色,因为答案里含有凭据。一个 douyin:read 的 key 可以要求本实例使用某个 jar;它不能要求把 jar 交到自己手上。
  • 每次使用都会写进审计日志——谁问的、问的哪个接口。这行日志不含任何 jar。
  • 隐含 refresh:对一个缓存答案做「解释」,描述的会是这次请求根本没发生过的调用。它同时关闭任务合并,所以一个带解释的调用永远不会被合并到已经在途的、不带解释的调用上。
  • 该块出现在结果元数据的 explain 下,含 methodurlheaderscookie_headeridentity_idsignerendpointproxy
  • 失败的任务上同样会记录——那正是它存在的场景。
  • 对任何没有 identity:manage 的读者,它会从存下来的任务结果里被剥掉;而且它永远不会出现在 SSE 流上。

参见调试台与工具,那是同一件事的控制台前端。

{platform}douyintiktok,且必须与你传的链接一致;不一致会得到 INVALID_URLdetails.reason"platform_mismatch"

方法 路径 权限范围 说明
POST /api/v1/parse 任一读取权限范围 任何受支持的链接,或包着链接的整段分享文案
POST /api/v1/tasks/batch 任一读取权限范围 最多 50 条链接,每条一个任务
GET /api/v1/{platform}/video {platform}:read 单条作品。url aweme_id
GET /api/v1/{platform}/video/comments {platform}:read 分页
GET /api/v1/{platform}/video/comments/replies {platform}:read comment_id 加上 url aweme_id。分页
GET /api/v1/{platform}/user {platform}:read 作者资料。url sec_user_id
GET /api/v1/{platform}/user/posts {platform}:read 分页
GET /api/v1/{platform}/user/likes {platform}:read 分页。见下面的平台差异说明
GET /api/v1/{platform}/mix/posts {platform}:read mix_id——抖音叫 mix_info,TikTok 叫 playlistId。分页
GET /api/v1/{platform}/user/followers tiktok:read 仅 TikTok
GET /api/v1/{platform}/user/following tiktok:read 仅 TikTok

除了核心那五个之外,两个平台并不对称,而这种不对称是被如实报告出来、而不是被抹平的。向抖音要 followersfollowing 会返回 UNSUPPORTED_CONTENTdetails.supported 列出哪些平台提供它——如果路由照样把任务提交上去,你会拿到一个空页,然后得出「这个作者没有粉丝」的结论。user/likes 两个平台都提供,但抖音不会把这个列表给游客身份看:它需要一份导入的已登录身份;而在 TikTok 上,空页通常意味着作者把喜欢列表设为私密。

作品用 url aweme_id 指定,不能都给也不能都不给;作者用 url sec_user_id(TikTok 里叫 secUid)。已经带 ID 的 URL 会在入口处把 ID 提取出来,所以两种写法会合并到同一个任务上。夹着链接的文本也能接受——平台 App 生成的剪贴板内容可以原样发过来。短链接(v.douyin.comvm.tiktok.com)会被排队交给 worker 展开,因为跟随一次跳转是一次网络调用,而 API 容器不做网络调用。

调用方自己输入的 ID 会在花掉任何代价之前被校验:aweme_id=not-an-idaweme_id=7123 都会得到带 details.fieldINVALID_PARAM,由我们免费答复,而不是发到上游去换回同一句话。

前缀 是什么 由哪篇文档覆盖
/api/v1/tasks/… 轮询、订阅、取消任务 本文
/api/v1/tools/… parse-urlparse-batchsigndecodeidentity——试跑与签名,前四个不消耗身份 调试台与工具
/api/v1/archive/… 本实例已存下来的内容:检索、统计、合集、导出、复检、回填 下载、素材库与监控
/api/v1/downloads/… 存在运营方磁盘上的媒体 下载、素材库与监控
/api/v1/admin/… 身份、代理、密钥、用户、设置、日志、关注列表、备份 运维用户与 API 密钥
/api/v1/auth/… 控制台会话与改密码 用户与 API 密钥
/api/v1/ios/… iOS 快捷指令的发布信息——无需认证 本文
/api/v1/system/status 版本、健康、身份池统计、存储 运维

归档类接口全部从本地存储作答:不消耗身份、不发起抓取,而一条后来被删掉的作品仍然在那里,availability 会说明这件事。所以想要「实例已经知道的东西」就用 GET /api/v1/archive,想要「最新的」才用 /parse

有五个接口刻意用别的形式作答,分成四种形态。别把它们喂给你的信封解析器。

接口 Content type 为什么
GET /healthzGET /readyz 普通 JSON 对象 给负载均衡器用的探针;/healthz 不碰任何依赖,/readyz 在 Postgres 或 Redis 挂掉时返回 503
GET /api/v1/tasks/{task_id}/events text/event-stream Server-sent events
GET /api/v1/archive/export application/x-ndjson 每行一条作品,按页流式输出;上限 50000 行;需要 archive:export
GET /api/v1/downloads/{download_id}/files/{name} 文件自身的类型 已存的文件字节,带一个让浏览器保存而不是渲染的 Content-Disposition

提交一个链接、轮询任务、读出结果。三个客户端做的是同一件事。

轮询版本,用 python3 从 JSON 里取一个字段:

#!/usr/bin/env bash
set -euo pipefail
BASE="${DTK_BASE_URL:-http://127.0.0.1:8000}"
KEY="$DTK_API_KEY"
LINK="https://www.douyin.com/video/7123456789012345678"
field() { python3 -c 'import json, sys
value = json.load(sys.stdin)
for key in sys.argv[1:]:
value = value[key]
print(value)' "$@"; }
task=$(curl -sS -X POST "$BASE/api/v1/parse" \
-H "X-API-Key: $KEY" \
-H 'Content-Type: application/json' \
-d "{\"url\": \"$LINK\"}" | field data task_id)
echo "task $task"
for _ in $(seq 1 60); do
body=$(curl -sS "$BASE/api/v1/tasks/$task" -H "X-API-Key: $KEY")
state=$(printf '%s' "$body" | field data state)
case "$state" in
done) printf '%s' "$body" | python3 -m json.tool; exit 0 ;;
failed) printf '%s' "$body" | python3 -m json.tool; exit 1 ;;
esac
sleep 2
done
echo "still running after two minutes; task $task is still valid" >&2
exit 1

同步的一行版,适合你只是想在终端里看到答案:

终端窗口
curl -sS -X POST "$DTK_BASE_URL/api/v1/parse?wait=20" \
-H "X-API-Key: $DTK_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"url": "https://www.douyin.com/video/7123456789012345678"}' \
| python3 -m json.tool

记住 ?wait=20 仍然可能返回 202——那是任务还在跑,不是失败。

"""Submit a link, poll the task, print the result. Needs: pip install httpx"""
from __future__ import annotations
import os
import time
import httpx
BASE = os.environ.get("DTK_BASE_URL", "http://127.0.0.1:8000")
HEADERS = {"X-API-Key": os.environ["DTK_API_KEY"]}
RETRYABLE = {
"RATE_LIMITED",
"QUEUE_FULL",
"IDENTITY_POOL_EXHAUSTED",
"ENDPOINT_CIRCUIT_OPEN",
"UPSTREAM_RISK_CONTROL",
"SIGNING_FAILED",
"DOWNLOADER_UNAVAILABLE",
"INTERNAL",
}
class DtkError(RuntimeError):
def __init__(self, error: dict, request_id: str | None = None) -> None:
super().__init__(f"{error['code']}: {error.get('message', '')}")
self.code = error["code"]
self.retry_after = error.get("retry_after")
self.details = error.get("details")
self.request_id = request_id
def unwrap(response: httpx.Response) -> dict:
"""Return `data`, or raise the envelope's error."""
body = response.json()
if not body.get("success"):
raise DtkError(body["error"], response.headers.get("X-Request-ID"))
return body["data"]
def submit(client: httpx.Client, url: str) -> str:
"""Queue a parse and return the task id, retrying while the instance pushes back."""
for attempt in range(5):
try:
return unwrap(
client.post("/api/v1/parse", json={"url": url})
)["task_id"]
except DtkError as exc:
if exc.code not in RETRYABLE or attempt == 4:
raise
time.sleep(exc.retry_after or 2 ** attempt)
raise RuntimeError("unreachable")
def collect(client: httpx.Client, task_id: str, deadline: float = 120.0) -> dict:
"""Poll until the task settles. A failed task is HTTP 200 with state 'failed'."""
delay, end = 0.5, time.monotonic() + deadline
while time.monotonic() < end:
task = unwrap(client.get(f"/api/v1/tasks/{task_id}"))
if task["state"] == "done":
# The result is nested: envelope.data.data
return task["data"]
if task["state"] == "failed":
raise DtkError(task["error"])
time.sleep(delay)
delay = min(delay * 2, 5.0)
raise TimeoutError(f"task {task_id} still running; it remains valid, poll it again")
def main() -> None:
with httpx.Client(base_url=BASE, headers=HEADERS, timeout=30.0) as client:
task_id = submit(client, "https://www.douyin.com/video/7123456789012345678")
print("task", task_id)
result = collect(client, task_id)
print(result.get("title") or result.get("description"))
if __name__ == "__main__":
main()

这段代码里有两处才是重点:unwrapsuccesserror.code 分支,绝不按 message;collectfailed 的任务当作数据而不是 HTTP 失败来处理,因为它本来就是数据。

// Node 18+ or any modern browser (see "Calling from a browser" for CORS).
const BASE = process.env.DTK_BASE_URL ?? "http://127.0.0.1:8000";
const HEADERS = { "X-API-Key": process.env.DTK_API_KEY, "Content-Type": "application/json" };
const RETRYABLE = new Set([
"RATE_LIMITED", "QUEUE_FULL", "IDENTITY_POOL_EXHAUSTED", "ENDPOINT_CIRCUIT_OPEN",
"UPSTREAM_RISK_CONTROL", "SIGNING_FAILED", "DOWNLOADER_UNAVAILABLE", "INTERNAL",
]);
class DtkError extends Error {
constructor(error, requestId) {
super(`${error.code}: ${error.message ?? ""}`);
this.code = error.code;
this.retryAfter = error.retry_after ?? null;
this.details = error.details ?? null;
this.requestId = requestId ?? null;
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function unwrap(response) {
const body = await response.json();
if (!body.success) throw new DtkError(body.error, response.headers.get("X-Request-ID"));
return body.data;
}
async function submit(url) {
for (let attempt = 0; attempt < 5; attempt += 1) {
try {
const data = await unwrap(await fetch(`${BASE}/api/v1/parse`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify({ url }),
}));
return data.task_id;
} catch (err) {
if (!(err instanceof DtkError) || !RETRYABLE.has(err.code) || attempt === 4) throw err;
await sleep(1000 * (err.retryAfter ?? 2 ** attempt));
}
}
}
async function collect(taskId, deadlineMs = 120_000) {
let delay = 500;
const end = Date.now() + deadlineMs;
while (Date.now() < end) {
const task = await unwrap(await fetch(`${BASE}/api/v1/tasks/${taskId}`, { headers: HEADERS }));
if (task.state === "done") return task.data; // envelope.data.data
if (task.state === "failed") throw new DtkError(task.error);
await sleep(delay);
delay = Math.min(delay * 2, 5000);
}
throw new Error(`task ${taskId} still running; it remains valid, poll it again`);
}
const taskId = await submit("https://www.douyin.com/video/7123456789012345678");
console.log("task", taskId);
console.log(await collect(taskId));

跨域请求默认被拒绝:security.cors_allow_origins 是空的,意味着仅同源,而预检请求会发现根本没有 OPTIONS 路由来应答它。想要浏览器访问的运营方,需要在那个设置里逐条写明来源。

  • 跨域允许的方法:GETPOSTPUTDELETEOPTIONS
  • 浏览器端能读到的响应头:X-Request-IDX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-ResetRetry-After。不暴露这些的话,关联 ID 和限额余量对 fetch() 就是不可见的,而那正是它们存在的大半意义。
  • 把来源列表设成 * 会强制关掉凭据。填 * 的运营方要的是一个开放的匿名 API,不是一个开放的已认证 API——何况浏览器本来也会拒绝这种组合。

不要把 API key 发到你控制不了的页面里:任何读到那个页面的人都持有了这个 key,可以花掉你的身份池。要么在前面放一个你自己的小服务,要么用 api.public_endpoints 精确开放你需要的那几个接口并接受匿名限额。

下面每一项都来自代码;标注 (设置项) 的可以在控制台设置页运行时修改——参见配置参考

项目
?wait= 上限 (设置项 api.max_wait_seconds 30 秒
默认速率限制 (设置项 api.default_rate_limit_per_min 120 次/分钟
队列上限 (设置项 sched.queue_max 500 个任务
QUEUE_FULL 的重试提示 (设置项 sched.max_wait_seconds 10 秒
任务结果保留 (设置项 retention.task_result_hours 24 小时
缓存 TTL,单条作品 (设置项 cache.content_ttl 1800 秒
缓存 TTL,作者资料 (设置项 cache.author_ttl 900 秒
缓存 TTL,任何分页列表 (设置项 cache.list_ttl 300 秒
任务合并占位存活时间 90 秒
平台分页大小(count 默认 20,最大 50
归档分页大小(limit 默认 50,最大 200
下载 / 管理列表(limit 默认 100,最大 500
单次批量条数 50
请求体上限 1 MiB(1,048,576 字节)
url 参数长度 4096 字符
cursor 长度 512 字符
aweme_id / mix_id / comment_id 长度 64 字符
sec_user_id 长度 256 字符
callback_url 长度 2048 字符
proxy 长度 512 字符
SSE 流 timeout 1–300 秒,默认 300
SSE 心跳 约 15 秒
Webhook 尝试次数 / 超时 / 退避 3 次 / 10 秒 / 2 秒后 8 秒
归档导出上限 50000 行

有一处文档上的小瑕疵需要知道:API 文档自己的首页(在 /swagger 可见)说 callback_url 的主机「必须在运营方的 security.url_allowlist 里」。那个名单管的是短链接展开时可以经过哪些主机,与回调无关。回调真正的开关是 security.enable_task_webhook,外加上文说的 https 与非私有地址检查。