下载、资料库与关注列表
这一篇讲的是「留下来」而不只是「看一眼」:把视频和图片存到你自己的磁盘上、几个月后还能找回来、以及按定时反复采集同一个目标。读完之后你会清楚这三个页面各自的代价、字节到底落在哪里、容量上限会拿走什么,以及怎样保住那个你真正舍不得丢的作品。
三个页面各管什么
Section titled “三个页面各管什么”控制台把「我见过什么」「我手上留着什么」「我还在持续采集什么」拆成了三个页面,这个拆分是有原因的:第一件事免费,第二件事会占满磁盘,第三件事会一直消耗身份池。
| 页面 | 路径 | 存的是什么 | 代价 |
|---|---|---|---|
| 资料库 | /library |
归档:本实例解析过的每一个作品各一行——标题、作者、标签、媒体清单 | 没有代价。它只查 Postgres,不消耗身份、不发起任何请求 |
| 下载 | /downloads |
下载索引:每一次「保存某作品媒体」的请求各一行,以及实际落盘的文件 | 磁盘;如果作品还没抓过,还要额外一次走身份池的请求 |
| 关注列表 | /watchlist |
长期指令:每隔 N 小时采集这个作者或这个作品,永远执行下去 | 只要条目还在,就按计划持续消耗身份池 |
三个页面都在侧边栏的 工具 分组下。一个作品可以只在归档里而媒体不在磁盘上(这是常态);反过来,下载记录也可以比归档行活得更久——即使作品后来被从资料库里删了,下载表里的那一行描述的仍然是媒体卷上一个真实的目录。
媒体下载器是可选的
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-02.jpg└── meta.jsonmeta.json 写在文件旁边,正是它让两年后翻出来的这个文件夹仍然说得清自己是什么。它包含平台、作品 ID、类型、web_url、标题、描述、发布时间、时长、作者 uid 与昵称、音乐、标签、位置以及文件列表。它刻意不包含任何带签名的 CDN 链接——那些几小时内就会过期,会是整个文件里唯一会误导人的部分。
在抓取任何东西之前,规划器就已经决定好的事:
- 每个作品只存一个视频。 平台给出的所有码率都被当作同一个文件的镜像——先落地的那个赢。把同一段视频存四份不是功能,是把磁盘塞满。解析器给出的顺序会被保留,而它把无水印的流排在最前。
- 只存一张封面,最后添加,且仅在作品还没达到文件数上限时才加。
- 每个作品最多 64 个文件。抖音图文上限大约是 35 张;这个上限是防止畸形数据变成上千项的任务,而不是给正常内容设的门槛。
- 图片单个上限 32 MiB,若
media.max_file_bytes更小则取更小的那个。一张 500 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。纯数字按 ID 处理,其他一律按链接处理。旁边的平台菜单只在输入纯 ID 时才起作用,输入链接时会被禁用,因为链接自带平台信息——而且如果两者同时给出且互相矛盾,API 会直接拒绝而不是去猜。
会发生三种情况,提示条会告诉你是哪一种:
- 已开始下载。 作品本来就在归档里,直接用已存的镜像生成计划并排队。响应是
202,带download_id和执行它的task_id。 - 正在先抓取再下载。 本实例从没见过这个作品。worker 会先通过和其他读取完全一样的身份池、调度器与请求日志把它抓下来,再下载抓回来的内容。首次响应会慢一些,好处是你不必先自己解析一遍。
- 已在下载中 / 已经下载过。 请求被一条已存在的下载记录接住了(
reused: "in_flight"或"already_stored",HTTP200)。合流到进行中的下载永远不是可选项,无论skip_existing怎么设:同一个作品的两次下载写进同一个目录,竞争中输的那一方会去重命名赢家已经挪走的文件,最后变成缺文件的partial。
短链(v.douyin.com/...、vm.tiktok.com/...)在这里会被拒绝,这是设计如此:解开短链意味着要去访问它,而这个接口不发起任何网络请求。把短链交给 POST /api/v1/parse——它会在后台展开——等你回来时作品已经归档好了。批量作品 模式可以直接接受短链,因为它走的是解析流水线。
如果作品已经归档但没有任何可下载的媒体,你会立刻拿到一个带原因的 400,而不是一分钟后失败的任务。
批量作品(批量解析并下载)
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 链接 |
short_link |
是 | 需要跟随跳转的分享链接;解析流水线会展开它 |
content_id |
否 | 一个不带平台信息的纯作品 ID |
bad_id |
否 | 看着像作品 ID 但不可能是——两个平台生成的 ID 高位就是它被签发时的那一秒 |
unknown |
否 | 这一行里没有抖音或 TikTok 链接 |
被拒绝的那几类在本地就给出答案,不会消耗任务,会以失败行的形式出现在表格里并附上原因。
提交是每块一次请求,而跟踪是逐行的。每一行从此各过各的日子——自己的状态、自己的错误、自己的重试——因为一个同步的批量接口会把一条失效链接抹成整批的一个错误。轮询间隔 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 “作者的最新作品”粘贴主页链接,或者资料库和调试台给你的那串纯 sec_user_id(两个平台写法一致,都以 MS4wLjABAAAA 开头)。下载多少个 菜单提供最新 20、50、100、200 个,或「feed 给多少取多少」。这里刻意用菜单而不是数字输入框:每个作品都是一次下载,feed 每翻一页都是一次走身份池的读取,而自由输入的深度会诱使人不假思索地敲一个「99999」。
页面会以每页 20 个的节奏翻 feed 直到取够,最多翻 40 页,然后为每个作品排一次下载。个别作品失败不会放弃其余的,提示条会报告「已排队 / 总数 / 本地已有」。
有一个坑值得知道:这个模式下平台菜单是禁用的,因为主页链接自带平台信息。如果你粘贴的是纯作者 ID,它会按菜单当时停留的那个平台去查——所以要关注 TikTok 的纯 secUid,请改为粘贴主页链接。
如果需要比 feed 一次能给出的更深的历史,请用资料库作品抽屉里的 回溯这位作者的历史,那是另一件事、另一种代价。
一个作品的评论
Section titled “一个作品的评论”通过普通读取接口最多翻 5 页、每页 50 条评论,然后把一个名为 comments-<platform>-<id>.json 的 JSON 文件交给你的浏览器。每一页都是一次走身份池的真实请求。评论是数据而不是媒体,所以服务器的媒体卷上不会写入任何东西,下载器也不参与。
| 列 | 含义 |
|---|---|
| 作品 | 已存封面的缩略图、归档里的标题,下方是作者与平台。归档对该作品一无所知时退回显示内容 ID |
| 状态 | queued、running、done、partial、failed 或 cancelled,运行中还会带一条实时进度条 |
| 平台 | douyin 或 tiktok |
| 文件数 | 成功完成的文件数量 |
| 大小 | 磁盘上的字节数;文件已被清理的行显示 已清理 |
| 完成时间 | 结束的时间 |
| 保留 | 置顶开关 |
| 重试 | 只在值得重试的已结束行上出现 |
partial 是一个独立状态,而不算成功也不算失败:视频落地了、第三张图 404 了的作品哪一头都不是——报成 done 会掩盖缺口,报成 failed 会掩盖磁盘上确实存在的那个视频。
按状态和平台的筛选发生在服务端,所以「最近的 TikTok 下载」指的是全部里最近的,而不是碰巧加载的那一页里最近的。排序则相反,是在浏览器里对已加载的行做的——默认按完成时间倒序。列表每次请求返回 100 行(GET /api/v1/downloads 默认 100,最大 500),控制台不对它分页,所以在数据量大的实例上,这张表是「最新一百条」的一扇窗。
有任务进行中时表格每 5 秒轮询一次,否则每 10 秒一次。进度条最多同时为 8 条进行中的行向 sidecar 实时读取,而且它统计的是文件数而不是字节:平台声明的大小 sidecar 并不采信——上限是按实际写入的字节强制的——所以没有一个可以拿来做分母的总量。旁边显示的是已传输字节数,那才是一直在变的数字。
点击一行会打开抽屉,里面有播放器、逐文件列表、每个文件的 SHA-256,以及每个已完成文件的保存链接。
重试,以及为什么没有断点续传
Section titled “重试,以及为什么没有断点续传”重试出现在 failed、partial 和 cancelled 的行上。它调用 POST /api/v1/downloads/retry 并带上 download_id,从头把同一个作品再走一遍;如果已存镜像太旧(超过 media.mirror_max_age_seconds,默认 600 秒),会先重新解析作品拿新链接。
没有断点续传,而且做出来也没有意义。sidecar 写入 <name>.part 并在每次尝试时清空它,原因在上游:媒体 URL 带签名且会过期,一小时前抓下来的字节接不到一条现在返回 403 的链接上。重新请求才是有效的做法。
下载还在进行时重试会被拒绝——第二次尝试会和第一次在同一个目录里打架。卡住的下载会在创建 两小时 后被维护任务判为失败(维护每五分钟跑一次),之后这一行就可以重试了。这个过程不会动磁盘上的任何东西:已经落地的文件原样保留。
置顶与容量上限
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。它不删除任何东西。已经完成的文件原样保留,而对一条已经结束的下载,它会原样返回状态。「下载」页面的批量 删除 按钮就是对每个仍有文件在磁盘上的选中行调用这个接口(文件已被清理的行会被跳过),所以对已经完成的行,它并不会释放任何字节——尽管确认对话框的文案是那样写的。
因此,真正会删掉已存文件的只有三条路:
- 在资料库里删除作品(它的批量删除),会一并删掉归档行、合集归属、下载记录和磁盘上的目录。
- 容量上限清理,针对一切未置顶的内容。取消置顶、让上限去回收它,是一种正当的腾空间方式。
- 上面说的重复项清理,针对冗余副本。
把已存文件保存到自己的电脑
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、控制台和任何其他客户端拿到的是同一个。
要注意它不是什么:服务器不会按需代理媒体。保存一个文件读的是已经落盘的那一份,而且要通过带认证的权限校验。「保存到本实例」和「保存到我的电脑」是两个不同的目的地,这也是控制台把它们分开命名的原因。
/library 就是归档:本实例解析过的全部内容,完全由本地存储回答。这里不消耗身份、不触发上游限流,作品即使已经被平台删除,也仍然在这里,并由「可用性」字段说明它的状态。这正是保留一份归档的全部意义。
顶部的指标显示作品总数、作者数、被标为 已消失(上游已删除)的数量,以及每个平台各一块。每块下面都带一行「已下载 N / M」,它同时是个链接:点击会把列表筛成媒体在本磁盘上的那些作品。「见过」和「留下了」是两个不同的数字,把前者当后者读,正是 66 个作品的归档被误以为是卷上 66 个视频的原因。
两种视图:
- 封面(默认)— 封面墙,带时长;已保存的作品还带一块体积标签。封面优先取本实例磁盘上的那份,没有才回退到平台的,这个顺序有讲究:归档里的
cover_url是会过期、还可能有防盗链的 CDN 链接。网格可以不分组、按作者分组,或按保存日期分组。 - 表格 — 可排序的列,用来回答网格答不了的问题:作品、类型、时长、可用性、发布时间、最后见到。
翻页用游标而不是页码,因为你翻的同时这张表还在被写入,用 offset 会静默地跳过和重复行。「上一页」是在你已经走过的游标里回退。每页 50 个作品。
选中状态按作品键保存,并且翻页不会丢失——这里选几个、那里选几个、然后一起处理,正是设计意图。只要有选中项,视口底部就会出现批量操作条。
筛选、搜索与视图
Section titled “筛选、搜索与视图”| 筛选 | 取值 | 生效时机 |
|---|---|---|
| 搜索 | 标题或描述的子串 | 提交时 |
| 平台 | douyin、tiktok |
改变即生效 |
| 类型 | 视频、图文 | 改变即生效 |
| 时长 | 一分钟以内(< 60 秒)、三分钟以内(< 180 秒)、长视频(≥ 180 秒)、未知 | 改变即生效 |
| 可用性 | 正常、已删除、已私密、未知 | 改变即生效 |
| 合集 | 你创建的任意合集 | 改变即生效 |
| 本地文件 | 下载与否不限 / 仅已下载 / 仅未下载 | 改变即生效 |
多个筛选条件可以叠加。菜单类的一改就生效,因为从一个封闭列表里选中「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)而不是 archive:read——读归档承诺不花任何代价,而重新检查是一次披着归档名字的平台读取。
删除作品及其媒体
Section titled “删除作品及其媒体”选中作品后点批量条里的 删除。这是归档里唯一一个真正销毁东西的调用:不可撤销、没有回收站。归档行会消失,合集归属随之消失;并且除非你在 API 里传 media: false,下载记录和磁盘上的目录也会被删除。
删除顺序是先删文件、后删记录。反过来会留下一条指向已不存在目录的记录——一个控制台提供得了、却交付不了的下载;而这个顺序最坏的结果是留下没有记录的字节,而存储面板本来就会汇报这一点。
如果作品有已存媒体而下载器不可达——没运行,或者没有响应——这个调用会拒绝执行并且什么都不删,而不是在无人能核对的卷上留下孤儿字节。如果你确实想删掉记录但保留文件,那就是对 POST /api/v1/archive/delete 传 media: false;控制台永远传 true。
单次调用最多 500 个作品,需要 media:write(或 admin):能发起下载和取消下载的那个权限范围,才能删除它产生的东西,比它更弱的都不行。
平台上的原作品不受影响。这里删的是你的那一份。
关注列表页面
Section titled “关注列表页面”/watchlist 是控制台里唯一会创建长期工作的地方。其他一切都是做一次就结束;而这里的一个条目,只要还存在,就会每隔几小时消耗一次身份池。整个页面都是围绕「让这个代价可见」设计的:间隔是一等公民的列、失败次数直接可见、全部暂停 是一个按钮而不是让你用逐行开关拼出来的东西。
它服务的是一张表:快照历史。在有东西按计划采集之前,那张表里装的只是「谁碰巧在什么时刻解析过什么」——一堆互不相关的散点,而不是一个序列。是它让增长曲线成为真的。
这个页面自己不抓取任何东西。条目到期时,worker 会把它作为普通任务提交:同一个队列、同一个调度器、同一个身份池,结束时同样写归档和快照。因此定时采集永远排在交互式请求之后——那边有人在等——而且它也跑不过限流,因为限流只有一套。
| 列 | 含义 |
|---|---|
| 目标 | 标签(首次成功运行后自动填入)或原始 ID,下方是类型和平台 |
| 间隔 | 多久运行一次 |
| 下次运行 | 相对时间,或 已暂停 |
| 运行次数 | 自加入以来采集到的观测次数 |
| 上次运行 | 相对时间,或一条红色的「连续失败 N 次」,鼠标悬停显示最后一次的错误 |
| 运行中 | 启用/暂停开关 |
| (无标题) | 移除 |
三块指标显示:目标总数与其中运行中的数量、总运行次数、当前失败中的数量。
添加一个目标
Section titled “添加一个目标”四个字段:平台、关注什么、目标 ID、间隔。
| 类型 | 目标 ID | 一次运行采集什么 |
|---|---|---|
| 一个作者 | 作者的稳定 ID——抖音是 sec_user_id,TikTok 是 secUid,两者都以 MS4wLjAB 开头 |
他作品列表的一页(最新 20 个)。那一页的每一项本来就带着作者记录,所以一次请求同时回答了「新增了什么」和「现在多少粉丝」 |
| 一个作品 | 作品 ID,和归档里保存的一致 | 该作品的详情,也就是它的数据随时间怎么变 |
作者条目里填 @handle 是不管用的。添加时没有任何地方校验 ID 的形状,所以条目会被创建——但之后每一次运行都会失败,因为作品列表接口没有任何能接受 handle 的参数,它会按名字拒绝,而不是把它送到一个根本用不了的地方。请先在调试台或资料库里查到这个作者,用结果里的 ID。同一个「平台 + 类型 + 目标」重复添加会被当作重复项拒绝。
首次运行是立即到期的。刚添加完一个作者的人想马上看到它采集,把首次运行按间隔往后推只会让这个功能在六小时里看起来是坏的。
一个条目采集什么,在这个页面上不可配置,这是有意的——原因见上表:一次请求就同时回答了两个问题。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 |
可用性重新检查与作者历史回溯,因为两者都消耗身份池 |
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 指南 — 这里每个按钮背后的接口。
- 故障排查 — 下载失败、关注条目持续失败,或磁盘被占满时怎么办。