跳转到内容

下载、资料库与关注列表

这一篇讲的是「留下来」而不只是「看一眼」:把视频和图片存到你自己的磁盘上、几个月后还能找回来、以及按定时反复采集同一个目标。读完之后你会清楚这三个页面各自的代价、字节到底落在哪里、容量上限会拿走什么,以及怎样保住那个你真正舍不得丢的作品。

控制台把「我见过什么」「我手上留着什么」「我还在持续采集什么」拆成了三个页面,这个拆分是有原因的:第一件事免费,第二件事会占满磁盘,第三件事会一直消耗身份池。

页面 路径 存的是什么 代价
资料库 /library 归档:本实例解析过的每一个作品各一行——标题、作者、标签、媒体清单 没有代价。它只查 Postgres,不消耗身份、不发起任何请求
下载 /downloads 下载索引:每一次「保存某作品媒体」的请求各一行,以及实际落盘的文件 磁盘;如果作品还没抓过,还要额外一次走身份池的请求
关注列表 /watchlist 长期指令:每隔 N 小时采集这个作者或这个作品,永远执行下去 只要条目还在,就按计划持续消耗身份池

三个页面都在侧边栏的 工具 分组下。一个作品可以只在归档里而媒体不在磁盘上(这是常态);反过来,下载记录也可以比归档行活得更久——即使作品后来被从资料库里删了,下载表里的那一行描述的仍然是媒体卷上一个真实的目录。

真正搬运字节的是一个独立容器:downloader sidecar。它是一个需要显式开启的 Compose profile;没有它的实例是「配置正确」而不是「有问题」——归档、API、控制台和关注列表都照常工作,缺的只有抓取文件这一半。

在仓库根目录启动它:

终端窗口
echo 'DTK_DOWNLOADER_URL=http://downloader:9100' >> .env
docker compose -p dtk -f docker/compose.yml --profile downloader up -d --build

没有设置 DTK_DOWNLOADER_URL 时,POST /api/v1/downloads 会返回 501NOT_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_KEYDTK_DATABASE_URLDTK_REDIS_URL 置空。它从来拿不到 Cookie、代理、API Key 或数据库地址;它收到的只有一份镜像 URL 列表、对应平台的主机白名单,和一个字节上限。

它自己的参数,全部从 .env 读取:

变量 默认值 作用
DTK_DOWNLOADER_URL (空) apiworker 读取。留空即完全关闭媒体下载
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 之后,它已经忘掉的那些任务的实时进度条会消失,那一行就不画进度条了。持久记录是在任务结束时写入的,所以除了进度条本身,什么都不会丢。

文件写入名为 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.json

meta.json 写在文件旁边,正是它让两年后翻出来的这个文件夹仍然说得清自己是什么。它包含平台、作品 ID、类型、web_url、标题、描述、发布时间、时长、作者 uid 与昵称、音乐、标签、位置以及文件列表。它刻意不包含任何带签名的 CDN 链接——那些几小时内就会过期,会是整个文件里唯一会误导人的部分。

在抓取任何东西之前,规划器就已经决定好的事:

  • 每个作品只存一个视频。 平台给出的所有码率都被当作同一个文件的镜像——先落地的那个赢。把同一段视频存四份不是功能,是把磁盘塞满。解析器给出的顺序会被保留,而它把无水印的流排在最前。
  • 只存一张封面,最后添加,且仅在作品还没达到文件数上限时才加。
  • 每个作品最多 64 个文件。抖音图文上限大约是 35 张;这个上限是防止畸形数据变成上千项的任务,而不是给正常内容设的门槛。
  • 图片单个上限 32 MiB,若 media.max_file_bytes 更小则取更小的那个。一张 500 MB 的「图片」说明出了问题,而不是照片很大。
  • 只在认识的时候才从 URL 取扩展名——视频认 mp4movwebmm4v,图片认 jpgjpegpngwebpheicgif——否则用该类型的默认扩展名。一个以 .php 结尾的查询串不可能变成你磁盘上的文件名。
  • 每个镜像都要先过媒体主机白名单,任务才会存在;被丢掉的镜像会作为 skipped 原因回报给你。

