关于 ttdl
ttdl 是通过浏览器播放捕获 TikTok 视频的命令行工具,适合需要保存视频或仅提取音频的用户。它记录播放器实际获得的媒体流,并用 ffmpeg 合并、裁剪而不重新编码。
主要功能
- 01下载 TikTok 视频
- 02仅保存音频为 M4A
- 03显示标题、作者、时长和分辨率
- 04支持短链接及纯数字视频 ID
- 05合并并裁剪媒体流而不重新编码
适用场景
- 01保存 TikTok 播放器播放的视频
- 02提取视频中的音频
- 03检查视频信息后决定是否下载
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ monotykamary/ browser-harness-js --skill ttdl安装后在 Claude Code 中输入/ttdl确认已加载。
附带文件scripts/setupscripts/ttdl
详细介绍
ttdl — 浏览器原生 TikTok 下载器
一个精简的 browser-harness-js heredoc,与 gsearch/xsearch/ytdl 完全相同。
没有 Bun 程序、没有随附的签名器、没有 HTTP 客户端伪装。
TikTok 为播放视频所做的一切复杂处理——签名 CDN URL、自适应画质提升、编辑列表——页面都已经在播放时完成。ttdl 只需录制结果。捕获的是干净、无水印的播放流:TikTok 应用内的“下载”按钮提供的是单独渲染的带水印文件,而录制 MediaSource 获取的正是播放器向观众展示的内容。
ttdl "https://www.tiktok.com/@user/video/7642721752497310989" # best → ~/Downloads
ttdl "https://www.tiktok.com/@user/video/7642721752497310989" -q audio # audio only (.m4a)
ttdl "https://www.tiktok.com/@user/video/7642721752497310989" --info # title / author / duration / resolution
ttdl "https://vm.tiktok.com/ZMxxxxx/" # short link (browser resolves the redirect)
ttdl 7642721752497310989 # bare numeric ID
ttdl "https://www.tiktok.com/@user/video/..." -o Name -d ~/Videos
画质目标
-q | 行为 | 需要 ffmpeg? |
|---|---|---|
best | 交由播放器选择(它会自动提升到所提供的最高画质),捕获、合并、裁剪并启用快速启动 | 是 |
audio | 仅保留音频缓冲区 → .m4a | 是 |
故意不提供 -q 720p/1080p。与 YouTube 不同,TikTok 的网页播放器没有公开可用的画质 API——它会根据连接状况自动选择,并逐步提升画质(在播放过程中创建新的、更高画质的 MediaSource;参见“工作原理”)。ttdl 会捕获每种类型中最大的内容,因此你总能获得播放器达到的最高画质。无法可靠地强制指定分辨率。
此处的 ffmpeg 仅用于合并和裁剪,绝不会重新编码(-c copy):
- 合并: TikTok 将音频和视频作为独立的 SourceBuffer 提供(例如,HEVC
hvc1.*视频 + AACmp4a.*音频),因此需要将它们合并成一个文件。 - 裁剪: 原始 MSE 媒体比播放器播放的内容更长(参见“为何要按时长裁剪”)。
- 快速启动: 捕获的流是分片 MP4;
-movflags +faststart会将moov移到文件开头,使文件可在任何地方跳转和流式播放。
输出保留 TikTok 自身的编解码器,纯净无损——HEVC 视频 + AAC 音频。这种组合兼容 QuickTime/iOS(iOS 可原生解码 HEVC+AAC),因此不同于 ytdl 的 AV1/Opus 输出,无需为 Apple 设备重新编码。
工作原理
全部写在一个 browser-harness-js <<EOF heredoc 中,采用 ytdl 风格:
- 连接到浏览器共享的 CDP 会话(或调用
session.connect())。 Page.addScriptToEvaluateOnNewDocument— 在任何页面 JS 运行之前注入 MSE hook:- 包装
MediaSource.prototype.addSourceBuffer(通过Object.defineProperty),为每个SourceBuffer标记其 编解码器和派生的类型。 - 在每个
SourceBuffer上定义一个自有的appendBuffer属性,在调用原始方法之前记录字节(解复用后的 ISO-BMFF 片段)。必须使用自有属性——SourceBuffer.prototype.appendBuffer是不可写的原生方法,因此重新赋值原型会静默无效。 - 类型根据
codecs=的 fourcc 派生,而非容器 MIME(见“陷阱”——这是与 ytdl 相比,TikTok 特有的最大差异)。
- 包装
- 将
Target.createTarget置于前台 → 对观看 URL 执行Page.navigate→ 等待networkIdle,然后轮询<video>;如果遇到验证/验证码拦截页则退出。必须置于前台:后台标签页的自动播放不稳定,而且如果播放器始终未启动,就不会向 MediaSource 输送数据。 - 从最终的(重定向后)
location.href读取作者和视频 ID——短链接(vm.tiktok.com/…、tiktok.com/t/…)此时已完成重定向。 - 从
currentTime = 0开始静音播放。与 ytdl 不同(16×,因为 YouTube 的 SABR 会在播放时获取数据,且播放速率会限制捕获速度),TikTok 的捕获由追加操作驱动:播放器会预先获取并追加整个 fMP4,因此在currentTime开始移动之前,buffered.end就会达到完整时长。所以播放速率对捕获来说只是表面设置——而 TikTok 的播放器在标签页处于前台时会将playbackRate重置为 1×,与之对抗毫无意义。ttdl 不设置playbackRate。不过它会在每次覆盖范围检查时重新设置muted:播放器可能在切换画质或重新初始化时取消静音,导致捕获过程中突然播放声音。 - 等待已观看部分完成缓冲,且追加活动趋于静止(
buffered.end >= duration-0.5,并且捕获的总字节数连续 2 次检查未变),然后原子地冻结捕获并暂停。__capDone标志会在覆盖范围完成的瞬间,让 hook 继续传递追加操作(停止录制),避免 TikTok 自动循环(会重新向 MSE 输送数据)污染捕获的缓冲区。 - 选取最大的 video 缓冲区和最大的 audio 缓冲区。 播放器通常会创建两个 MediaSource 实例——一个初始的低画质实例,随后在画质提升时创建一个更高画质的实例——从而产生 4 个缓冲区(2 个 video、2 个 audio)。每种类型中最大的缓冲区才是真正捕获的内容;较小的缓冲区属于已弃用的低画质实例。
- 分块写入磁盘,每块 256 KB(字节数可被 3 整除,因此每个 base64 分片都可独立解码):页面端的
__pullBuffer(i,offset,len)返回 base64;REPL 使用Buffer.from(b64,'base64')解码,并通过fs.appendFileSync追加写入。使用 256 KB,而不是 4 MB——见下文“陷阱”:第一次调用时,携带 4 MB 分片 base64 的单个returnByValue帧会关闭 Dia 的调试 WebSocket。 - ffmpeg
-i video -i audio -t <duration> -c copy -movflags +faststart out.mp4(heredoc 返回临时文件路径后,在 bash 中执行)。 - 在
try/finally中以 fire-and-forget 方式调用closeTab——与 ytdl 的清理流程完全一致。
为什么在 MediaSource 捕获,而不是从 CDN URL 下载
TikTok 会通过页面生成并与会话绑定的签名 CDN URL 提供每条视频
(v16-webapp-prime.tiktok.com/video/tos/…?a=…&bti=…&ft=…&x-expires=…&x-signature=…)。
签名就在 URL 中,因此简单的 fetch(url) 在页面仍然有效时可能可行,但 URL
有效期很短,响应可能受范围限制,而且你得逆向分析 TikTok 的播放器才能找到它。在
SourceBuffer.appendBuffer 处捕获可以绕过这些问题:无论播放器获取了什么,也无论它如何签名,解复用后的字节都会明文流经 MSE。这种方式不依赖身份验证、签名或画质——页面已经处理好这一切;我们只需记录输出。这其实就是“观看 → 录制”,与 ytdl 使用的模式相同。
为什么要按时长裁剪
TikTok 提供的 fMP4 长于播放器实际播放的内容。<video>.duration(以及 seekable/buffered 范围)反映的是剪辑列表中的播放时长,例如 27.8 s;而原始 MSE 媒体约为 30.4 s。播放器播放 0 → 27.8 s,然后循环回到 0;尾部约 2.6 s 是真实画面,但播放器会刻意将其排除在信息流之外。如果你原样捕获所有 MSE 字节(ytdl 模式),得到的视频会比用户观看的长约 10%,并带有用户从未看到的尾部内容。
因此,ttdl 会将混流输出裁剪到 <video>.duration(ffmpeg -t <dur> -c copy)。输出与用户实际观看的内容一致。覆盖率锁存器中的静默保护(步骤 6)让我们可以安全地从可能捕获了完整尾部、也可能没有的捕获结果中进行裁剪:buffered.end >= dur-0.5 可确保已观看部分全部追加完成,无论尾部捕获了多少,裁剪都会丢弃多余尾部。
文件
所有路径均相对于 <skill-dir>。
scripts/ttdl— bash CLI(使用browser-harness-jsheredoc,不含#!bun)scripts/setup— 将ttdl和browser-harness-js符号链接到 PATH- (没有
lib/— 没有求解器/客户端脚手架)
陷阱
- 始终根据
codecs=中的编解码器对 SourceBuffer 分类,绝不能根据容器 MIME 类型分类。 TikTok 会把音频放在容器为video/mp4的 SourceBuffer 中——只有codecs="mp4a.40.29"参数才能表明它是音频。简单地使用mime.startsWith("video") ? "video" : "audio"(ytdl 的做法)会把 TikTok 的两个缓冲区都标记为video,然后选中最大的缓冲区(真正的视频),并把音频缓冲区当作“较小的视频”丢弃,导致捕获结果没有声音。ttdl 会解析 fourcc 前缀(mp4a→音频,hvc1/avc1/av01→视频),只有在没有codecs=时才回退到容器 MIME 类型。请保留此逻辑——这是让 TikTok 捕获正常工作的关键一行。 - MSE 钩子必须通过
Page.addScriptToEvaluateOnNewDocument注入,而不是在页面加载后调用Runtime.evaluate。 播放器启动时会获取MediaSource/addSourceBuffer的引用;只有在页面运行任何 JS 之前注入,才能及时对它们进行修补。钩子在主 world 中运行(不设置worldName),播放器也在此处运行。 - 将
appendBuffer作为每个SourceBuffer实例的自有属性进行修补,绝不要修改原型。SourceBuffer.prototype.appendBuffer是不可写的原生方法——在原型上执行sb.appendBuffer = fn会静默无效;Object.defineProperty(SourceBuffer.prototype, 'appendBuffer', …)也无法生效。在实例上定义自有属性可以正确遮蔽原型方法。(可以通过defineProperty修补addSourceBuffer的原型——它是可写的。) - 使用前台标签页。 TikTok 在后台标签页中的自动播放并不可靠;如果播放器没有启动,MediaSource 就不会收到数据,捕获结果也会是空的。使用
createTarget({ url: 'about:blank' })(不要设置background: true)。 - 将输出截取到
<video>.duration。 TikTok 原始 MSE 媒体的时长比实际播放(编辑列表所列)的时长更长;如果不使用-t <dur>,封装后的文件末尾会多出一段用户从未看到的内容(参见“为何要截取到媒体时长”)。静默判定闩锁可确保截取前已完整捕获观看部分,因此这样做始终安全。 - 覆盖范围由 append 驱动,但要在静默时锁存,不能只看
buffered.end。 TikTok 会预先追加整个 fMP4,因此捕获只需几秒即可完成——但即使更长的原始媒体仍在不断追加,buffered.end也会封顶在编辑列表所列的时长。仅在end >= dur-0.5时锁存,可能会在字节仍持续流入时触发。ttdl 还要求已捕获字节总量连续 2 个检测周期(500 ms)保持不变,才会退出。锁存时冻结的__capDone可防止自动循环再次追加数据到缓冲区。 - 每种类型都选择最大的缓冲区——播放器在画质提升时会重新初始化 MediaSource。 TikTok 会在播放中途提高画质(例如从 HEVC
hvc1.1.6.L186切换到hvc1.1.6.L150),从而创建第二个 MediaSource,并带有自己的音频和视频 SourceBuffer。你会看到 4 个缓冲区(2 个视频、2 个音频);每种类型中最大的缓冲区才是真正完整的捕获内容,较小的一对属于被弃用的低画质版本。不要假定每种类型只有一个缓冲区。 - TikTok 会自动循环播放;绝不要依赖
ended。<video>会循环回到 0,而不是触发ended,因此基于ended的闩锁会无限运行。__capDone标志负责阻止循环播放产生的重复追加,以免捕获内容翻倍。(为处理不循环的边缘情况,仍保留了ended检查作为安全措施,但真正的退出条件是静默加覆盖范围闩锁。) - 每次轮询都重新设置
muted(但不要设置playbackRate)。 TikTok 播放器可能会在切换画质或重新初始化时取消静音;播放开始时只设置一次muted = true会被覆盖,导致捕获过程中听到声音。覆盖范围轮询每隔 250 ms 就会重新设置muted。不要重新设置playbackRate:捕获由 append 驱动(整个文件会在播放进度推进前就缓冲完成),因此播放速率只是表面设置;而且 TikTok 在标签页切回前台时会将playbackRate重置为 1×,与之对抗毫无意义。ytdl 会重新设置为 16×,因为 YouTube 的覆盖范围受播放进度限制(SABR 会边播放边抓取);ttdl 则不受此限制,因此两者有意采用不同做法。 - 在覆盖范围达到后再读取标题,并设置
data-e2e=video-desc作为回退。 在 SRM/页面水合完成之前(需要几秒),document.title都是通用标题"TikTok - Make Your Day"。如果在networkIdle后立即读取,得到的就是这个通用标题。ttdl 会在覆盖范围循环完成后读取标题;如果标题仍是通用标题,则回退到[data-e2e=video-desc]元素的文本。 - 检测“验证你是人类”/验证码拦截页,并停止重试。 TikTok 对机器人检测十分严格。ttdl 会在播放器就绪轮询期间扫描
document.body.innerText中是否包含verify|robot|human|captcha|unusual|slide to|puzzle,如果命中,就抛出清晰的错误。先在浏览器中手动打开一次 URL 以解除拦截,再重试——不要反复轰炸。 - 访问受限内容时,借用已登录的标签页。 地区锁定、年龄限制和仅限关注者观看的视频可能需要有效的 TikTok 登录。ttdl 会打开自己的标签页,但会复用浏览器的 Cookie 存储;如果你能在浏览器中观看,ttdl 就能录制。
- 始终需要
ffmpeg。 与 ytdl(只有封装 HD 视频时才需要 ffmpeg)不同,ttdl 每次下载都需要它:TikTok 会分别提供音频和视频缓冲区(需要封装)、编辑列表尾部(需要截取),还要捕获分片 MP4(需要 faststart)。如果ffmpeg不在 PATH 中,CLI 会拒绝运行。不要删掉-y(覆盖)——否则 ffmpeg 的交互式Overwrite? [y/N]提示会在非交互模式下失败,并留下过期文件。 - 字节会通过 CDP 边界以 base64 形式传输:通过
Runtime.evaluate的returnByValue,以 256 KB(3 的倍数)为单位切片,使用Buffer.from(b64,'base64')解码后再追加。切片大小可被 3 整除,因此每个切片的 base64 都能单独解码(内部不会出现=填充)。不要从非 3 的倍数偏移处切片,否则拼接后的内容会解码为乱码。必须是 256 KB,不能是 4 MB: 单个returnByValue帧携带 4 MB 切片的 base64 时,一个 CDP 帧中的 JSON 大约有 5.6 MB,会在首次调用时关闭 Dia 的调试 WebSocket(已在 ytdl 中复现:同一缓冲区的 256 KB 切片可以正常传输,而 4 MB 切片会立即导致rs→3)。这个潜在问题在 ttdl 中一直未暴露,因为 TikTok 缓冲区通常小于 4 MB;但较长或较高画质的 HEVC TikTok 视频会超过此大小,首次拉取切片就会导致套接字断开。请使用较小切片;在这些大小下,吞吐量不是问题。 - 输出为 HEVC + AAC,可直接用于 QuickTime/iOS。 TikTok 通常提供 HEVC(
hvc1)视频和 AAC(mp4a)音频;-c copy会保留这些编码,iOS/macOS 可以原生播放(无需重新编码,不同于 ytdl 的 AV1/Opus)。如果视频下载后使用 AV1/VP9+Opus,而你需要 QuickTime/iOS 格式,可一步重新编码:ffmpeg -y -i "out.mp4" -c:v libx264 -crf 18 -preset veryfast -pix_fmt yuv420p -c:a aac -b:a 192k "out-qt.mp4"。 - heredoc 正文中的任何位置都不能出现反引号。 在未引用的
<<EOF中,单个反引号会开启 bash 命令替换并触发 EOF 解析错误。请使用字符串拼接('...' + var + '...')和双引号 JS 字符串,绝不要使用模板字面量。同样要避免在 JS 中使用$(只允许有意使用${bash_var}插值),并避免使用反斜杠正则解析 URL(改用indexOf/slice),这样未引用的 heredoc 才会原样传递内容。