下载、资料库与关注列表
这一篇讲的是「留下来」而不只是「看一眼」:把视频和图片存到你自己的磁盘上、几个月后还能找回来、以及按定时反复采集同一个目标。读完之后你会清楚这三个页面各自的代价、字节到底落在哪里、容量上限会拿走什么,以及怎样保住那个你真正舍不得丢的作品。
三个页面各管什么
Section titled “三个页面各管什么”控制台把「我见过什么」「我手上留着什么」「我还在持续采集什么」拆成了三个页面,这个拆分是有原因的:第一件事免费,第二件事会占满磁盘,第三件事会一直消耗身份池。
| 页面 | 路径 | 存的是什么 | 代价 |
|---|---|---|---|
| 资料库 | /library |
归档:本实例解析过的每一个作品各一行——标题、作者、标签、媒体清单 | 没有代价。它只查 Postgres,不消耗身份、不发起任何请求 |
| 下载 | /downloads |
下载索引:每一次「保存某作品媒体」的请求各一行,以及实际落盘的文件 | 磁盘;如果作品还没抓过,还要额外一次走身份池的请求 |
| 关注列表 | /watchlist |
长期指令:每隔 N 小时采集这个作者或这个作品,永远执行下去 | 只要条目还在,就按计划持续消耗身份池 |
第四个页面 视频转写(/transcriptions)存放开启可选的转写服务后由已存视频生成的文字;它消耗本机 CPU,不向平台发任何请求。见把已存的视频转成文字。
这些页面都在侧边栏的 内容 分组下。一个作品可以只在归档里而媒体不在磁盘上(这是常态);反过来,下载记录也可以比归档行活得更久——即使作品后来被从资料库里删了,下载表里的那一行描述的仍然是媒体卷上一个真实的目录。
媒体下载器是可选的
Section titled “媒体下载器是可选的”真正搬运字节的是一个独立容器:downloader sidecar。它是一个需要显式开启的 Compose profile;没有它的实例是「配置正确」而不是「有问题」——归档、API、控制台和关注列表都照常工作,缺的只有抓取文件这一半。
在仓库根目录启动它:
echo 'DTK_DOWNLOADER_URL=http://downloader:9100' >> .envdocker compose -p dtk -f docker/compose.yml --profile downloader up -d --build没有设置 DTK_DOWNLOADER_URL 时,POST /api/v1/downloads 会返回 501 与 NOT_CONFIGURED,消息是 “this instance has no media downloader; start the downloader compose profile and set DTK_DOWNLOADER_URL”。「下载」页面顶部会显示同样的提示卡片,页面本身仍可作为此前已存内容的只读索引使用。
sidecar 是整个栈里唯一会去连接任意 CDN 主机的容器,所以它被刻意做成了最「空」的一个:scratch 镜像里只有一个静态编译的 Go 二进制,没有 shell、没有包管理器,根文件系统只读,丢弃全部 capabilities,no-new-privileges,并且只有一个可写路径。它只接在 edge 网络上——没有任何通往 Postgres 或 Redis 的路由——而且即便共享的 env 文件里带着这些值,Compose 也会把它的 DTK_SECRET_KEY、DTK_DATABASE_URL、DTK_REDIS_URL 置空。它从来拿不到 Cookie、代理、API Key 或数据库地址;它收到的只有一份镜像 URL 列表、对应平台的主机白名单,和一个字节上限。
它自己的参数,全部从 .env 读取:
| 变量 | 默认值 | 作用 |
|---|---|---|
DTK_DOWNLOADER_URL |
(空) | 由 api 和 worker 读取。留空即完全关闭媒体下载 |
DTK_DOWNLOADER_TOKEN |
(空) | 共享密钥。留空表示 sidecar 不做校验;它监听在一个别的容器都不在的内部网络上,所以这属于纵深防御而不是边界本身 |
DTK_DOWNLOADER_WORKERS |
4 |
并行处理的任务数 |
DTK_DOWNLOADER_ITEM_WORKERS |
4 |
单个任务内部并行下载的文件数 |
DTK_DOWNLOADER_QUEUE |
64 |
队列容量,超出后回答「下载队列已满」 |
DTK_DOWNLOADER_TIMEOUT_SECONDS |
900 |
单次传输的时间上限 |
DTK_DOWNLOADER_HISTORY |
500 |
内存中保留多少条已完成任务,用于汇报进度 |
DTK_DOWNLOADER_MAX_REDIRECTS |
5 |
一个镜像允许的重定向跳数 |
并发传输的上限是这两个 worker 数的乘积——默认配置下是 16。带宽好可以调高,代价只是每个传输一个 goroutine 加一个 32 KiB 缓冲区,此外没什么开销。容器被限制在 512 MB 内存和 2 个 CPU,因为传输是流式的,常驻内存取决于缓冲区数量而不是文件大小。
任务历史保存在内存里,重启即丢失。这带来一个可见的后果:重启 sidecar 之后,它已经忘掉的那些任务的实时进度条会消失,那一行就不画进度条了。持久记录是在任务结束时写入的,所以除了进度条本身,什么都不会丢。
文件落在哪里
Section titled “文件落在哪里”文件写入名为 media-data 的 Docker 命名卷,挂载在 /var/lib/dtk/media。下载器以读写方式挂载它,并且是唯一会写入的组件;api 容器以只读方式挂载,这正是控制台能把已存文件交给浏览器、而下载器又不会变成中继的原因。容器内的路径是固定的——如果你想把媒体放到宿主机的其他位置,改的是 docker/compose.yml 里的卷定义,而不是某个设置项。
目录结构是「一个作品一个目录」:
/var/lib/dtk/media/<platform>/<author_uid>/<content_id>/├── video.mp4 # 也可能是 .mov、.webm、.m4v├── cover.jpg # 只有一个,不会有两个├── image-01.jpg # 图文作品:一张图一个文件├── image-01.mp4 # 实况图的视频,和它的静图同名├── image-02.jpg└── meta.jsonmeta.json 写在文件旁边,正是它让两年后翻出来的这个文件夹仍然说得清自己是什么。它包含平台、作品 ID、类型、web_url、标题、描述、发布时间、时长、作者 uid 与昵称、音乐、标签、位置以及文件列表。它刻意不包含任何带签名的 CDN 链接——那些几小时内就会过期,会是整个文件里唯一会误导人的部分。
在抓取任何东西之前,规划器就已经决定好的事:
- 每个作品只存一个视频。 平台给出的所有码率都被当作同一个文件的镜像——先落地的那个赢。把同一段视频存四份不是功能,是把磁盘塞满。解析器给出的顺序会被保留,而它把无水印的流排在最前。B 站视频是一个渐进式 MP4,而不是分开的音轨和画面轨,所以也只有一个文件;多 P 视频算一个作品,存下来的是第一个分 P,除非你指定别的分 P(见多 P 视频的某一 P)。用访客身份实测,这个文件在较早的投稿上最高 1080p,在当下的投稿上是 720p。
- 实况图连视频一起存。 抖音的实况图每张都带一段几秒的 MP4,存在它的静图后面,文件名相同——
image-01.webp旁边就是image-01.mp4。一个图集里可以混着普通静图,静图没有视频。这段视频不是作品的视频:资料库不会把它当作品播放,转写也不会读它。5.1.6 之前归档的图集,存下的媒体清单里没有这些视频,所以升级后第一次下载它时,不管链接多新都会先重新解析一次。 - 只存一张封面,最后添加,且仅在作品还没达到文件数上限时才加。
- 每个作品最多 128 个文件。抖音图文上限大约是 35 张,如果每张都是实况图,加上封面就是 71 个文件;这个上限是防止畸形数据变成上千项的任务,而不是给正常内容设的门槛。
- 图片单个上限 32 MiB,若
media.max_file_bytes更小则取更小的那个。一张 500 MB 的「图片」说明出了问题,而不是照片很大。实况图的视频也用这个上限:实测 720p 约 0.6 MB。 - 只在认识的时候才从 URL 取扩展名——视频认
mp4、mov、webm、m4v,图片认jpg、jpeg、png、webp、heic、gif——否则用该类型的默认扩展名。一个以.php结尾的查询串不可能变成你磁盘上的文件名。 - 每个镜像都要先过媒体主机白名单,任务才会存在;被丢掉的镜像会作为
skipped原因回报给你。
正在传输的文件写作 <name>.part,完成后才改名就位,所以一个名字出现时,它背后的文件一定是完整的。.part 存在期间也计入媒体卷总量。
开始一次下载
Section titled “开始一次下载”「下载」页面顶部是一排模式标签。每个模式接受的输入形状不同,这正是它们不能共用一个提交按钮的原因——作品链接和主页链接是两回事,一个会猜的表单会悄悄下载错东西。
| 模式 | 接受什么 | 结果 |
|---|---|---|
| 单个作品 | 一条分享链接、包含链接的整段分享文案,或纯作品 ID | 该作品的媒体存到服务器的媒体卷上 |
| 批量作品 | 一次粘贴很多条链接 | 每条链接一条解析结果,之后可一次性全部保存 |
| 评论 | 作品链接或 ID | 一个 JSON 文件,下载到你的浏览器——评论是数据,不是媒体 |
| 作者的作品 | 主页链接或纯作者 ID | 为该作者 feed 中的每个作品各排一次下载 |
跳过已下载的内容 这个勾选框由所有「可能作用于多个对象」的模式共用(除评论外都算)。它会直接返回本实例已有的那一份,而不是重新抓取——重跑一次 feed 时想要的正是这个:作者新发了三个作品,另外四十个早就在本地了。已被清理掉的副本不算,那里已经没有字节可跳过。
粘贴链接或作品 ID。纯数字以及 B 站的 BV…、av… 号按 ID 处理,其他一律按链接处理。旁边的平台菜单只在输入纯数字时才起作用,输入链接时会被禁用,因为链接自带平台信息——而且如果两者同时给出且互相矛盾,API 会直接拒绝而不是去猜。BV… 和 av… 号自带平台信息,所以会发往 B 站,菜单也会显示 B 站;选中 B 站时输入的纯数字会被当作 av 号并换算成 bvid。
会发生三种情况,提示条会告诉你是哪一种:
- 已开始下载。 作品本来就在归档里,直接用已存的镜像生成计划并排队。响应是
202,带download_id和执行它的task_id。 - 正在先抓取再下载。 本实例从没见过这个作品。worker 会先通过和其他读取完全一样的身份池、调度器与请求日志把它抓下来,再下载抓回来的内容。首次响应会慢一些,好处是你不必先自己解析一遍。
- 已在下载中 / 已经下载过。 请求被一条已存在的下载记录接住了(
reused: "in_flight"或"already_stored",HTTP200)。合流到进行中的下载永远不是可选项,无论skip_existing怎么设:同一个作品的两次下载写进同一个目录,竞争中输的那一方会去重命名赢家已经挪走的文件,最后变成缺文件的partial。
短链(v.douyin.com/...、vm.tiktok.com/...、b23.tv/...)在这里会被拒绝,这是设计如此:解开短链意味着要去访问它,而这个接口不发起任何网络请求。把短链交给 POST /api/v1/parse——它会在后台展开——等你回来时作品已经归档好了。批量作品 模式可以直接接受短链,因为它走的是解析流水线。
如果作品已经归档但没有任何可下载的媒体,你会立刻拿到一个带原因的 400,而不是一分钟后失败的任务。
多 P 视频的某一 P
Section titled “多 P 视频的某一 P”B 站的投稿可以分成好几 P。普通下载存的是第一 P,也就是作品链接播放的那一 P。要存别的,给 POST /api/v1/downloads 传 part(从 1 开始数),或者直接粘贴播放器所在的链接——B 站链接里的 ?p=3 就是第 3 P,会按同样的方式读取。part: 1 就是作品本身的下载,不管怎么写都一样。请求的分 P 超过最后一 P 时会失败,并说明这个视频有几 P。抖音和 TikTok 的作品没有分 P,传 part 会返回 400。
在下载页的 单个作品 模式里,B 站作品的输入框下方会多出一个 分P 菜单。它默认停在第 1 P,粘贴的链接带 ?p= 时则停在链接指定的那一 P;点 列出分P 才会把其余各 P 连同标题和时长一起列出来——要你点了才列,而不是边输入边查,因为列一次就是一次走身份池的真实请求,而大多数视频只有一 P。分 P 的下载在表格行和抽屉里都会标成 P3 这样。
每一 P 都是独立的下载,有自己的 download_id,存在作品目录旁边的单独目录里:bilibili/<mid>/<bvid>-p3/。放在旁边而不是里面,是因为删除下载会删除它的目录,嵌在作品目录里的分 P 会在第一 P 的文件被删时一起消失。同一个作品的两个分 P 可以同时下载;同一分 P 的第二个请求会加入正在进行的那个,和其他作品一样。保存到你自己的电脑时,分 P 的文件名会带上分 P(dtk-bilibili-<bvid>-p3-video.mp4),所以存到同一个文件夹里的不同分 P 不会互相覆盖。
资料库展示的是作品链接播放的内容,所以只把第一 P 的下载算作作品已保存的媒体,不算后面的分 P。转写跟着下载走,所以转写第 3 P 就是转写第 3 P 的那次下载。GET /api/v1/bilibili/video/parts 会列出每一 P 的序号、标题和时长(API)。
批量作品(批量解析并下载)
Section titled “批量作品(批量解析并下载)”这个模式会把单行表单整个替换掉,换成一个文本框、它自己的结果表格和自己的工具栏。一次最多粘贴 500 条链接,提交时按每批 50 条分块送入解析流水线。
它是围绕「你手上真实拥有的输入」设计的。一段抖音分享文案长这样:2.84 nqe:/ <标题> https://v.douyin.com/L4FJNR3/ <一句话>——一条链接被散文包着。按空白切分会得到四行,其中三行是垃圾。所以这里的做法是:你输入时(停顿 300 毫秒后)整段文本会被送到 POST /api/v1/tools/parse-batch,由它用系统其他部分同一份主机白名单抽取 URL 并报告识别结果。这个接口不碰网络、不消耗身份;它最多接受 512 KiB 文本、最多返回 1000 项,被截断时会用 truncated 说明。
每一行会被判定为其中之一:
| 类型 | 可提交 | 含义 |
|---|---|---|
link |
是 | 一条能识别的抖音、TikTok 或 B 站链接 |
short_link |
是 | 需要跟随跳转的分享链接;解析流水线会展开它 |
content_id |
否 | 一个不带平台信息的纯作品 ID——或一个裸的 B 站 bvid,它带着平台信息,但仍然不是链接;这个模式只提交链接 |
bad_id |
否 | 看着像作品 ID 但不可能是——抖音和 TikTok 生成的 ID 高位就是它被签发时的那一秒 |
unknown |
否 | 这一行里没有抖音、TikTok 或 B 站链接 |
被拒绝的那几类在本地就给出答案,不会消耗任务,会以失败行的形式出现在表格里并附上原因。
提交是每块一次请求,而跟踪是逐行的。每一行从此各过各的日子——自己的状态、自己的错误、自己的重试——因为一个同步的批量接口会把一条失效链接抹成整批的一个错误。轮询间隔 1.5 秒,一次 8 行,轮转推进,这样长列表的尾部不会被饿死。
结果表格的列是:状态、链接、标题、作者、平台、类型、内容 ID、播放、点赞、错误码。评论数、分享数和发布时间也有,但默认隐藏——用表格的列选择器打开。点击一条已完成的行会打开抽屉,里面有播放器、图片、各项数据、逐文件的下载按钮和原始结果载荷。实况图在静图的位置直接播放它的视频,并且单独有一个下载这段视频的按钮。
工具栏:
- 保存到本实例 — 对每一行发出和单作品模式完全相同的请求,所以这些行同样享有进度、重试、去重和「跳过已有」这一整套行为。一条失败不会放弃其余的;提示条会报告排队了多少、已经在本地的有多少、无法排队的有多少。
- 导出 CSV — 每条解析结果一行,保存为
dtk-parse-<时间戳>.csv。列为url、state、error_code、platform、kind、content_id、web_url、title、author、author_uid、created_at、duration_ms、play_count、digg_count、comment_count、share_count、collect_count、video_url。含逗号、引号或换行的单元格会加引号,并且以=、+、-、@开头的值前面会补一个单引号,防止表格软件把它当公式执行。 - 导出 JSON — 同样这些行,但带完整结果载荷,保存为
dtk-parse-<时间戳>.json。 - 重试失败项(N) — 只重试错误码不属于永久性错误的行。一条死链不值得再消耗一个身份。
- 清空 — 清空表格。服务器上什么都不会变;这些行是浏览器里的状态,刷新页面就没了。
抽屉里的下载按钮和「保存到本实例」是两回事:它们从平台 CDN 直接抓到你的浏览器,文件落在你自己的下载文件夹里,服务器全程不中继媒体。镜像会按顺序逐个尝试。有些 CDN 会拒绝跨域读取,当所有镜像都拒绝时,第一个镜像会在新标签页打开并给出提示——那是 CORS 或防盗链,不是链接失效。
作者的最新作品
Section titled “作者的最新作品”粘贴主页链接,或者纯作者 ID:抖音是资料库和调试台给你的 sec_user_id,TikTok 是 secUid(两者都以 MS4wLjABAAAA 开头),B 站是数字 mid。TikTok 的主页链接里是 @handle,作品列表接口不接受它,所以页面会先读一次主页拿到 secUid。下载多少个 菜单提供最新 20、50、100、200 个,或「feed 给多少取多少」。这里刻意用菜单而不是数字输入框:每个作品都是一次下载,feed 每翻一页都是一次走身份池的读取,而自由输入的深度会诱使人不假思索地敲一个「99999」。选「全部」时会先确认一次。
页面会以每页 20 个的节奏翻 feed 直到取够,最多翻 40 页,然后为每个作品排一次下载,并在表单下方实时显示进度:先是正在读第几页,然后是已排队、本地已有、失败各多少,以及第一个失败的原因。停止 会在任意两次请求之间结束这次运行。个别作品失败不会放弃其余的。
抖音只对已登录身份提供作者作品的第二页及以后:没有的话,第 2 页会返回 IDENTITY_LOGIN_REQUIRED。这时会保留前面几页取到的作品并为它们排队,同时提示后面的页需要已登录身份,并附上该平台已登录身份的链接。游客应答的第一页也可能漏掉作者最新的几个作品,发生时报告里会写明。
有一个坑值得知道:纯作者 ID 看不出属于哪个平台——MS4wLjABAAAA… 在抖音和 TikTok 上长得一样,mid 又是纯数字——所以会按菜单当前显示的平台去查。平台菜单正是为这种情况才启用的;框里是主页链接时它会被禁用,因为链接自带平台信息。
如果需要比 feed 一次能给出的更深的历史,请用资料库作品抽屉里的 回溯这位作者的历史,那是另一件事、另一种代价。
一个作品的评论
Section titled “一个作品的评论”通过普通读取接口最多翻 5 页评论,平台允许时每页 50 条(TikTok 为 35 条;B 站的每页条数由它自己决定),然后把一个名为 comments-<platform>-<id>.json 的 JSON 文件交给你的浏览器。每一页都是一次走身份池的真实请求。评论是数据而不是媒体,所以服务器的媒体卷上不会写入任何东西,下载器也不参与。
B 站只给游客看最热的三条评论,而且没有第二页。如果是游客应答的,或者后面的页需要已登录身份,已经读到的评论照样会保存,页面会说明为什么没有更多。
| 列 | 含义 |
|---|---|
| 作品 | 已存封面的缩略图、归档里的标题,下方是作者与平台。归档对该作品一无所知时退回显示内容 ID |
| 状态 | queued、running、done、partial、failed 或 cancelled,运行中还会带一条实时进度条 |
| 平台 | douyin、tiktok 或 bilibili |
| 文件数 | 成功完成的文件数量 |
| 大小 | 磁盘上的字节数;文件已被删除的行(手动删除或容量上限清理)显示 已清理 |
| 完成时间 | 结束的时间 |
| 保留 | 置顶开关 |
| 重试 | 只在值得重试的已结束行上出现 |
partial 是一个独立状态,而不算成功也不算失败:视频落地了、第三张图 404 了的作品哪一头都不是——报成 done 会掩盖缺口,报成 failed 会掩盖磁盘上确实存在的那个视频。
按状态和平台的筛选发生在服务端,所以「最近的 TikTok 下载」指的是全部里最近的,而不是碰巧加载的那一页里最近的。排序则相反,是在浏览器里对已加载的行做的——默认按完成时间倒序。表格每页 100 行,带上一页、下一页和当前页在全部中的位置(第 101-200 条,共 340 条);上方的搜索框按归档里的作品标题、作者或作品 ID 匹配(GET /api/v1/downloads 的 q)。筛选、搜索和打开的抽屉都记在地址里,所以 /downloads?id=<下载 ID> 从任何地方都能直接打开那条下载。
有任务进行中时表格每 2 秒轮询一次,否则每 10 秒一次。进度条最多同时为 8 条进行中的行向 sidecar 实时读取。它显示已写入的字节数与总大小、百分比、最近几秒的速度和剩余时间,例如 38.2 MB / 96.7 MB · 39%,下一行是 1.4 MB/s · 剩余 42 秒。总大小来自各文件服务器在 Content-Length 里声明的大小。sidecar 在执行容量上限时从不采信这个数字,上限是按实际写入的字节强制的,但拿来画进度条足够了。还没开始下载的文件还没有声明大小,所以在一个下载的所有文件都开始之前(比如图集后面的几张图),进度条改为按文件数计算,旁边显示已写入的字节数。
点击一行会打开以作品标题命名的抽屉,里面有播放器、逐文件列表、每个文件的 SHA-256,以及每个已完成文件的保存链接。归档里有这个作品时,抽屉会链接到资料库中的它;视频已经有转写时,转写视频 会变成 查看转写。抽屉会随表格刷新跟着这一行更新:在下载进行中打开它,能看到每个文件各自的进度和速度,下载完成后会显示已保存的文件。
progress 对象
Section titled “progress 对象”在 API 里,这条进度条就是 progress:GET /api/v1/downloads 的每一行和 GET /api/v1/downloads/{download_id} 都带着它。它是实时向 sidecar 读的,从不落库,所以以下情况它是 null:已结束的行;下载器没配置或没应答;sidecar 已经忘了这个任务(它只保留有限的历史,重启就全丢);以及一页里排在前 8 条之后的进行中行。null 的意思是“没有进度条”,不是“没有进展”。
| 字段 | 类型 | 含义 |
|---|---|---|
bytes |
整数 | 目前已写入的字节数,所有文件相加 |
bytes_expected |
整数或 null |
整个下载最终的大小:已完成的文件按实际大小,进行中的文件按它声明的 Content-Length,失败的文件不计。只要有一个文件还没开始、或者它的服务器没有声明长度,就是 null——这时请像控制台那样改按文件数计算 |
bytes_per_second |
整数 | 最近几秒的速度,进行中的文件相加 |
files_done、files_total |
整数 | 已完成的文件数,以及任务里的文件总数 |
state |
字符串 | sidecar 自己记录的任务状态:queued、running、done、partial、failed 或 cancelled。它可能比这一行早一点结束 |
items |
数组 | 每个文件一项,按任务里的顺序:name、kind(video、image 或 cover)、state(queued、running、done 或 failed)、bytes、bytes_expected(服务器没声明时为 null)和 bytes_per_second(不在下载中时为 0) |
bytes_expected 只用于显示:容量上限在 sidecar 里按实际写入的字节强制执行,从不采信服务器声明的大小。
重试,以及为什么两次尝试之间没有断点续传
Section titled “重试,以及为什么两次尝试之间没有断点续传”重试出现在 failed、partial 和 cancelled 的行上。它调用 POST /api/v1/downloads/retry 并带上 download_id,从头把同一个作品再走一遍;如果已存镜像太旧(超过 media.mirror_max_age_seconds,默认 600 秒),会先重新解析作品拿新链接。
两次尝试之间没有断点续传,而且做出来也没有意义。sidecar 写入 <name>.part 并在每次尝试时清空它,原因在上游:媒体 URL 带签名且会过期,一小时前抓下来的字节接不到一条现在返回 403 的链接上。重新请求才是有效的做法。
同一次传输之内则不同。有些 CDN 节点会提前结束传输:2026 年 10 月在 B 站实测,Akamai 节点在还没缓存某个视频时,每次都在恰好 2 MiB 处以 “unexpected EOF” 断开,一分钟后同样的请求就能完整返回。所以当镜像在声明的长度之前就断开时,sidecar 会向同一个镜像请求剩下的部分——从断开的那个字节开始发 Range 请求——最多三次,间隔只有几秒,用的还是同一条链接。只接受恰好覆盖同一个文件剩余部分的 206(If-Range 带上第一次响应的校验值),所以中途被替换的文件会被拒绝,而不是拼接上去。如果节点对「剩下的部分」这个请求返回服务器错误——实测是它还在从源站拉取文件时返回的 504——会在 1、2、4 秒后再问。下载器日志里 item stored 那一行会写明一次传输续传了几次。
下载还在进行时重试会被拒绝——第二次尝试会和第一次在同一个目录里打架。卡住的下载会在创建 两小时 后被维护任务判为失败(维护每五分钟跑一次),之后这一行就可以重试了。这个过程不会动磁盘上的任何东西:已经落地的文件原样保留。
置顶与容量上限
Section titled “置顶与容量上限”这是会删东西的部分,值得在需要它之前先读一遍。
已存媒体的总量由 media.max_bytes 限制,默认 2 GiB。当媒体卷超过它时,维护任务会从最旧的开始删除已存下载,直到回到上限以下。这个任务具体怎么做:
- 总量取自媒体卷本身,由 sidecar 测量,而不是取自数据库。因此手动删掉的文件、从备份恢复回来的文件都会被算进去;一个只相信自己账本的清理任务,最终会为了一个别处都不认同的数字去删文件。
- 置顶的下载永远跳过。 这是唯一的豁免方式。
- 只有文件会消失。记录保留它的文件列表、大小和摘要,并汇报
on_disk: false,所以控制台仍能告诉你当时收了什么、以及它后来被清理了。这个区分很重要:「收过后来被清理」可以靠重新请求撤销,「从来没抓过」则怎么都撤销不了。 - 每一次真的删了东西的清理都会触发
media_evicted告警(六小时最多一次)。这是你唯一能得到的「磁盘策略刚刚执行过」的通知——配置通知渠道见运维。 - 如果超出上限的部分全是置顶内容,则什么都不删,并记录一条「没有可清理项」的警告。
media.max_bytes = 0完全关闭清理。这是你可以做的选择,代价是从此不会自动清理任何东西。
置顶 在「保留」列操作,也可以选中若干行后批量操作。它让整条下载永久免于清理。任何你丢了会难受的内容都该置顶,尤其是平台已经下架的作品——那个再也抓不回来了。
和媒体上限相互独立的还有一道容量护栏,它盯的是本实例能看到的卷的实际磁盘占用。超过 capacity.warn_percent(80)会发警告;超过 capacity.hard_stop_percent(92)时,新的下载任务会被以 Retry-After: 300 拒绝、定时采集也会停下来,而所有读取照常。护栏本身不删任何东西。磁盘满了应该让实例降级,而不是让它下线。
页面顶部的四块指标分别是:已占用字节(对照上限,75% 变黄、90% 变红)、下载总数与进行中数量、置顶数量、已清理数量。
重复项、取消,以及到底什么才会真的删掉字节
Section titled “重复项、取消,以及到底什么才会真的删掉字节”重复下载。 重复下载同一个作品是常事——第一次失败了、链接过期了、文件被清理了——每次尝试都会留下一条记录,而成功的那几次会留下完整的第二份副本。重复下载 卡片会先跑一次 dry run,把数量放进确认对话框里,因为这个数字就是整个决策本身。确认之后,每个作品保留最新的、且文件还在的那一份,其余删除。只有当没有任何保留下来的记录还在用某个目录时,该目录才会被删除——所以在两次尝试写到同一位置这种常见情形下,记录会删掉、文件会留下,这是正确结果:磁盘上本来就只有一份。如果需要删除目录而下载器不可达,它会拒绝执行,而不是留下无人指向的字节。
取消。 DELETE /api/v1/downloads/{id} 会停止一个尚未结束的传输:通知 sidecar 取消,并把记录标为 cancelled。它不删除任何东西。已经完成的文件原样保留,而对一条已经结束的下载,它会原样返回状态。
删除下载的文件。 「下载」页面的批量 删除 会对每个仍有文件在磁盘上的选中行调用 DELETE /api/v1/downloads/{id}/files。它删掉文件、保留记录,记录随后显示 已清理,并计入那块指标——和容量上限清理掉的效果一样,重新请求这次下载就能把文件取回来。同一作品的多条下载共用一个目录,所以会删掉这个目录并标记其中所有记录。已置顶或仍在进行中的下载不会被选入,接口也会拒绝:置顶是「文件会留着」的承诺,进行中的下载还在往里写。在有这个接口之前,这个按钮调用的是上面的取消接口,对已完成的行提示删除成功、实际什么都没删。
因此,会删掉已存文件的有四条路:
- 上面说的下载页的删除:文件删除,记录保留并显示为已清理。
- 在资料库里删除作品(它的批量删除),会一并删掉归档行、合集归属、下载记录和磁盘上的目录。因为记录也一起删了,它们不会出现在 已清理 里——已经没有东西可计数了。
- 容量上限清理,针对一切未置顶的内容。
- 上面说的重复项清理,针对冗余副本。
把已存文件保存到自己的电脑
Section titled “把已存文件保存到自己的电脑”已存文件可以交给你的浏览器。在下载抽屉里,每个已完成文件都有一个 保存这个文件 链接;在资料库抽屉里,则是 导出视频文件 以及每张已存图片(和实况图的视频)、封面各一个链接。
这走的是 GET /api/v1/downloads/{download_id}/files/{name},需要 media:read 权限范围。这个接口不抓取任何东西:它用已存记录重建路径——该下载自己的目录,加上一个必须出现在这行文件列表里的名字——解析路径后再校验结果仍落在媒体根目录内。记录里没有的名字一律 404,无论磁盘上是否真的存在同名文件。文件已被清理的下载会返回 404 并带 reason: "evicted",而不会回头去平台抓;想恢复就重新请求一次这个下载。
文件会带着下载器记录的 content type,以及一个把它重命名的 Content-Disposition,其中 <stored name> 是这个文件存在卷上时的文件名:
dtk-<platform>-<content_id>-<stored name>例如 dtk-douyin-7408915107113127220-video.mp4。在媒体卷上,每个作品独占一个目录,所以文件叫 video.mp4 也永远不会撞名。你的下载文件夹没有这样的目录——存五十个作品就是五十个 video.mp4,第二个变成 video (1).mp4,而两个名字都说不出它来自哪个作品。A-Za-z0-9._- 以外的字符会变成连字符,每一段最多 64 个字符。名字由服务端生成,所以 curl、控制台和任何其他客户端拿到的是同一个。
要注意它不是什么:服务器不会按需代理媒体。保存一个文件读的是已经落盘的那一份,而且要通过带认证的权限校验。「保存到本实例」和「保存到我的电脑」是两个不同的目的地,这也是控制台把它们分开命名的原因。
把已存的视频转成文字
Section titled “把已存的视频转成文字”已完成下载的视频可以在本机转成文字。transcriber 边车运行 FunASR 模型——Paraformer 识别语音,FSMN-VAD 找出哪里有人在说话,CT-Transformer 补上标点——而且它所在的网络没有任何出口,所以音频不会离开这台主机。它是可选的,默认关闭:用 transcriber profile 开启,需要的约 500 MB 模型按准备 FunASR ONNX 模型获取。用一键安装脚本时,回答“开启转写”这两件事会一起做完。
批量排队之前先了解它擅长什么:模型是通用普通话模型,中文语音效果好,其他语言效果差。结果是每个视频一整段文字——没有时间戳,也不区分说话人。B 站的下载和抖音、TikTok 的一样可以转写;多 P 视频转写的是那次下载存下来的分 P——默认是第一个,除非你下载的是别的分 P。
| 从哪里 | 怎么做 |
|---|---|
| 控制台 | 下载 → 打开一个已完成的下载 → 转写视频;或者 资料库 → 打开一个已保存的视频作品 → 转写视频 |
| REST | POST /api/v1/transcriptions,请求体 {"download_id": "<uuid>"};加 ?wait=30 可以挂住连接直到完成 |
| MCP | transcribe_video(download_id)——从一个链接开始的话,顺序是 parse_url → download_media → transcribe_video |
下载必须已完成,而且视频还在磁盘上。被容量上限清理掉的会被拒绝并说明原因:重新下载一次再转写。
重复请求不浪费。同一个文件已完成的转写会直接返回(200,reused 会说明),正在进行的会被合并(202),而不是重新开始。refresh: true 强制重新转写。normalize(默认开启)把口语里的数字改写成阿拉伯数字;如果这最后一步失败,由 DTK_TRANSCRIBER_NORMALIZATION_FAILURE_POLICY 决定是拿到未规范化的文字(fallback,默认),还是让这次转写失败(fail)。
视频转写页面
Section titled “视频转写页面”/transcriptions 同样在 内容 分组里,按时间倒序列出所有转写,显示状态、文字预览、视频时长、模型,以及数字是否已规范化。进行中的会显示当前阶段——解码音频、检测语音片段、识别语音、添加标点、规范数字写法——详情抽屉里显示目前已识别的文字,在最后加上标点和数字格式之前都标为临时文本。已完成的可以导出为 .txt。
列表每页 100 条,可以按转写文字、作品标题或作品 ID 搜索;筛选和打开的抽屉都记在地址里(/transcriptions?id=<转写 ID>)。转写的抽屉会链接到它所用的下载,以及资料库中的作品。转写组件关闭时(默认如此),各处的转写按钮会变灰并说明如何开启,这个页面为空时也会这样提示。
取消转写会停止排队中或进行中的任务。删除只移除已结束的记录——文字及其元数据——不会动它来源的视频。
只在任务运行时占用 CPU:模型常驻内存约 750 MiB,一个任务会占满两个核。2026-10-16 实测,一段 3 分 13 秒的视频在独占两个核时用了 3.7 秒。和浏览器容器铸造身份共用这些核时会变慢——见转写和浏览器在同一台主机上。
边车一次只跑一个任务(DTK_TRANSCRIBER_WORKERS),后面最多排八个(DTK_TRANSCRIBER_QUEUE);再多的转写会被标为失败并写明原因,稍后重新发起即可。转写和下载一样计入 worker 队列的上限,所以批量发起时可能在到达边车之前就收到 QUEUE_FULL。
/library 就是归档:本实例解析过的全部内容,完全由本地存储回答。这里不消耗身份、不触发上游限流,作品即使已经被平台删除,也仍然在这里,并由「可用性」字段说明它的状态。这正是保留一份归档的全部意义。
顶部的指标显示作品总数、作者数、被标为 已消失(上游已删除)的数量,以及每个平台各一块。每块下面都带一行「已下载 N / M」,它同时是个链接:点击会把列表筛成媒体在本磁盘上的那些作品。「见过」和「留下了」是两个不同的数字,把前者当后者读,正是 66 个作品的归档被误以为是卷上 66 个视频的原因。
两种视图:
- 封面(默认)— 封面墙,带时长;已保存的作品还带一块体积标签。封面优先取本实例磁盘上的那份,没有才回退到平台的,这个顺序有讲究:归档里的
cover_url是会过期、还可能有防盗链的 CDN 链接。网格可以不分组、按作者分组,或按保存日期分组。 - 表格 — 可排序的列,用来回答网格答不了的问题:作品、类型、时长、可用性、发布时间、最后见到。
翻页用游标而不是页码,因为你翻的同时这张表还在被写入,用 offset 会静默地跳过和重复行。「上一页」是在你已经走过的游标里回退。每页 50 个作品。
选中状态按作品键保存,并且翻页不会丢失——这里选几个、那里选几个、然后一起处理,正是设计意图。只要有选中项,视口底部就会出现批量操作条。除了合集和 删除,它还能 保存媒体(每个作品发起一次下载;已经保存过的只计数,不会重新抓取)、转写(选中项中媒体已保存在本地的视频;图集和还没保存的作品会被跳过并计数)和 导出选中项(通过 POST /api/v1/archive/export 导出为 NDJSON)。保存和转写会逐个进行,带实时计数和停止按钮,结束后报告完成、跳过和失败各多少。
筛选、搜索与视图
Section titled “筛选、搜索与视图”| 筛选 | 取值 | 生效时机 |
|---|---|---|
| 搜索 | 标题或描述的子串 | 提交时 |
| 平台 | douyin、tiktok、bilibili |
改变即生效 |
| 类型 | 视频、图文 | 改变即生效 |
| 时长 | 一分钟以内(< 60 秒)、三分钟以内(< 180 秒)、长视频(≥ 180 秒)、未知 | 改变即生效 |
| 可用性 | 正常、已删除、已私密、未知 | 改变即生效 |
| 合集 | 你创建的任意合集 | 改变即生效 |
| 本地文件 | 下载与否不限 / 仅已下载 / 仅未下载 | 改变即生效 |
| 作者 | 点击卡片、表格或作品抽屉里的作者名 | 立即生效,显示为可移除的标签 |
| 标签 | 点击作品抽屉里的 #标签 |
立即生效,显示为可移除的标签 |
多个筛选条件可以叠加,而且每一个——连同打开的作品——都记在地址里:/library?author=<uid>、/library?tag=<标签> 和 /library?platform=<平台>&content_id=<ID> 这些其他页面使用的链接,从任何地方都能直接打开。菜单类的一改就生效,因为从一个封闭列表里选中「douyin」这件事本身就是决定;而文本框要等按钮,因为在你停下来之前打字还不算决定,每敲一个键就发一次查询等于在拼一个词的路上把整个归档翻一遍。
搜索是按子串匹配而不是按词匹配,这是一个有意的取舍而非疏漏:Postgres 的全文检索不会切分中文,按词搜索会在主力平台上静默地匹配不到任何东西。代价是搜一个很短的串会命中比你想要的更多的内容。
「本地文件」描述的是留下了什么,而不是作品本身:已被清理的下载不算已下载,因为已经没有东西可播了。「清空」旁边的计数会告诉你有几个筛选条件正在收窄列表,而这通常就是「为什么 660 个作品的资料库只显示了 4 个」的答案。
合集是你手动分出来的一组作品。它是资料库里唯一不从作品本身推导出来的分组方式——作者、平台、时长、保存日期都来自记录本身,只有这一个来自你的决定。
点击「清空」旁边的 合集 可以创建、查看和删除它们。名称最长 80 个字符,且大小写不敏感地唯一(由数据库保证,所以两个控制台标签页不会竞争成功);可选的备注最长 500 个字符,且永远不会被解析。
选中作品后用批量条里的 加入合集 添加。从当前合集移出 只在你正按某个合集筛选时才出现——否则「从哪个合集移出」没有答案。把已经在合集里的作品再加一次是空操作而不是错误,不在归档里的作品会被跳过,所以一张过期的卡片不会连累另外十九张。单次调用最多 500 个作品。
删除合集只删掉这个标签。作品仍留在归档中,文件也仍在磁盘上。这是本页唯一一个不碰任何作品、任何字节的删除操作。
从资料库保存媒体
Section titled “从资料库保存媒体”点开一个作品会打开抽屉,底部有三个操作:
- 回溯这位作者的历史 — 翻过第一页,把这位作者的历史作品全部归档。它和关注列表刻意分开:关注列表关注的是「新增了什么」并且永远运行下去,而回溯是一次性的、代价完全不同的任务。控制台使用服务端默认的 5 页;API 接受 1–20,worker 硬性上限是 20 页、每页 20 个作品。遇到第一个空页、达到上限,或平台表示没有更多历史时停止。每页一次上游请求,需要对应平台的读取权限范围。
- 保存到本实例 — 为这个作品发起一次下载。如果媒体已经存过,按钮会变成 已保存 · 重新抓取,提示里会写明已存副本多大;点它会重新抓一份替换掉现有的。(这个按钮不设置
skip_existing,所以它是重新抓取而不是什么都不做。)下载是排队执行的任务,所以提示条出现的那一刻文件还没落盘——页面会在几秒后重新查询一次,而下载记录会立刻出现在「下载」页面上。 - 导出视频文件 — 把已存的视频交给你的浏览器(如果有视频的话)。图文作品没有视频,所以这个按钮不会出现;它们的每张图(实况图的视频紧跟在它的静图后面)在抽屉正文里各有一个链接,因为浏览器不会因为一次点击就发起三十个下载,假装可以只会静默地只存下三十分之一。
抽屉还会内嵌播放已存视频,播的是本实例磁盘上的文件而不是平台的:没有会过期的 CDN 链接、没有会失败的防盗链校验,也不会有本机以外的人知道有人在看什么。它只预加载元数据,所以打开抽屉不会让一个 240 MB 的视频开始传输。
播放器下方,视频类作品有一个 转写视频 按钮,和下载抽屉里的是同一个操作;已经有转写时它会变成 查看转写。它读取的是已保存的文件,而不是平台上的,所以作品还没保存时按钮是灰的,并提示先保存。已保存的作品还会链接到对应的下载。见把已存的视频转成文字。
页面标题栏的 导出 会把归档以换行分隔的 JSON(NDJSON)流式导出,保存为 archive-YYYY-MM-DD.ndjson。
它是一页一页流式发出的,而不是在服务端拼好再返回,所以不会出现「数据越多越导不出来」的情况。一行一个作品,形状和列表接口返回的一致,但不含媒体清单——需要镜像链接时请用列表接口或 GET /api/v1/archive/{platform}/{content_id}。单次导出上限 50 000 行,超出请用列表接口的 cursor 翻页。
依赖它之前有两点要知道:
- 它需要
archive:export权限范围,普通读取 Key 并不带这个权限范围。这是唯一一个能把读取 Key 变成一份数据库副本的调用,所以刻意单独划成一个权限范围。控制台会话带全部权限范围,因此任何已登录用户点这个按钮都能用。 - 导出会应用页面上的所有筛选,包括合集和「本地文件」。以前这两个会被忽略,导出一个合集拿到的是整个归档。如果要导出手动挑选的一组作品,请选中它们后用 导出选中项。
要备份整个实例(包含身份和设置),见运维里的备份工具;这里的导出只涵盖归档。
重新检查哪些还在
Section titled “重新检查哪些还在”页面标题栏的 重新检查 回答的是「我保存的东西里哪些已经没了」。每个作品都是一次走身份池的真实请求,所以这个过程作为后台任务运行,并且有明确边界。
默认值都是可改的设置项:每次检查 archive.recheck_batch 个作品(25,硬上限 200),只检查上次检查距今超过 archive.recheck_after_days(7)天的作品,每次请求之间间隔 archive.recheck_pause_seconds(3)秒。这个间隔是实测出来而不是拍出来的:设成 0.5 秒时,一次真实的扫描在 13 秒内发了 20 个 TikTok 请求,全部被拒,而一分钟后手动发的单个请求却成功了——调度器是按身份限速的,这对一个按地址计数的平台限制毫无作用。没有人在等这个扫描,所以慢是免费的。
已删除的作品会返回平台自己的 not-found,而那就是结论,不是失败:记录会保留并标记为 deleted,于是它仍然可搜索、可导出,并且带着真相。私密作品标记为 private。其他任何错误都不会改动这一行,因为一个风控响应说明不了作品是否还存在。如果扫描途中某个接口的熔断器打开,扫描会提前停止,而不是把剩下的都标成未检查。
worker 大约每六小时自己排一次重新检查,所以这个按钮是给「我现在就要答案」用的。把 archive.recheck_after_days 设为 0 会彻底关闭重新检查。
因为它会消耗身份池,这个接口要求的是平台读取权限范围(douyin:read、tiktok:read 或 bilibili:read)而不是 archive:read——读归档承诺不花任何代价,而重新检查是一次披着归档名字的平台读取。
出于同一个原因,重新检查和作者回溯抓取都像读请求一样排队。worker 队列到达 sched.queue_max(500)之后,它们会返回 503 和 QUEUE_FULL,并带 Retry-After,值为 sched.max_wait_seconds(10):等这么久再发一次。在一个相同的请求还在排队时再发,会合并到它上面,而不是再排一个。worker 自己定时排的重新检查是直接提交的,不受这个上限约束。
删除作品及其媒体
Section titled “删除作品及其媒体”选中作品后点批量条里的 删除。这是归档里唯一一个真正销毁东西的调用:不可撤销、没有回收站。归档行会消失,合集归属随之消失;并且除非你在 API 里传 media: false,下载记录和磁盘上的目录也会被删除。
删除顺序是先删文件、后删记录。反过来会留下一条指向已不存在目录的记录——一个控制台提供得了、却交付不了的下载;而这个顺序最坏的结果是留下没有记录的字节,而存储面板本来就会汇报这一点。
如果作品有已存媒体而下载器不可达——没运行,或者没有响应——这个调用会拒绝执行并且什么都不删,而不是在无人能核对的卷上留下孤儿字节。如果你确实想删掉记录但保留文件,那就是对 POST /api/v1/archive/delete 传 media: false;控制台永远传 true。
单次调用最多 500 个作品,需要 media:write(或 admin):能发起下载和取消下载的那个权限范围,才能删除它产生的东西,比它更弱的都不行。
平台上的原作品不受影响。这里删的是你的那一份。
关注列表页面
Section titled “关注列表页面”/watchlist 是控制台里唯一会创建长期工作的地方。其他一切都是做一次就结束;而这里的一个条目,只要还存在,就会每隔几小时消耗一次身份池。整个页面都是围绕「让这个代价可见」设计的:间隔是一等公民的列、失败次数直接可见、全部暂停 是一个按钮而不是让你用逐行开关拼出来的东西。
它服务的是一张表:快照历史。在有东西按计划采集之前,那张表里装的只是「谁碰巧在什么时刻解析过什么」——一堆互不相关的散点,而不是一个序列。是它让增长曲线成为真的。
这个页面自己不抓取任何东西。条目到期时,worker 会把它作为普通任务提交:同一个队列、同一个调度器、同一个身份池,结束时同样写归档和快照。因此定时采集永远排在交互式请求之后——那边有人在等——而且它也跑不过限流,因为限流只有一套。
| 列 | 含义 |
|---|---|
| 目标 | 名称或 ID,下方是类型和平台;链接到资料库里它采集到的内容(TikTok 作者除外:归档按数字 ID 记录 TikTok 作者,而关注的是 secUid) |
| 间隔 | 多久运行一次 |
| 下次运行 | 相对时间,或 已暂停 |
| 运行次数 | 自加入以来采集到的观测次数 |
| 上次运行 | 相对时间,或一条红色的「连续失败 N 次」,鼠标悬停显示最后一次的错误 |
| 运行中 | 启用/暂停开关 |
| (无标题) | 编辑(名称和间隔)与 移除,移除前会先确认 |
三块指标显示:目标总数与其中运行中的数量、总运行次数、当前失败中的数量。
添加一个目标
Section titled “添加一个目标”五个字段:平台、关注什么、目标、可选的名称、间隔。目标可以是主页或作品链接——平台以链接为准——也可以是纯 ID。在这里或之后在 编辑 里起的名称会一直保留;留空时首次成功运行会填上作者的昵称,在编辑里清空则把名称交还给平台。
| 类型 | 目标 ID | 一次运行采集什么 |
|---|---|---|
| 一个作者 | 作者的稳定 ID——抖音是 sec_user_id,TikTok 是 secUid(两者都以 MS4wLjAB 开头),B 站是数字 mid |
他作品列表的一页(最新 20 个)。那一页的每一项本来就带着作者记录,所以一次请求同时回答了「新增了什么」和「现在多少粉丝」 |
| 一个作品 | 作品 ID,和归档里保存的一致 | 该作品的详情,也就是它的数据随时间怎么变 |
TikTok 的主页链接里是 @handle,作品列表接口不接受它,所以页面会先读一次主页并保存 secUid。但直接在框里输入裸的 @handle 仍然不管用:添加时没有任何地方校验 ID 的形状,所以条目会被创建——之后每一次运行都会失败,因为作品列表接口没有任何能接受 handle 的参数,它会按名字拒绝,而不是把它送到一个根本用不了的地方。同一个「平台 + 类型 + 目标」重复添加会被当作重复项拒绝。
首次运行是立即到期的。刚添加完一个作者的人想马上看到它采集,把首次运行按间隔往后推只会让这个功能在六小时里看起来是坏的。
一个条目采集什么,在这个页面上不可配置,这是有意的——原因见上表:一次请求就同时回答了两个问题。API 里的 pages 字段(1–10,默认 1)会存在条目上、也会由列表接口返回,但一次定时运行提交的是一次「最新 20 个作品」的请求;更深的历史请用资料库的 回溯这位作者的历史。
间隔、下限,以及一个持续失败的条目
Section titled “间隔、下限,以及一个持续失败的条目”控制台提供 15 分钟、1 小时、6 小时、12 小时和 24 小时,并隐藏低于服务端下限的选项。新条目默认使用 watchlist.default_interval_seconds(6 小时),菜单里也一定列着表单将要提交的那个值。
下限是 watchlist.min_interval_seconds,默认 900 秒,而且无论怎么改都不能低于 60 秒。低于下限的间隔会被拒绝,而不是被悄悄抬高到一个你没要求过的数字。理由不是客气:每几秒采集一次的目标不会产生更好的时间序列,只会把整个身份池烧在一个作者身上,把其他所有事饿死。哪个平台上都没有任何东西变化得快到需要短于 15 分钟。
代价,方便你估算:一个条目按 6 小时计,一天 4 次请求。一百个作者按 6 小时计,一天 400 次请求,每次一页。缩短间隔会立即生效,而不是等旧的间隔走完。
退避作用在条目上,而不是任务上。连续 2 次失败之后,每多失败一次间隔翻一倍,上限 8 小时。否则一个 ID 敲错的作者会永远每次运行都失败;这样一来它变成一天试几次,而那一行连同它的错误仍然可见,留给来修它的人。平台故障总会结束,所以它仍然会被重试。重新启用一个暂停的条目会清掉退避和错误并让它立刻到期——你在断言问题已经解决,而让你为了验证这一点再等八小时,本身就是个 bug。
还有两条边界值得知道:
- watcher 每分钟走一轮,每轮最多排
watchlist.batch_size(10)个到期条目。其余仍然到期,留到下一轮,于是同一分钟到期的一百个条目会被摊到几分钟里,而不是一次性堵在正在用 API 的人前面。 - 下次运行是从提交时刻起算的,而不是从完成时刻。否则一个永远跑不完的任务会让这个条目永远停住,而「每六小时采集一次」即使丢了一次运行也应该继续成立。
- 超过容量硬停线后,定时采集完全停下。没有人在等它,而让这些写入方停下正是护栏存在的意义。
暂停、移除与全局关闭
Section titled “暂停、移除与全局关闭”三个不同的开关,作用范围由小到大:
| 操作 | 效果 | 如何恢复 |
|---|---|---|
| 行上的 运行中 开关 | 暂停该条目。它的历史、运行次数和标签都保留 | 再打开即可;退避和最后一次错误会被清除,并立刻到期 |
| 标题栏的 全部暂停 | 一次性停用所有启用中的条目。这是「出事了,我要把身份池要回来」那个按钮 | 只能逐个恢复,这是刻意的——没有「全部恢复」 |
watchlist.enabled 设置 |
全局停止定时采集,所有条目的配置原样保留。页面会显示一条横幅说明 | 把设置改回打开即可,其他什么都不变 |
移除 只删除这条计划,别的什么都不删。已经采集到的内容全部留在归档和快照历史里——删掉指令不等于要求删掉它产出的东西。
修改、暂停、添加和移除条目都需要 operator 及以上角色。仅查看列表只需要一个已登录的控制台会话。
以下都是运行时设置,可在控制台的「设置」页面或设置 API 中修改;设置的作用域与修改方式见配置参考。
| 设置项 | 默认值 | 作用 |
|---|---|---|
media.enabled |
true |
是否允许媒体下载。关闭会拒绝新任务,已存文件原样保留;关闭本身不删除任何东西 |
media.max_bytes |
2147483648(2 GiB) |
已存媒体可占用的磁盘量。超出后从最旧的未置顶下载开始删除,直到回到上限以下,并发出告警说明删了什么。0 表示完全关闭清理 |
media.max_file_bytes |
536870912(512 MiB) |
单文件上限,按实际写入的字节计算,而不是按服务器声明的大小。达到上限的传输会被拒绝且不留下任何东西。必须为 0 或至少 1 MiB |
media.mirror_max_age_seconds |
600 |
归档里的媒体链接多旧之后,下载会先重新解析作品拿新的。0 表示每次都重新解析 |
capacity.warn_percent |
80 |
磁盘占用达到百分之多少时发出警告 |
capacity.hard_stop_percent |
92 |
磁盘占用达到百分之多少时暂停新下载任务与定时采集。交互式读取永不暂停,也不会删除任何东西 |
archive.enabled |
true |
任务结果过期后仍保留解析出的作品和作者。关闭后除日志外实例不保存状态 |
archive.store_raw |
false |
同时保留各平台未加工的原始载荷。默认关闭:它是本实例可以选择存储的最大一块内容 |
archive.recheck_after_days |
7 |
作品上次存在性检查距今多久之后需要重新检查。0 关闭重新检查 |
archive.recheck_batch |
25 |
一次重新检查扫描验证多少个作品 |
archive.recheck_pause_seconds |
3 |
重新检查或回溯抓取的各次请求之间间隔多少秒 |
watchlist.enabled |
true |
是否运行定时采集。关闭会保留条目及其历史,只是不再提交运行 |
watchlist.min_interval_seconds |
900 |
条目允许的最短间隔。不能低于 60 |
watchlist.default_interval_seconds |
21600(6 小时) |
未指定间隔时新条目使用的间隔 |
watchlist.batch_size |
10 |
一轮最多排队多少个到期条目 |
权限范围与角色
Section titled “权限范围与角色”这三个页面背后的 API 面是刻意分开授权的:看采集到了什么、看运维者文件系统上有什么、以及往上面放东西,是三个不同的问题。
| 权限范围 | 允许 |
|---|---|
archive:read |
搜索归档、读取统计、读取单个作品 |
archive:export |
批量 NDJSON 导出,仅此一项 |
media:read |
列出下载、读取存储用量、获取已存文件 |
media:write |
发起、重试、置顶和取消下载;删除下载的文件;去重;创建和修改合集;删除归档作品 |
douyin:read / tiktok:read / bilibili:read |
可用性重新检查与作者历史回溯,因为两者都消耗身份池 |
transcription:write |
发起、刷新、取消和删除转写 |
transcription:read |
列出转写并读取文字 |
identity:manage |
关注列表的各个接口(/api/v1/admin/watchlist/*);其中一切写入操作还要求 operator 及以上角色 |
admin |
全部,关注列表的各个接口也在内 |
由此引出两点容易搞错的事:
- 一个只带
archive:read的 API Key 既不能导出归档,也看不到你磁盘上的任何一个字节。这正是设计意图。给 Key 授予刚好够用的最小一组权限范围——见用户与 API 密钥。 - 控制台会话带有全部权限范围,其边界是账号的角色而不是权限范围。只有关注列表里做写入的那些接口会额外检查角色(operator 及以上)。也就是说,任何已登录控制台的用户——包括 viewer——都能从界面上发起下载或删除归档作品。如果你不希望这样,那就不要把控制台账号发给你不愿意授予
media:write的人。
接下来读什么
Section titled “接下来读什么”- 配置参考 — 这里提到的每一个设置项,以及怎么修改。
- 核心概念 — 这些页面所消耗的身份池、调度器和任务队列。
- 控制台总览 — 其余页面以及它们之间的关系。
- 调试台与工具 — 解析单条链接、给请求签名,以及本页输入框用到的 URL 识别器。
- 用户与 API 密钥 — 用正确的权限范围签发一个 Key。
- 运维 — 告警、通知渠道、磁盘与备份。
- REST API 指南 — 这里每个按钮背后的接口。
- 故障排查 — 下载失败、关注条目持续失败,或磁盘被占满时怎么办。