正在传输的文件写作 <name>.part,完成后才改名就位,所以一个名字出现时,它背后的文件一定是完整的。.part 存在期间也计入媒体卷总量。

「下载」页面顶部是一排模式标签。每个模式接受的输入形状不同,这正是它们不能共用一个提交按钮的原因——作品链接和主页链接是两回事,一个会猜的表单会悄悄下载错东西。

模式 接受什么 结果
单个作品 一条分享链接、包含链接的整段分享文案,或纯作品 ID 该作品的媒体存到服务器的媒体卷上
批量作品 一次粘贴很多条链接 每条链接一条解析结果,之后可一次性全部保存
评论 作品链接或 ID 一个 JSON 文件,下载到你的浏览器——评论是数据,不是媒体
作者的作品 主页链接或纯作者 ID 为该作者 feed 中的每个作品各排一次下载

跳过已下载的内容 这个勾选框由所有「可能作用于多个对象」的模式共用(除评论外都算)。它会直接返回本实例已有的那一份,而不是重新抓取——重跑一次 feed 时想要的正是这个:作者新发了三个作品,另外四十个早就在本地了。已被清理掉的副本不算,那里已经没有字节可跳过。

粘贴链接或作品 ID。纯数字按 ID 处理,其他一律按链接处理。旁边的平台菜单只在输入纯 ID 时才起作用,输入链接时会被禁用,因为链接自带平台信息——而且如果两者同时给出且互相矛盾,API 会直接拒绝而不是去猜。

会发生三种情况,提示条会告诉你是哪一种:

  • 已开始下载。 作品本来就在归档里,直接用已存的镜像生成计划并排队。响应是 202,带 download_id 和执行它的 task_id
  • 正在先抓取再下载。 本实例从没见过这个作品。worker 会先通过和其他读取完全一样的身份池、调度器与请求日志把它抓下来,再下载抓回来的内容。首次响应会慢一些,好处是你不必先自己解析一遍。
  • 已在下载中 / 已经下载过。 请求被一条已存在的下载记录接住了(reused: "in_flight""already_stored",HTTP 200)。合流到进行中的下载永远不是可选项,无论 skip_existing 怎么设:同一个作品的两次下载写进同一个目录,竞争中输的那一方会去重命名赢家已经挪走的文件,最后变成缺文件的 partial

短链(v.douyin.com/...vm.tiktok.com/...)在这里会被拒绝,这是设计如此:解开短链意味着要去访问它,而这个接口不发起任何网络请求。把短链交给 POST /api/v1/parse——它会在后台展开——等你回来时作品已经归档好了。批量作品 模式可以直接接受短链,因为它走的是解析流水线。

如果作品已经归档但没有任何可下载的媒体,你会立刻拿到一个带原因的 400,而不是一分钟后失败的任务。

这个模式会把单行表单整个替换掉,换成一个文本框、它自己的结果表格和自己的工具栏。一次最多粘贴 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。列为 urlstateerror_codeplatformkindcontent_idweb_urltitleauthorauthor_uidcreated_atduration_msplay_countdigg_countcomment_countshare_countcollect_countvideo_url。含逗号、引号或换行的单元格会加引号,并且以 =+-@ 开头的值前面会补一个单引号,防止表格软件把它当公式执行。
  • 导出 JSON — 同样这些行,但带完整结果载荷,保存为 dtk-parse-<时间戳>.json
  • 重试失败项(N) — 只重试错误码不属于永久性错误的行。一条死链不值得再消耗一个身份。
  • 清空 — 清空表格。服务器上什么都不会变;这些行是浏览器里的状态,刷新页面就没了。

