身份与代理
身份池是决定这套系统能不能跑起来的关键部分。读完本文,你将能够读懂身份列表和它的状态机、铸造或导入身份、把身份绑到代理上、理解消耗它们的调度器、按自己的流量规模估算池子大小,并且在「什么都不响应了」时分清三种完全不同的故障。
为什么需要身份池
Section titled “为什么需要身份池”两个平台都不发放 API Key。每个请求都必须看起来像一次普通的浏览器访问:带着平台自己下发的 Cookie、互相自洽的 User-Agent 与 TLS 指纹,以及这份 Cookie 曾经出现过的出口地址。
因此这套系统轮换的最小单位不是一条 Cookie,而是一个完整的身份:Cookie、浏览器指纹,以及(可选的)一个代理,三者绑定终身。在同一个出口、同一个 User-Agent 后面轮换 Cookie,其异常程度高于单纯的请求频率——在平台看来,那是一台设备在不停地换访客,而真实浏览器不会这么做。
由此引出两个后果,两者都是刻意的取舍,不要试图「修正」它们:
- 身份永远不会被重新配到另一个出口。 代理挂了,它后面的身份只会进入冷却,不会被搬走;代理被删除,它们会被退休。把一份还活着的 Cookie 重新配上一个新的出口 IP,是这套系统能做出的关联性最强的动作。
- 每个身份同时只跑一个请求。 单身份并发数是 1,而且应当保持为 1:真实会话本来就不会并发发 API 请求。提升吞吐靠增加身份,而不是调大这个数。
一个身份由什么组成
Section titled “一个身份由什么组成”| 字段 | 含义 |
|---|---|
cookies |
Cookie 罐,以 AES-GCM 密文存储,并与该行的 id 绑定。除了那条带审计的显示接口,任何 API 响应都不会包含它。 |
fingerprint |
browser_family、browser_major、user_agent、platform、screen、language、timezone,以及浏览器上报了的话还有 hardware_concurrency 和 device_memory。 |
proxy_id |
该身份铸造或导入时所处的出口。null 表示走服务器自身的出口。 |
authenticated |
导入时 Cookie 中带有登录态标记则为 true。这是关于那次粘贴的事实,不是关于账号的事实。 |
source |
minted(由无头浏览器产生)或 imported(你粘贴进来的)。 |
state |
active、cooling、degraded、retired——见下文。 |
consecutive_fails |
连续失败次数。一次成功即清零;除此之外只有手动重置能清。 |
cooldown_until |
冷却中或已降级的身份重新可被调度的时间。 |
minted_at、last_used_at、retired_at、retire_reason |
时间线。 |
指纹里推断不出浏览器家族和主版本号的身份会被直接拒绝,而不是套一个默认值。一个与 User-Agent 自相矛盾的 TLS 指纹,比没有身份更糟糕,所以铸造和导入在这种情况下都选择拒绝而不是猜。
| 状态 | 含义 | 怎么进来 | 怎么出去 |
|---|---|---|---|
active(可用) |
当前可被调度。 | 刚铸造、刚导入、冷却到期,或被手动重置。 | 命中风控,或被退休。 |
cooling(冷却中) |
因命中风控而暂歇。 | risk_control 结果,按指数退避计算冷却时长。 |
冷却到期(在下一次调度时被提回),或冷却期间有一次请求成功。 |
degraded(降级) |
冷却已到上限。只有在没有任何可用身份时才会被启用。 | 算出的冷却时长达到或超过 sched.cooldown_max_seconds。 |
上限冷却到期后回到 active,但连续失败计数保留——所以在它成功之前,健康分会把它排在最后。 |
retired(已退休) |
已死。Cookie 已被清除,行本身保留用于统计。 | 你退休了它,或者它绑定的代理被删除了。 | 不会回来。已退休的身份不能重置、不能被指定、不会被调度。 |
minting(铸造中) |
数据库列的默认值。铸造路径不会把身份留在这个状态——浏览器一返回 Cookie 就直接写 active——所以正常情况下你不会看到处于这个状态的行。 |
目前没有任何代码写入它。 | — |
每次请求结束后,结果都会回写到身份状态上:
| 结果 | 对身份的影响 |
|---|---|
ok |
清空连续失败计数。冷却中的身份被提回 active;已降级的只清计数,仍然要熬完它的观察期。 |
business_error(作品已删除或私密) |
什么都不做。 作品不存在是关于内容的事实,不是关于身份的事实。 |
network_error |
连续失败计数 +1。不冷却——请求根本没到达平台。 |
risk_control |
连续失败计数 +1,计算并写入冷却时间,状态置为 cooling 或 degraded。 |
冷却时长为 sched.cooldown_base_seconds × 2^(连续失败次数 - 1) × risk_weight,上限为 sched.cooldown_max_seconds,其中 risk_weight 来自接口策略(见熔断、令牌桶,以及熔断打开时该怎么办)。按默认值——基准 60 秒、上限 21600 秒(6 小时):
| 连续命中风控次数 | 权重 1.0 时的冷却 | 权重 1.8 时的冷却 |
|---|---|---|
| 1 | 1 分钟 | 1 分 48 秒 |
| 2 | 2 分钟 | 3 分 36 秒 |
| 3 | 4 分钟 | 7 分 12 秒 |
| 5 | 16 分钟 | 28 分 48 秒 |
| 8 | 2 小时 8 分 | 3 小时 50 分 |
| 9 | 4 小时 16 分 | 6 小时 —— 降级 |
| 10 | 6 小时 —— 降级 | 6 小时 —— 降级 |
健康度,以及两个含义完全不同的列
Section titled “健康度,以及两个含义完全不同的列”身份列表里有两个看起来都像是在给身份下判断的列,但它们量的是完全不同的东西。页面在工具栏下面用一句话说明了这一点——把这两个数当成一个来读,正是误诊身份池的典型方式。
**会话凭据(Credential)**是对已存 Cookie 的静态检查。不发请求、不看历史:会话 cookie 在不在?长度够不够,是真实会话还是首次访问拿到的引导值?
| 判定 | 含义 |
|---|---|
ok(完整) |
会话 cookie 存在,且长度足够。 |
missing(缺失) |
Cookie 里根本没有会话值。 |
too_short(仅引导值) |
这是 HTML 文档里写死的引导值,而不是平台自己的 JavaScript 替换上去的那个。带着它发出去的请求,即使签名完全正确也会被拒。 |
unknown(无法判断) |
Cookie 解密不了,或者该平台没有登记会话 cookie。 |
各平台用来判断的 cookie:
| 平台 | Cookie | 视为真实会话的最小长度 |
|---|---|---|
tiktok |
msToken |
144 |
douyin |
UIFID_TEMP |
32 |
**健康度(Health)**衡量的是这个身份最近的请求实际跑得怎么样。这一列和调度器排序读的是同一张聚合表、同样的两个时间窗,所以你看到的数字和身份池采用的顺序出自同一个公式:
health = success_rate × (1 − risk_rate) × 0.5^consecutive_fails其中 success_rate 取最近 15 分钟,risk_rate 取最近 60 分钟,consecutive_fails 是连续失败次数——这三个也是你在 request_log 和 API 里会遇到的字段名。
三个因子是相乘而不是加权求和,因为它们量纲不同——在加权和里,无界的连续失败次数要么压倒一切,要么把一个自相矛盾的状态(「成功率 100%,但刚刚连续失败了三次」)平均掉。相乘意味着任何一个因子塌下去,整体分数就跟着塌。
- 15 分钟窗口内的样本少于 5 个时,成功率会用一个先验值代替,这样新身份既不会被当成久经考验的那样信任,也不会被压到队尾。页面上显示的这一列固定使用内置的
0.8;pool.health_prior调整的是调度器排序时用的先验值。把这个配置项改离默认值,改变的是冷启动身份被挑中的积极程度,而不是表格里给它显示的数字。 - 当聚合表里没有这个身份的记录时,这一列是空的(「无流量」)。这既不是通过,也不是 0,而是「一无所知」。此时页面退回显示连续失败次数。
- 时间窗读自
identity_health_5m连续聚合视图,这是一个 TimescaleDB 对象。在原生 PostgreSQL 上这次读取会静默失败,所有身份都显示为未测量;调度照常工作,只是仅按连续失败次数和最久未使用来排序。 - 排序时分数会被归入 5 个粗档位。按精确分数排序会让最健康的那一个永远胜出,那是热点而不是轮换。
身份页面(/identities)
Section titled “身份页面(/identities)”列表来自 GET /api/v1/admin/identities(按铸造时间倒序,limit 默认 100、最大 500)。控制台一次取 200 行,每 5 秒轮询一次;搜索、来源和登录态筛选在浏览器里对这一页做过滤,所以页面上给出的每个计数都是精确值。
自动补充卡片
Section titled “自动补充卡片”位于页面顶部,数据来自 GET /api/v1/admin/identities/pool:
- 每个平台一个数字,显示可用身份数——存活(
active+cooling)且连续失败次数低于pool.max_fail_streak的那些。刻意不用行数:一个每次请求都失败的身份依然算存活,所以按行数统计的池子可以一边停在目标值上、一边什么都服务不了。 - 两个可编辑的数字:低于(
pool.min_size,默认 3)和补到(pool.target_size,默认 8)。保存时只写你改动的那一个。目标值不能低于下限。 - 一行铸造动态:当前正在铸造什么、最近 20 次尝试(一排小方块,鼠标悬停显示原因),以及连续失败把补充任务打入退避时的等待时间。这些数据由 worker 经 Redis 提供,因为失败的铸造不会留下任何身份行——而这正是「死活补不上来的池子」以前看起来和「根本没人要求补充的池子」一模一样的原因。
- 如果没有设置
DTK_BROWSER_RPC_URL,两个数字输入框会消失,卡片会直接说明:什么都铸造不了,池子里只有你手动导入的身份,两个阈值不起作用。
补充任务本身跑在 worker 里:每分钟检查一次各平台,每次只铸造一个身份,并持有跨进程锁,这样 --scale worker=N 不会把一次铸造变成 N 次。因此从下限补到目标值需要几分钟。这个节奏是设计的一部分——一秒之内从同一个部署冒出五个新访客,比这些身份之后发出的请求更刺眼。
这个任务还会拒绝往一场平台级事故里补充:当所有存活身份都在失败时(可用数为 0 而存活数不为 0),它会按住不动,让身份池告警去说话,而不是再送几个新身份进去一起被烧。
| 列 | 说明 |
|---|---|
| 状态 | active / cooling / degraded / retired。 |
| 健康度 | 实测分数,或「无流量」。 |
| 平台 | douyin 或 tiktok。 |
| 会话凭据 | 上文说的静态会话检查。 |
| 身份 ID | 完整 uuid,带复制按钮。不可隐藏——这就是你要粘进 ?identity= 的那个值。 |
| 代理 | 有备注时显示备注,没有时显示代理 id,没有代理时显示「无代理」。从不显示 URL,哪怕是掩码后的。 |
| 冷却剩余 | 剩余时间,或 —。 |
| 最近使用 | 相对时间,悬停看精确时间。 |
| 来源 | 铸造或导入。 |
| 已登录 | Cookie 带登录态时显示一个锁图标。 |
| 连续失败、铸造时间 | 默认隐藏,可从列菜单打开。 |
轮询时,只有状态、会话凭据判定、连续失败次数或健康度档位发生变化的行才会闪一下——分数的细微漂移不算,因为每次轮询每一行都闪的表格只会教会你无视这个提示。
默认视图隐藏了什么
Section titled “默认视图隐藏了什么”默认情况下表格会隐去两类行,并在表格上方给出计数和一个显示全部按钮:
- 已退休的身份——凭证已被清除,无法承接请求。
- 会话凭据判定为
missing或too_short的身份——即使签名正确,平台也会拒绝它们。
冷却中和已降级的身份不会被隐藏。把一个正在恢复的池子藏起来,正是控制台向一位手握四十个身份的运维报告「没有身份」的方式。显式按状态筛选 retired 时,这些行照常显示。
- 测试——以该身份排队发出一次真实的签名请求,然后展示结果。见测试、重置与退休。
- 重置——只在真的有事可做时才出现:身份处于冷却或降级,或者连续失败次数不为 0。
- 退休——对已退休的行是禁用状态。
- 勾选若干行后,工具栏会出现重置、导出 N 个和退休。
- 点击行会打开抽屉。
点击任意一行会打开抽屉,它回答两个问题:这个身份是什么,以及它一直在干什么。
Cookie 清单
Section titled “Cookie 清单”「这个身份由什么组成」卡片显示平台、来源、是否带登录态、铸造时间,以及指纹里的浏览器和时区。下面是来自 GET /api/v1/admin/identities/{id}/cookies 的逐条 Cookie:名称、角色、一句说明这条 cookie 是干什么的、字符长度,以及掩码后的值(首四位和末四位,中间是固定长度的星号——固定,是因为可变长度会泄露原值长度)。
长度出现在这里、而在列表页刻意不显示:一个被截断的 token 和一个错误的 token 从外面看完全一样,长度正是区分它们的东西。
| 角色 | 含义 |
|---|---|
required(必需) |
本程序拒绝使用缺少它的身份。目前两个平台都是 ttwid。 |
session(登录态) |
携带登录会话:sessionid、sessionid_ss、sid_tt、sid_guard、uid_tt、sid_ucp_v1。 |
useful(有用) |
不是必需的,但带上它的身份被拒的次数会少一些:odin_tt、s_v_web_id、msToken、passport_csrf_token、tt_csrf_token、__ac_nonce,以及本程序签名器会读取的那些 cookie。 |
other(其他) |
平台下发的,原样保留。本程序不读它。 |
每条 cookie 的说明按 cookie 名索引,找不到时回落到该角色自己的那句话,所以一个本程序从没见过的 cookie 名也能得到一个诚实的回答,而不是一片空白。
有两种横幅会取代这个列表:
- 这份 Cookie 用的密钥本实例已经没有了——密钥轮换时没有重新加密。这个身份同样无法再签名,退休它。
- 已退休的身份会清空 Cookie——没有内容可看。
显示真实内容,以及它会被审计这件事
Section titled “显示真实内容,以及它会被审计这件事”显示真实内容会调用 GET /api/v1/admin/identities/{id}/cookies/reveal,把即将发出的 Cookie 原样打印出来,并提供两个复制按钮:复制成 Cookie: 请求头(给 curl 用)或复制成 JSON。
这是「任何响应都不携带 Cookie」这条规则的一个刻意的例外,而且它是一条独立的路由而不是一个开关参数,理由如下:
- 它要求 operator 角色以及
admin或identity:manage权限——比那份掩码后的清单更严的门槛,后者任何管理侧只读用户都能加载。 - 它会写一条审计记录(
identity.cookies_revealed,记录谁读的、返回了多少条 cookie)。正因为它是独立路由,这条记录不会被抽屉每次打开都要加载的掩码视图淹没。 - 它取代的替代方案是开个 shell 手动解密:同样的泄露,却没有审计记录,也没有权限检查挡在前面。
把显示出来的 Cookie 当成凭证对待。带登录态的那份等于别人的账号。
身份上的 authenticated 说明的是粘贴内容里有会话 cookie。平台是否还认它,是另一回事,而这两者不一致时唯一的症状是:本该有的登录态数据在原本成功的响应里悄悄消失。检查登录态会去问平台(POST /api/v1/admin/identities/{id}/session,以任务方式排队),并给出:
| 原因 | 含义 |
|---|---|
live |
平台确认登录态有效,并给出了账号。 |
signed_out |
没有有效登录态。TikTok 对「从来没登录过」和「登录已失效」的回答完全一样,所以分不出是哪一种。 |
indeterminate |
仅抖音。它的接口对访客和登录态的回答完全相同,都会给出一个自己的 uid。这次能确认的只是:Cookie 到达了平台并被认了下来——不是它已登录。 |
refused |
平台拒绝了这次检查而不是回答它。这一次说明不了会话的状态。 |
unreachable |
完全没有拿到回应。是网络或代理的问题,不是会话的问题。 |
这个检查走的是正常的请求链路,所以它像真实流量一样消耗这个身份,但不会把结果记到身份头上。
抽屉会读取该身份最近一周的 request_log 记录(最多 200 条)并展示:
- 一条横幅,直接点明故障的形状。只有某一个接口拒绝、其他接口照常响应,通常是本程序在那条路径上的签名有问题,或者那是个需要 visitor id 的签名保护接口——不是 Cookie 废了。所有接口都拒绝,那就是身份本身的问题。
- 按接口的统计:调用次数、其中多少次被拒、以及错误码。
- 最近 25 次请求,含结果、HTTP 状态和耗时。
铸造会驱动一个真实的无头浏览器、经由该身份自己的代理完成,因为 Cookie 和指纹必须来自后续真正使用它们的同一个会话。出口的时区和语言会和代理的 GeoIP 对齐:一个德国的代理配上 Asia/Shanghai 的时钟,是白送出去的破绽。屏幕尺寸不由地理位置推导——真实浏览器报告什么就是什么。
它需要 browser-rpc 容器,这个容器按设计是可选的,也是整个栈里最重的一个服务:
echo 'DTK_BROWSER_RPC_URL=http://browser-rpc:9000' >> .envCLOAKBROWSER_COMMIT=<40 位 sha> \ docker compose -p dtk -f docker/compose.yml --profile browser up -d --build这里有三件事必须同时成立,而且失败方式各不相同。容器要跑起来(profile)。要告诉 api 和 worker 它在哪里(.env 里的 DTK_BROWSER_RPC_URL)。镜像还必须是带着 CLOAKBROWSER_COMMIT 构建出来的——这个值在构建时读取,来自你的 shell 或 docker/.env,而不是仓库根目录的 .env;构建时没给它,镜像里根本不含浏览器:容器能起来、健康检查也过,但什么都铸造不出来。要用哪个 commit、以及为什么钉的是 commit 而不是 tag,见安装的「两个可选 profile」与「构建浏览器镜像:CloakBrowser 版本固定」两节。
这些都没有的话,池子里就只有你手动导入的身份——这是受支持的运行模式,不是错误。
在控制台里,铸造身份需要选择平台、数量(1 到 10),以及可选的铸造出口。每个身份作为一个独立任务提交(POST /api/v1/admin/identities/mint,返回 202),对话框会逐个等待,每个耗时以秒计——几十秒是正常的。铸造在整个部署范围内串行执行,所以一次要五个身份就是五次接连的铸造;单个任务最多等 180 秒来抢锁,超时就放弃。
铸造没有产出时,任务会带着一个原因码失败:
| 原因 | 含义 |
|---|---|
no_free_proxy |
每个代理后面都已经有一个该平台的存活身份了。同一出口后面放两个正是身份池要避免的重组,所以任务选择等待而不是叠加。加代理。 |
proxy_not_found |
本次铸造指定的代理已经不存在了。 |
proxy_undecryptable |
代理 URL 解密失败(DTK_SECRET_KEY 不对)。改用直连铸造会把这个身份绑死在一个它永远不会再用的出口上。 |
rpc_unavailable |
浏览器容器没有响应。 |
unusable_fingerprint |
浏览器返回的指纹里没有可用的版本号,与其配一个猜出来的 TLS 档案,不如直接拒绝这个身份。 |
busy / locked |
另一次铸造持有锁。可重试;API 返回 429,Retry-After 为 30 秒。 |
自动铸造失败会让补充任务按指数退避慢下来(60 秒起,翻倍至 1 小时上限,下一次成功即清除)。手动按钮触发的铸造失败刻意不碰这个计数器,这样对着一个挂掉的浏览器连按几次,也不会把自动补充停掉一小时。
导入你已经有的 Cookie
Section titled “导入你已经有的 Cookie”导入 Cookie 接收你从浏览器里复制出来的一份 Cookie。在没有浏览器容器的部署里它就是整个身份池,也是让这个实例拥有登录态会话的唯一途径。
四种可接受的粘贴格式
Section titled “四种可接受的粘贴格式”格式是自动识别的,不需要你声明——要求用户先说清自己手上是四种格式中的哪一种,正是导入流程劝退人的方式。
| 格式 | 长什么样 | 从哪来 |
|---|---|---|
header |
ttwid=a; odin_tt=b; sessionid=c |
DevTools → 复制请求头。开头的 Cookie: 会被去掉。 |
json_array |
[{"name": "ttwid", "value": "..."}, ...],或者一个普通的 {"名称": "值"} 对象 |
Cookie 管理类浏览器扩展。 |
netscape |
制表符分隔、7 个字段、# 注释 |
cookies.txt 导出文件。 |
loose |
每行一个 name=value |
手工复制。同时也是兜底:当格式识别猜错、选中的解析器什么都没解出来时会回落到它。 |
整段粘贴内容上限为 200000 字符。
预览会告诉你什么
Section titled “预览会告诉你什么”在输入框里打字会触发一次防抖的服务端试运行(dry run),所以你看到的是服务端对这段粘贴的真实理解,而不是浏览器的猜测。在存下任何东西之前,它会报告:
- 识别出的格式;
- 每个 cookie 的名称、角色和掩码后的值(控制台从来拿不到真实值);
- 从你填的 User-Agent 推断出的浏览器家族与主版本号。Edge 和 Opera 会映射到 Chrome,那是它们真正的 TLS 档案;
- 这份 Cookie 是否带登录态;
- 带登录态时,从
sid_guard里读出的过期时间; - 缺失的必需 cookie——没有
ttwid根本存不进去; - 警告,以稳定的代码加一句话的形式给出:
no_cookies、logged_in_session、unknown_browser、no_useful_cookies。
另外两个字段值得填:
- User agent——来自同一个浏览器会话。两个平台都会把它哈希进签名,所以换一个 UA 发出去的 Cookie 是它们看得见的自相矛盾。不填就推断不出浏览器,导入会被拒绝。
- 代理——这个账号平时登录所用的出口。导入的 Cookie 和铸造出来的一样,与它绑定终身。
POST /api/v1/admin/identities/import 还接受 language 和 timezone;控制台没有为它们提供输入框。
带登录态的 Cookie 不等于访客身份
Section titled “带登录态的 Cookie 不等于访客身份”对话框顶部有警告、底部有勾选框,两者都不是装饰:
- 一份带登录态的 Cookie 等同于账号密码。 拿到它的人就能以那个账号的身份行事。它加密存储,没有任何接口会返回它,但本实例的管理员可以使用它。
- 用一个专门为此准备的账号,绝不要用你的主账号。
- 身份池是全实例共享的,行本身没有归属人。因此在请求里指定身份(
?identity=<uuid>)需要 operator 角色以及admin或identity:manage权限——一把普通的只读 Key 不该能碰到别人的登录会话。见 REST API 指南。 - 指定了身份的请求绝不会改由其他身份来发,也绝不会走响应缓存。只有来自那个会话的答案才是正确的。
- 导入的登录态会话到期前 3 天,worker 会发出告警(
cookie_expiring通知),依据是从sid_guard里读出的过期时间。这把一次静默的集体失效,变成了提前几天的预警。
访客身份便宜且用完即弃:烧掉一个,池子再铸一个。带登录态的身份不是——这也是为什么围绕它的每一处设计(显示时的审计记录、指定身份的权限门槛、到期预警)都要付出代价。
导出与恢复身份包
Section titled “导出与恢复身份包”勾选若干行后按导出 N 个,会调用 POST /api/v1/admin/identities/export,并把 dtk-identities-<时间戳>.json 存到你的下载目录。用它把一个能用的池子搬到第二个实例、在做有风险的改动前留个副本,或者把某一个身份交给别人排查问题。
落到磁盘上的是一个凭证文件。 里面每一份 Cookie 在平台使其失效之前都是能用的,带登录态的那份等于别人的账号。文件里带一个 warning 字段,好让日后捡到它的人知道自己拿的是什么;导出操作按身份逐个写入审计。
- 每次调用 1 到 200 个身份;响应里包含每一份完整的 Cookie,且在内存中组装。
- 已退休的身份会以一个空 Cookie 的条目导出,而不是被丢掉——悄悄返回得比你要的少,只会让你在恢复到一半时才发现。
恢复走的是同一个导入对话框:在从文件恢复处选择文件,粘贴表单会随即关闭——恢复一个池子和手打一份 Cookie 是两件不同的事,一个靠猜来判断你想干哪件的对话框,一定会在最要紧的那天猜错。
身份包路由(POST /api/v1/admin/identities/import/bundle)会带上每个条目原本的指纹。这正是它存在的意义:把导出的 Cookie 重新走一遍普通导入,浏览器信息只能从一个已经不在旁边的 User-Agent 推断,恢复出来的身份就会用一个不同的指纹去签名——而这恰恰是风控在找的差异。
version是被检查而不是被信任的。由更新版本写出的文件会被拒绝,而不是靠猜去读。当前版本是1。- 每个条目独立判断:一份已过期的 Cookie 不会拖住其余的,响应会说明存了哪些、跳过了哪些、为什么。
proxy_id会把恢复出来的每个身份都绑到同一个出口。dry_run可以在不写入的情况下回答上面这些问题。
测试、重置与退休
Section titled “测试、重置与退休”测试(POST /api/v1/admin/identities/{id}/test)会以该身份、经由它自己的代理发出一次真实的签名请求,并对回答分类。它不占租约、不消耗配额、不记录任何结果——一次会把被测身份冷却掉的探测,会改变你正在读的那个状态。
| 结果 | 含义 |
|---|---|
ok |
平台正常响应了。Cookie 被接受,签名也被读取了。 |
business_error |
算通过。平台正常响应了,只是说这条作品本身不可用——这是关于内容的事实。内置的冒烟链接确实会失效,而这正是它不会被误读成故障的原因。 |
risk_control |
平台拒绝的是这个身份,不是这次请求。它的 Cookie 已经废了或者被标记了。 |
network_error |
根本没有拿到响应:本机与平台之间的代理、DNS 或 TLS 出了问题。换一个出口再测一次是值得的。 |
重置(POST /api/v1/admin/identities/{id}/reset)会清除冷却时间和连续失败计数,并把身份标记为可用。恢复过程是刻意做慢的——一个反复失败的身份通常确实该被观察一阵——所以这是留给自动恢复无从知晓的那种情况的手动开关:失败并不是这个身份的错。 比如连续查了一批错误的 ID,或者代理已经修好了。如果会话本身已经废了,平台会在几次请求内再次拒绝它,它会重新进入冷却。对已退休的身份,重置会被拒绝:Cookie 已被清除,没有会话可以放回轮换,一个看起来能把它救回来的按钮就是在撒谎。
退休(DELETE /api/v1/admin/identities/{id})会设置状态、把原因记到该身份的时间线上,并立刻清空 Cookie 列。统计值值得留下,被丢弃的凭证不值得。此操作不可撤销。已退休的行会在超过 retention.retired_identity_days(默认 90 天)后,由维护任务删除。
代理页面(/proxies)
Section titled “代理页面(/proxies)”代理是可选的。不配代理时,池子里所有身份共用服务器自身的出口地址——对于一个躲在防火墙后、每天几百个请求的个人实例来说通常没问题,但一旦你运行的身份多于几个,这恰恰就是身份池要避免的那种关联。
以下情况该加代理:
- 每个平台运行超过一两个身份——不配代理时它们全都从同一个地址出去,数量越多,这个模式越显眼;
- 你服务器的地址已经被限流或封禁;
- 你希望出口的国家和时区与身份声称的语言环境一致。
如果你只跑一个身份自用而且它工作正常,就别折腾了。一个挂掉或很慢的代理比没有代理代价更大——探测失败时,它后面的每个身份都会被冷却 15 分钟。
可接受的 URL 形式
Section titled “可接受的 URL 形式”单个添加框和批量导入都按行接受以下形式:
host:porthost:port:user:passuser:pass@host:porthttp://user:pass@host:portsocks5://user:pass@host:port协议:http、https、socks5、socks5h。其他都是笔误而不是功能,会以 scheme_not_supported 被拒。行内没有写协议时按 http 处理。端口缺失或越界会报错,而不是套一个默认值。带方括号的 IPv6 字面量可以识别。
注意这里不检查什么:代理可以指向内网或回环地址。对一个自托管部署来说,本机的 SOCKS 监听或局域网网关就是再正常不过的出口。SSRF 白名单管的是你让服务去抓取的 URL,那是完全不同的另一件事。
代理 URL 里带着账号和密码,所以它以 AES-GCM 密文存储并与行 id 绑定,离开服务时只以掩码形式出现(http://***:***@host:port)。没有「把代理密码显示给我看」这样的接口,将来也不会有。
加一个,或者加一百个
Section titled “加一个,或者加一百个”添加代理接收一个 URL,以及可选的备注、国家和时区;保存前控制台会预览这行被读成了什么(掩码后)。
批量导入接收一整段粘贴内容,最多 500 行。空行和 # 注释会被跳过。允许部分成功正是它的意义所在——一份有三行不合法的供应商清单,其余九十七行照样导入——响应会告诉你每一行的结果,被拒的行会被引用出来,其中任何凭证部分都已剥离:
| 拒绝代码 | 含义 |
|---|---|
unparsable |
无法识别的代理格式。 |
empty |
空行。 |
scheme_not_supported |
不支持的协议。 |
malformed_authority |
主机与端口无法解析。 |
missing_host |
缺少主机。 |
missing_port |
缺少端口。 |
port_not_a_number |
端口不是数字。 |
port_out_of_range |
端口不在 1 到 65535 之间。 |
可选的备注会应用到本批的每一个代理。导入成功的会立刻被探测。
探测、健康与地理位置
Section titled “探测、健康与地理位置”测试(POST /api/v1/admin/proxies/{id}/test)会经由该代理发出一个很小的 JSON 请求,并报告延迟、出口地址、国家和时区。每次探测都用一个全新的 HTTP 客户端:连接池是与出口绑定的,跨代理复用只会报告池中那条连接所属出口的健康状况。
后台的 worker 会:
- 每五分钟扫一遍所有代理,回写健康状态和 GeoIP(同一个代理不会在一分钟内被重复探测);
- 当五分钟内某个代理后面的身份累计出现三次
network_error时,立刻探测它,而不是等下一轮扫描——那意味着在一个死掉的出口上再烧五分钟身份; - 把探测失败的代理后面所有可用或已降级的身份冷却 15 分钟。它不会退休它们,也不会搬走它们:Cookie 是好的,坏的只是出口;
- 发出
proxy_unhealthy通知,带上备注(没有备注则带掩码后的 URL)。
一个永久死掉的代理,其身份要不要退休,由你决定——那是一个决策,不是超时的副作用。
国家和时区之所以重要,只有一个具体原因:在这个代理后面铸造时,它们会作为地理提示交给浏览器,让铸出来的身份的时钟和语言环境与它的出口地址一致。探测会自动填写它们;GeoIP 查错时你可以手动覆盖。修改它们不会影响已经铸造出来的身份。
表格的列是:状态、备注、地址(掩码)、出口地区(国家在上、时区在下)、延迟、身份数、最近检查,以及默认隐藏的代理 ID 和创建时间。有两点值得知道,否则它们看起来像 bug:
- 延迟来自你本次会话中在这个页面上跑过的探测。列表本身不携带存储的延迟值,所以没测过的行显示
—。 - 身份数目前在列表里是
—;这个计数不在列表响应中。要看某个代理后面挂着哪些身份,请用身份页面:那里的代理列会显示备注,搜索框也能匹配代理 id。
你也可以手动覆盖健康状态(对选中行执行标记为可用 / 标记为不可用,或用 PUT /api/v1/admin/proxies/{id} 的 healthy 字段),适用于一个你确定没问题却探测失败的出口,或者相反的情况。下一次探测会覆盖你的判断。
删除一个代理
Section titled “删除一个代理”删除代理会把绑定在它上面的每个身份退休,并清除它们的 Cookie。这不是代码可以放宽的安全余量:身份和它铸造时所在的出口绑定,因此代理没了的身份也就没有将来——留着它,最终意味着让它的 Cookie 从另一个地址发出去。确认对话框会告诉你有多少身份会一起消失。
如果代理只是暂时不通,什么都别做:探测器会冷却它的身份,代理恢复时它们也会回来。
身份与代理是怎么配对的
Section titled “身份与代理是怎么配对的”- 自动铸造任务会挑一个健康的、且该平台还没有存活身份占用的代理。因此,健康且空闲的代理数量就是自动补充在每个平台上能加到多少个身份的上限;所有代理都被占用时,它报告
no_free_proxy并等待,而不是叠加。退休一个身份会重新释放它的代理。 - 你显式指定的代理(铸造对话框、CLI 的
--proxy,或 API 的proxy_id)会覆盖这个搜索:出口是你有意选的,否则只有一个代理的安装将永远铸不出第二个身份。 - 完全没有配置代理时,身份在直连出口上铸造。这就是一个没有代理的安装所要求的。
- 导入的 Cookie 使用你在导入对话框里选的代理,或者不使用代理。
- 系统中没有任何机制会把一个身份挪到另一个代理上。
调度器页面(/scheduler)
Section titled “调度器页面(/scheduler)”这个页面把调度器的配置项放在它们所作用的身份池旁边,而不是埋进一个按字母排序的设置列表里。它显示:
-
三个指标块——当前处于
active、cooling和degraded的身份各有多少。下面每一个数字都是对这份实时统计的表述,脱离它去读这些数字,正是把低水位线设成一个池子从没接近过的值的原因。 -
一次请求是怎么挑身份的——按顺序的五道关卡:
- 接口熔断。 至少要有
sched.circuit_min_identities个不同身份同时失败才会触发,所以一个坏身份不会把接口对所有人关掉。熔断期间每个间隔只放行一次探测,其余请求直接拒绝,不消耗任何配额。 - 排序——先按健康档位,再按最久未使用,最后按每次调用生成的抖动。
- 在途——每个身份同时只能有一个请求在途。
- 配额——按(身份,接口)各自一个令牌桶。
- 结果回写——请求结果会折回身份的状态。
- 接口熔断。 至少要有
-
一段关于公平性的实话。 排序的目标是「没有哪个身份被系统性偏袒」,而不是严格轮询。与均匀随机基线相比,实测得到的分散度并不优于随机。调度器真正保证、并且被测试严格断言的,是互斥、配额,以及没有身份被饿死。不要把这个轮换理解成轮流坐庄。
-
每一项
sched.*和pool.*配置的编辑器,以及一个折叠区,说明其中有多少项是存在数据库里、而不是来自默认值或环境变量种子。
按接口的健康状况和熔断状态不在这个页面上——它们在总览页(/),那里会列出所有已声明的接口,无论有没有流量。见控制台总览。
熔断、令牌桶,以及熔断打开时该怎么办
Section titled “熔断、令牌桶,以及熔断打开时该怎么办”令牌桶。 配额的键是 (身份, 接口),而不是只有身份。没有接口这一维时,一个身份可以把全部预算花在最敏感的那一个调用上,这读起来就是「这个访客只做一件事,而且一直在做」——比在多个接口上均匀分布更刺眼。令牌桶按流逝时间惰性回填,闲置的桶 24 小时后过期。
| 接口 | 突发量(桶容量) | 每秒回填 | 风险权重 |
|---|---|---|---|
douyin.content_detail、tiktok.content_detail |
5 | 0.30 | 1.0 |
douyin.author_profile、tiktok.author_profile |
4 | 0.20 | 1.2 |
douyin.comments、douyin.comment_replies、douyin.mix_posts 及对应的 TikTok 接口 |
3 | 0.15 | 1.5 |
douyin.author_posts、douyin.author_likes 及对应的 TikTok 接口 |
3 | 0.12 | 1.8 |
tiktok.author_followers、tiktok.author_following |
3 | 0.12 | 1.8 |
douyin.session_check、tiktok.session_check |
3 | 0.20 | 1.0 |
未登记的接口(default) |
3 | 0.15 | 1.5 |
把回填速率读成「每个身份每 1/速率 秒一个请求」:0.12/s 大约是单个身份每八秒一次列表类调用,无论排了多少请求。风险权重会在该接口命中风控后乘进冷却时长。
熔断。 单个身份被限流是常事。所有身份在同一个接口上都失败则是另一回事——通常是上游接口变了或者签名器死了——这时继续重试只会烧池子。当以下三个条件在 300 秒的滚动窗口里同时成立时,接口熔断:
| 条件 | 配置项 | 默认值 |
|---|---|---|
| 样本足够多 | sched.circuit_min_samples |
20 |
| 风控率高于阈值 | sched.circuit_risk_threshold |
0.6 |
| 失败跨越足够多的不同身份 | sched.circuit_min_identities |
3 |
第三个条件是把「接口坏了」和「一个身份坏了」区分开的关键;没有它,一个反复抽风的身份就能把接口对所有人熔断。
熔断打开后会持续 300 秒,其间每 60 秒放行恰好一次探测(这两个值都没有开放为配置项)。调用方会立即收到 503 ENDPOINT_CIRCUIT_OPEN 和一个 retry_after——等待改变不了答案,所以调度器不会把等待预算花在它身上。探测成功时熔断立即关闭,窗口清空。熔断打开还会触发一条 endpoint_circuit_open 通知。
熔断打开时该怎么办,按值得尝试的顺序:
| 先看什么 | 如果是 | 那么 |
|---|---|---|
| 是一个接口还是全部?(总览页) | 只有一个接口 | 那条路径的签名或上游结构变了,或者那条路径受签名保护而你的身份没有对应的 visitor id。用调试台对着那个接口试——见调试台与工具。 |
| 某个平台的所有接口 | 问题在身份池而不在接口。看会话凭据列:满屏的 missing 或 too_short 就是会话已经用尽。 |
|
| 身份的会话凭据列 | 大多是 ok |
Cookie 没问题;怀疑签名或出口。先探测一个身份,再探测它的代理。 |
大多是 missing / too_short |
退休它们,让池子铸造替补。 | |
| 单个身份的抽屉 | 一个接口拒绝,其他正常 | 不是 Cookie 废了。是签名,或者受签名保护的路径。 |
| 全部都拒绝 | 这个身份用尽了。退休它。 | |
| 代理页面 | 某个代理刚变成不可用 | 它的身份已被自动冷却。修好或删掉这个代理;在它恢复之前不要去重置那些身份。 |
这里没有「重置这个熔断」的按钮,也没有对应的 API 接口,这是设计使然:熔断的接口会在一次探测成功后自行恢复,而在病因未除之前强行放开它,只会让池子烧得更快。病因修好之后,常规做法就是等下一次探测放行——最多一分钟。如果一分钟都嫌久,还有一个手动的应急口子:直接删掉这个熔断在 Redis 里的两个键,具体命令见故障排查。那是一个刻意留在控制台之外的动作,不是缺了一个按钮。
接口访问控制页面(/endpoint-access)
Section titled “接口访问控制页面(/endpoint-access)”这个页面之所以属于本文,只有一个理由:每一个开放的接口都在消耗你的身份池。 匿名调用者不会自带身份。
默认情况下所有接口都需要 API Key 或控制台会话,这是正确的默认值——一台放在公网服务器上的实例是陌生人能够到的机器,而它存在的全部意义就是花掉别人的身份池。这个页面按标签分组列出所有已文档化的操作,数据由 API 文档本身生成,所以开关和它写入的配置项不会对「这条路径叫什么」产生分歧。
- 开关的语义是**「该接口需要 API Key」,所以打开是受保护状态,一整页都打开的开关描述的是一个什么都没暴露的实例。每一行都会标出自己处于哪一边——需要 Key 或 无需 Key。把某个开关关掉**才是去掉鉴权检查,因此需要确认的是这个方向;重新打开则不需要。
- 开放某个接口会写入
api.public_endpoints,这是一个 SENSITIVE 配置项(写入时会带上显式的确认标记),内容是形如"<METHOD> <路径模板>"的字符串列表,与 API 文档里的写法完全一致。 - 管理、认证与初始化类路径永远无法开放。
/api/v1/admin、/api/v1/auth和/api/setup无论配置怎么写都会被服务端拒绝,所以这些行渲染成锁定状态、不提供开关——一个会撒谎的开关比没有开关更糟。 - 有少数几条路由在代码里根本没有鉴权检查(你没法先登录再去访问登录接口)。它们被单独列为「无需凭据」,不计入告警,这里也没有开关能关掉它们。它们都不抓取任何内容,因此也都不消耗身份池。
- 匿名调用者只获得读权限(
douyin:read、tiktok:read),绝不包含管理与写入,并且按来源地址而不是按 Key 限流,使用api.default_rate_limit_per_min(默认 120)。在 Docker 的用户态代理后面,所有调用者可能看起来都来自网桥网关,这会退化成一个共享的计数桶——是更严,不是更松。
关于这个页面的完整说明——哪些路径永远无法开放、为什么有 Key 是必要但不充分的、以及那些从来就没关过的路由——见用户与 API 密钥。在可从公网访问的实例上开放任何接口之前,先读安全。
我需要多少个身份
Section titled “我需要多少个身份”从令牌桶算起,不要凭感觉。一个身份在每个接口上分别大约能维持 refill_per_sec 个请求每秒,同时整体上最多只有一个请求在途。
| 你要做什么 | 接口 | 单身份能力 | 要达到 1 请求/秒需要 |
|---|---|---|---|
| 查单个视频 | *.content_detail(0.30/s) |
约 18 次/分钟 | 约 4 个身份 |
| 读用户资料 | *.author_profile(0.20/s) |
约 12 次/分钟 | 约 5 个身份 |
| 翻评论 | *.comments(0.15/s) |
约 9 次/分钟 | 约 7 个身份 |
| 遍历作者作品 | *.author_posts(0.12/s) |
约 7 次/分钟 | 约 9 个身份 |
然后再留出余量,因为池子并不总是全员在岗:
- 冷却中的身份不可被调度。池子健康时这只是一小部分;出事时那就是大部分。
- 补充任务只统计连续失败次数低于
pool.max_fail_streak(默认 3)的身份,所以一个烧掉的身份不会白占名额。 - 每次铸造都是一个一个来、每个以秒计,所以池子在损失之后没法快速长回来。把
pool.target_size设得比实际需要高几个,比在压力下临时铸造便宜。
默认值——每个平台低于 3 补充、补到 8——适合一个每天做几千次详情查询的个人实例。三个身份、不配代理、每六小时跑一次关注列表,是一个能工作的配置;三个身份去支撑一个公开接口则不是。
拿不到租约的请求最多等 sched.max_wait_seconds(默认 10)秒,然后以 503 IDENTITY_POOL_EXHAUSTED 失败。如果你看到这个错误,只有三个选择:加身份、减调用方,或者接受排队。
为什么它们会一起进入冷却
Section titled “为什么它们会一起进入冷却”因为冷却是按身份计的,而几乎所有诱因都是共享的。按可能性从高到低:
| 原因 | 特征 | 怎么办 |
|---|---|---|
| 某个代理探测失败 | 代理页面显示它不可用;被冷却的身份都用着这个代理;冷却时长清一色 15 分钟 | 修代理。身份会自己回来。不要先去重置它们——它们会立刻再次冷却。 |
| 平台正在拒绝这个部署 | 冷却身份横跨多个代理;紧接着某个接口熔断 | 停止发送流量并等待。往里面铸造只会让信号更响,这也是补充任务刻意拒绝这么做的原因。 |
| 会话确实已经用尽 | 会话凭据列满是 missing / too_short;抽屉里显示所有接口都拒绝 |
退休它们,让池子铸造替补,或者导入新的 Cookie。 |
| 签名或上游发生变化 | 会话凭据列大多是 ok,但每个请求都被判为 risk_control;通常先是一个接口,然后是全部 |
检查浏览器容器和接口健康看板;这不是身份池的问题。 |
| 分类器把正常回答读成了风控 | 探测能过、平台明显没事,身份却在被冷却 | 重置受影响的身份。这真的发生过:2026-09-09 之前,本程序把抖音对「作品不存在」的回答读成了风控,所以查一个错误的 ID 就会冷却发起查询的那个身份。修好分类器并不能修复它已经造成的伤害——而这正是重置的用途。 |
| 到平台的网络有问题 | 出现的是 network_error 而不是 risk_control;没有冷却,但连续失败次数在涨 |
查 DNS、TLS 和代理。network_error 会退还令牌,且本身从不冷却身份。 |
从命令行操作
Section titled “从命令行操作”CLI 位于 api 容器内,读取同一套数据库和配置:
docker compose -p dtk -f docker/compose.yml exec api dtk identity list --platform douyin| 命令 | 作用 |
|---|---|
dtk identity list [--platform P] [--state S] [--limit N] |
列出身份及其状态、浏览器、代理、连续失败次数、冷却和最近使用时间。Cookie 从不出现。 |
dtk identity mint --platform P [--count N] [--proxy ID] |
通过 browser-rpc 铸造,1 到 20 个。未设置 DTK_BROWSER_RPC_URL 时会给出可操作的失败提示。 |
dtk identity test <id> [--url URL] [--timeout S] |
一次真实的签名请求。不占租约、不消耗配额、不记录结果。 |
dtk identity retire <id> [--reason TEXT] |
退休并清除 Cookie。重复退休会被拒绝,而不是覆盖掉原来的退休原因。 |
dtk proxy list [--healthy] |
列出代理,URL 掩码,含地理位置和探测状态。没探测过的代理显示 unchecked 而不是 healthy。 |
dtk proxy add <url> [--label L] [--country C] [--timezone Z] |
添加一个;URL 在写入数据库之前就已加密。 |
dtk proxy import <file> |
每行一个 URL,后面可以跟空白和一个备注;跳过 # 注释;跳过重复项。被拒的行只报行号,绝不回显内容。 |
dtk proxy test <id> [--probe-url URL] [--write/--no-write] |
探测一个代理,默认把健康状态和 GeoIP 写回该行。 |
只要不产生歧义,id 可以用表格里打印的短前缀。完整参考见命令行参考。
影响身份池的配置项
Section titled “影响身份池的配置项”下面这些配置项里,pool.* 和 sched.* 在调度器页面上编辑,其余的在设置页面上(api.public_endpoints 另有上文说的接口访问控制页面)。它们全都可以用 dtk config 修改,也都与其余全部配置一起记录在配置参考中。
| 配置项 | 默认值 | 作用 |
|---|---|---|
pool.min_size |
3 | 每个平台的低水位线。低于它,补充任务开始铸造。 |
pool.target_size |
8 | 补到多少。设得比 min_size 低时会被抬到 min_size。 |
pool.max_fail_streak |
3 | 连续失败达到该值后,身份不再计入池子水位,补充任务会去补一个新的而不是把它算进来。 |
pool.health_prior |
0.8 | 流量太少无法评分时,假定的成功率。 |
sched.max_wait_seconds |
10 | 一个请求在失败前最多等多久拿身份。 |
sched.cooldown_base_seconds |
60 | 命中风控后的首次冷却时长。 |
sched.cooldown_max_seconds |
21600 | 冷却上限;达到它意味着身份被标记为降级。 |
sched.circuit_risk_threshold |
0.6 | 接口可被熔断的风控率阈值。 |
sched.circuit_min_samples |
20 | 熔断前在 300 秒窗口内所需的最少样本数。 |
sched.circuit_min_identities |
3 | 触发熔断所需的不同失败身份数。 |
sched.queue_max |
500 | 最大排队请求数。 |
retention.identity_events_days |
90 | 身份时间线保留多久。 |
retention.retired_identity_days |
90 | 已退休身份的行在被删除前保留多久。 |
api.public_endpoints |
[] |
无需 Key 即可访问的接口。SENSITIVE。 |
api.default_rate_limit_per_min |
120 | 单 Key 与单匿名地址的限流上限。 |