抽屉里的下载按钮和「保存到本实例」是两回事:它们从平台 CDN 直接抓到你的浏览器,文件落在你自己的下载文件夹里,服务器全程不中继媒体。镜像会按顺序逐个尝试。有些 CDN 会拒绝跨域读取,当所有镜像都拒绝时,第一个镜像会在新标签页打开并给出提示——那是 CORS 或防盗链,不是链接失效。

粘贴主页链接,或者资料库和调试台给你的那串纯 sec_user_id(两个平台写法一致,都以 MS4wLjABAAAA 开头)。下载多少个 菜单提供最新 20、50、100、200 个,或「feed 给多少取多少」。这里刻意用菜单而不是数字输入框:每个作品都是一次下载,feed 每翻一页都是一次走身份池的读取,而自由输入的深度会诱使人不假思索地敲一个「99999」。

页面会以每页 20 个的节奏翻 feed 直到取够,最多翻 40 页,然后为每个作品排一次下载。个别作品失败不会放弃其余的,提示条会报告「已排队 / 总数 / 本地已有」。

有一个坑值得知道:这个模式下平台菜单是禁用的,因为主页链接自带平台信息。如果你粘贴的是纯作者 ID,它会按菜单当时停留的那个平台去查——所以要关注 TikTok 的纯 secUid,请改为粘贴主页链接。

如果需要比 feed 一次能给出的更深的历史,请用资料库作品抽屉里的 回溯这位作者的历史,那是另一件事、另一种代价。

通过普通读取接口最多翻 5 页、每页 50 条评论,然后把一个名为 comments-<platform>-<id>.json 的 JSON 文件交给你的浏览器。每一页都是一次走身份池的真实请求。评论是数据而不是媒体,所以服务器的媒体卷上不会写入任何东西,下载器也不参与。

含义
作品 已存封面的缩略图、归档里的标题,下方是作者与平台。归档对该作品一无所知时退回显示内容 ID
状态 queuedrunningdonepartialfailedcancelled,运行中还会带一条实时进度条
平台 douyintiktok
文件数 成功完成的文件数量
大小 磁盘上的字节数;文件已被清理的行显示 已清理
完成时间 结束的时间
保留 置顶开关
重试 只在值得重试的已结束行上出现

partial 是一个独立状态,而不算成功也不算失败:视频落地了、第三张图 404 了的作品哪一头都不是——报成 done 会掩盖缺口,报成 failed 会掩盖磁盘上确实存在的那个视频。

按状态和平台的筛选发生在服务端,所以「最近的 TikTok 下载」指的是全部里最近的,而不是碰巧加载的那一页里最近的。排序则相反,是在浏览器里对已加载的行做的——默认按完成时间倒序。列表每次请求返回 100 行(GET /api/v1/downloads 默认 100,最大 500),控制台不对它分页,所以在数据量大的实例上,这张表是「最新一百条」的一扇窗。

有任务进行中时表格每 5 秒轮询一次,否则每 10 秒一次。进度条最多同时为 8 条进行中的行向 sidecar 实时读取,而且它统计的是文件数而不是字节:平台声明的大小 sidecar 并不采信——上限是按实际写入的字节强制的——所以没有一个可以拿来做分母的总量。旁边显示的是已传输字节数,那才是一直在变的数字。

点击一行会打开抽屉,里面有播放器、逐文件列表、每个文件的 SHA-256,以及每个已完成文件的保存链接。

重试,以及为什么没有断点续传

Section titled “重试,以及为什么没有断点续传”

重试出现在 failedpartialcancelled 的行上。它调用 POST /api/v1/downloads/retry 并带上 download_id,从头把同一个作品再走一遍;如果已存镜像太旧(超过 media.mirror_max_age_seconds,默认 600 秒),会先重新解析作品拿新链接。

没有断点续传,而且做出来也没有意义。sidecar 写入 <name>.part 并在每次尝试时清空它,原因在上游:媒体 URL 带签名且会过期,一小时前抓下来的字节接不到一条现在返回 403 的链接上。重新请求才是有效的做法。

下载还在进行时重试会被拒绝——第二次尝试会和第一次在同一个目录里打架。卡住的下载会在创建 两小时 后被维护任务判为失败(维护每五分钟跑一次),之后这一行就可以重试了。这个过程不会动磁盘上的任何东西:已经落地的文件原样保留。

这是会删东西的部分,值得在需要它之前先读一遍。

已存媒体的总量由 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。它不删除任何东西。已经完成的文件原样保留,而对一条已经结束的下载,它会原样返回状态。「下载」页面的批量 删除 按钮就是对每个仍有文件在磁盘上的选中行调用这个接口(文件已被清理的行会被跳过),所以对已经完成的行,它并不会释放任何字节——尽管确认对话框的文案是那样写的。

因此,真正会删掉已存文件的只有三条路:

  1. 在资料库里删除作品(它的批量删除),会一并删掉归档行、合集归属、下载记录和磁盘上的目录。
  2. 容量上限清理,针对一切未置顶的内容。取消置顶、让上限去回收它,是一种正当的腾空间方式。
  3. 上面说的重复项清理,针对冗余副本。

已存文件可以交给你的浏览器。在下载抽屉里,每个已完成文件都有一个 保存这个文件 链接;在资料库抽屉里,则是 导出视频文件 以及每张已存图片、封面各一个链接。

这走的是 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 个作品。

选中状态按作品键保存,并且翻页不会丢失——这里选几个、那里选几个、然后一起处理,正是设计意图。只要有选中项,视口底部就会出现批量操作条。

筛选 取值 生效时机
搜索 标题或描述的子串 提交时
平台 douyintiktok 改变即生效
类型 视频、图文 改变即生效
时长 一分钟以内(< 60 秒)、三分钟以内(< 180 秒)、长视频(≥ 180 秒)、未知 改变即生效
可用性 正常、已删除、已私密、未知 改变即生效
合集 你创建的任意合集 改变即生效
本地文件 下载与否不限 / 仅已下载 / 仅未下载 改变即生效

多个筛选条件可以叠加。菜单类的一改就生效,因为从一个封闭列表里选中「douyin」这件事本身就是决定;而文本框要等按钮,因为在你停下来之前打字还不算决定,每敲一个键就发一次查询等于在拼一个词的路上把整个归档翻一遍。

搜索是按子串匹配而不是按词匹配,这是一个有意的取舍而非疏漏:Postgres 的全文检索不会切分中文,按词搜索会在主力平台上静默地匹配不到任何东西。代价是搜一个很短的串会命中比你想要的更多的内容。

「本地文件」描述的是留下了什么,而不是作品本身:已被清理的下载不算已下载,因为已经没有东西可播了。「清空」旁边的计数会告诉你有几个筛选条件正在收窄列表,而这通常就是「为什么 660 个作品的资料库只显示了 4 个」的答案。

合集是你手动分出来的一组作品。它是资料库里唯一不从作品本身推导出来的分组方式——作者、平台、时长、保存日期都来自记录本身,只有这一个来自你的决定。

点击「清空」旁边的 合集 可以创建、查看和删除它们。名称最长 80 个字符,且大小写不敏感地唯一(由数据库保证,所以两个控制台标签页不会竞争成功);可选的备注最长 500 个字符,且永远不会被解析。

选中作品后用批量条里的 加入合集 添加。从当前合集移出 只在你正按某个合集筛选时才出现——否则「从哪个合集移出」没有答案。把已经在合集里的作品再加一次是空操作而不是错误,不在归档里的作品会被跳过,所以一张过期的卡片不会连累另外十九张。单次调用最多 500 个作品。

删除合集只删掉这个标签。作品仍留在归档中,文件也仍在磁盘上。这是本页唯一一个不碰任何作品、任何字节的删除操作。

点开一个作品会打开抽屉,底部有三个操作:

  • 回溯这位作者的历史 — 翻过第一页,把这位作者的历史作品全部归档。它和关注列表刻意分开:关注列表关注的是「新增了什么」并且永远运行下去,而回溯是一次性的、代价完全不同的任务。控制台使用服务端默认的 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 变成一份数据库副本的调用,所以刻意单独划成一个权限范围。控制台会话带全部权限范围,因此任何已登录用户点这个按钮都能用。
  • 导出会应用平台、类型、时长、可用性和搜索这几个筛选,但不会应用合集筛选和「本地文件」筛选——它们不是导出接口的参数,会被忽略。在合集筛选生效时导出,你拿到的是整个归档,而不是那个合集。

要备份整个实例(包含身份和设置),见运维里的备份工具;这里的导出只涵盖归档。

页面标题栏的 重新检查 回答的是「我保存的东西里哪些已经没了」。每个作品都是一次走身份池的真实请求,所以这个过程作为后台任务运行,并且有明确边界。

默认值都是可改的设置项:每次检查 archive.recheck_batch 个作品(25,硬上限 200),只检查上次检查距今超过 archive.recheck_after_days7)天的作品,每次请求之间间隔 archive.recheck_pause_seconds3)秒。这个间隔是实测出来而不是拍出来的:设成 0.5 秒时,一次真实的扫描在 13 秒内发了 20 个 TikTok 请求,全部被拒,而一分钟后手动发的单个请求却成功了——调度器是按身份限速的,这对一个按地址计数的平台限制毫无作用。没有人在等这个扫描,所以慢是免费的。

已删除的作品会返回平台自己的 not-found,而那就是结论,不是失败:记录会保留并标记为 deleted,于是它仍然可搜索、可导出,并且带着真相。私密作品标记为 private。其他任何错误都不会改动这一行,因为一个风控响应说明不了作品是否还存在。如果扫描途中某个接口的熔断器打开,扫描会提前停止,而不是把剩下的都标成未检查。

worker 大约每六小时自己排一次重新检查,所以这个按钮是给「我现在就要答案」用的。把 archive.recheck_after_days 设为 0 会彻底关闭重新检查。

因为它会消耗身份池,这个接口要求的是平台读取权限范围(douyin:readtiktok:read)而不是 archive:read——读归档承诺不花任何代价,而重新检查是一次披着归档名字的平台读取。

选中作品后点批量条里的 删除。这是归档里唯一一个真正销毁东西的调用:不可撤销、没有回收站。归档行会消失,合集归属随之消失;并且除非你在 API 里传 media: false,下载记录和磁盘上的目录也会被删除。

删除顺序是先删文件、后删记录。反过来会留下一条指向已不存在目录的记录——一个控制台提供得了、却交付不了的下载;而这个顺序最坏的结果是留下没有记录的字节,而存储面板本来就会汇报这一点。

如果作品有已存媒体而下载器不可达——没运行,或者没有响应——这个调用会拒绝执行并且什么都不删,而不是在无人能核对的卷上留下孤儿字节。如果你确实想删掉记录但保留文件,那就是对 POST /api/v1/archive/deletemedia: false;控制台永远传 true

单次调用最多 500 个作品,需要 media:write(或 admin):能发起下载和取消下载的那个权限范围,才能删除它产生的东西,比它更弱的都不行。

平台上的原作品不受影响。这里删的是你的那一份。

/watchlist 是控制台里唯一会创建长期工作的地方。其他一切都是做一次就结束;而这里的一个条目,只要还存在,就会每隔几小时消耗一次身份池。整个页面都是围绕「让这个代价可见」设计的:间隔是一等公民的列、失败次数直接可见、全部暂停 是一个按钮而不是让你用逐行开关拼出来的东西。

它服务的是一张表:快照历史。在有东西按计划采集之前,那张表里装的只是「谁碰巧在什么时刻解析过什么」——一堆互不相关的散点,而不是一个序列。是它让增长曲线成为真的。

这个页面自己不抓取任何东西。条目到期时,worker 会把它作为普通任务提交:同一个队列、同一个调度器、同一个身份池,结束时同样写归档和快照。因此定时采集永远排在交互式请求之后——那边有人在等——而且它也跑不过限流,因为限流只有一套。

含义
目标 标签(首次成功运行后自动填入)或原始 ID,下方是类型和平台
间隔 多久运行一次
下次运行 相对时间,或 已暂停
运行次数 自加入以来采集到的观测次数
上次运行 相对时间,或一条红色的「连续失败 N 次」,鼠标悬停显示最后一次的错误
运行中 启用/暂停开关
(无标题) 移除

三块指标显示:目标总数与其中运行中的数量、总运行次数、当前失败中的数量。

四个字段:平台、关注什么、目标 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_seconds6 小时)。

下限是 watchlist.min_interval_seconds,默认 900 秒,而且无论怎么改都不能低于 60 秒。低于下限的间隔会被拒绝,而不是被悄悄抬高到一个你没要求过的数字。理由不是客气:每几秒采集一次的目标不会产生更好的时间序列,只会把整个身份池烧在一个作者身上,把其他所有事饿死。两个平台上都没有任何东西变化得快到需要短于 15 分钟。

代价,方便你估算:一个条目按 6 小时计,一天 4 次请求。一百个作者按 6 小时计,一天 400 次请求,每次一页。缩短间隔会立即生效,而不是等旧的间隔走完。

退避作用在条目上,而不是任务上。连续 2 次失败之后,每多失败一次间隔翻一倍,上限 8 小时。否则一个 ID 敲错的作者会永远每次运行都失败;这样一来它变成一天试几次,而那一行连同它的错误仍然可见,留给来修它的人。平台故障总会结束,所以它仍然会被重试。重新启用一个暂停的条目会清掉退避和错误并让它立刻到期——你在断言问题已经解决,而让你为了验证这一点再等八小时,本身就是个 bug。

还有两条边界值得知道:

  • watcher 每分钟走一轮,每轮最多排 watchlist.batch_size10)个到期条目。其余仍然到期,留到下一轮,于是同一分钟到期的一百个条目会被摊到几分钟里,而不是一次性堵在正在用 API 的人前面。
  • 下次运行是从提交时刻起算的,而不是从完成时刻。否则一个永远跑不完的任务会让这个条目永远停住,而「每六小时采集一次」即使丢了一次运行也应该继续成立。
  • 超过容量硬停线后,定时采集完全停下。没有人在等它,而让这些写入方停下正是护栏存在的意义。

三个不同的开关,作用范围由小到大:

操作 效果 如何恢复
行上的 运行中 开关 暂停该条目。它的历史、运行次数和标签都保留 再打开即可;退避和最后一次错误会被清除,并立刻到期
标题栏的 全部暂停 一次性停用所有启用中的条目。这是「出事了,我要把身份池要回来」那个按钮 只能逐个恢复,这是刻意的——没有「全部恢复」
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 一轮最多排队多少个到期条目

这三个页面背后的 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 的人。
  • 配置参考 — 这里提到的每一个设置项,以及怎么修改。
  • 核心概念 — 这些页面所消耗的身份池、调度器和任务队列。
  • 控制台总览 — 其余页面以及它们之间的关系。
  • 调试台与工具 — 解析单条链接、给请求签名,以及本页输入框用到的 URL 识别器。
  • 用户与 API 密钥 — 用正确的权限范围签发一个 Key。
  • 运维 — 告警、通知渠道、磁盘与备份。
  • REST API 指南 — 这里每个按钮背后的接口。
  • 故障排查 — 下载失败、关注条目持续失败,或磁盘被占满时怎么办。