关于 ytdl
ytdl 是基于 browser-harness-js 的 YouTube 下载命令行工具,借助网页播放器播放并捕获媒体流,再用 ffmpeg 合并音视频。适用于按指定画质、最佳可用画质或仅音频保存视频。
主要功能
- 01从 YouTube 网页播放器捕获媒体
- 02支持多种画质及仅音频模式
- 03查看标题、时长和可用画质
- 04以 ffmpeg 合并音视频流
- 05可指定输出名称和目录
适用场景
- 01将 YouTube 视频保存到本地
- 02下载指定画质的视频
- 03仅提取视频音频
- 04查看视频信息后选择画质
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ monotykamary/ browser-harness-js --skill ytdl安装后在 Claude Code 中输入/ytdl确认已加载。
附带文件scripts/setupscripts/ytdl
详细介绍
ytdl — 浏览器原生 YouTube 下载器
一个精简的 browser-harness-js heredoc,与 gsearch/xsearch 完全相同。没有 Bun 程序、没有随附的求解器,也没有伪装成浏览器的 HTTP 客户端。YouTube 为播放视频所做的一切复杂处理——Cookie、poToken、n-signature、SABR 多路复用、自适应码率选择——页面在播放时已经处理好了。ytdl 只负责记录结果。
ytdl "https://www.youtube.com/watch?v=..." # best quality → ~/Downloads
ytdl "https://www.youtube.com/shorts/<id>" # Shorts — normalized to watch?v=
ytdl "https://www.youtube.com/watch?v=..." -q 360p # 360p
ytdl "https://www.youtube.com/watch?v=..." -q 1080p # 1080p (ffmpeg mux)
ytdl "https://www.youtube.com/watch?v=..." -q audio # audio only
ytdl "https://www.youtube.com/watch?v=..." --info # title / duration / qualities
ytdl "https://www.youtube.com/watch?v=..." -o Name -d ~/Videos
画质目标
-q | 执行方式 | 需要 ffmpeg? |
|---|---|---|
360p / 480p / 720p / 1080p / 1440p / 2160p | 强制设置该播放器画质,捕获并混流 | 是 |
best | 由播放器选择(提供的最高画质) | 是 |
audio | 强制选择最小的视频画质,只保留音频缓冲区 | 否 |
这里 ffmpeg 仅用作混流器——播放器会将视频和音频作为独立的 ISO-BMFF(分片 MP4)流提供,因此需要将它们合并为一个文件。它绝不会重新编码(-c copy);输出会保留 YouTube 自身的编码格式,且不失真。如果你需要适用于 QuickTime/iOS 的文件,可以自行一步重新编码输出——参见 Traps。
工作原理
全部在一个 browser-harness-js <<EOF heredoc 中完成,采用 gsearch 风格:
- 连接到浏览器共享的 CDP 会话(或使用
session.connect())。 Page.addScriptToEvaluateOnNewDocument— 在任何页面 JS 运行之前注入 MSE 钩子:- 包装
MediaSource.prototype.addSourceBuffer(通过Object.defineProperty),为每个SourceBuffer标记其 mime 类型(video/mp4 或 audio/mp4)。 - 在每个 SourceBuffer 上定义一个自有的
appendBuffer属性,在调用原始方法前记录字节(解复用后的 ISO-BMFF 片段)。必须使用自有属性——SourceBuffer.prototype.appendBuffer是不可写的原生方法,因此重新赋值原型只会静默失败。
- 包装
Target.createTarget前台标签页 → 使用Page.navigate导航到观看 URL → 等待networkIdle,然后轮询#movie_player+<video>。必须使用前台标签页:后台标签页的自动播放不稳定;如果播放器始终无法启动,MediaSource 就不会收到数据,也就无法捕获任何内容。- 停用自动导航,然后强制设置画质(尽力调用
player.setPlaybackQualityRange(q,q)),并以静音、16× 倍速播放,让播放器快速获取并追加每个片段。播放前先点击.ytp-autonav-toggle关闭自动导航(根据aria-label/data-tooltip-title读取状态:"Autoplay is on" → "... off"),这样播放器绝不会自动播放下一个视频——这是解决后台标签页问题的真正方法,因为即使节流后的标签页未能锁定,也没有下一个视频可供其跳转。播放器自己的 SABR 客户端会在播放过程中请求整个时间线。这就是完整的模式:加速观看视频。 - 等待整个时间线都已缓冲(
buffered.end >= duration-0.5;播放器会提前缓冲),然后在一次页面端调用中原子地冻结捕获并暂停。时间线首次完整时就会退出,之后不会再次检查,因此自动播放进入下一个视频也无关紧要——我们已经停止了。覆盖率完成的瞬间,__capDone标记会让钩子停止记录并直接传递追加操作,因此此后没有任何内容会进入捕获缓冲区。同一轮轮询中还会重新断言muted+playbackRate=16——播放器可能会在切换画质、播放广告或重新初始化时覆盖这些设置,导致捕获期间 16× 播放的音频取消静音。 - 在播放期间将捕获的缓冲区写入磁盘(与覆盖率轮询交错进行,约每 1 秒一次)。页面端的
__drainNew(i,maxBytes)会返回自上次调用以来追加的最多maxBytes字节,并推进每个缓冲区的(_chunkIdx,_chunkOff)游标;返回内容为 base64 编码。REPL 使用Buffer.from(b64,'base64')解码,并通过fs.appendFileSync将每个切片写入对应缓冲区的临时文件。即使单个 fmp4 片段超过maxBytes,__drainNew也绝不会输出超过maxBytes的内容——它会把过大的片段拆分到两次 drain 中(1440p 片段通常为 1–4 MB),从而修复导致套接字断开的崩溃问题。到锁定时,几乎所有媒体内容都已写入磁盘,因此暂停后的处理只需提取尾部数据,随后标签页就会立即关闭。选取最大的已提取视频文件和最大的音频文件(播放器可能会在切换画质时创建多个 MediaSource;较小的初始化片段副本会被解除链接),并将它们的路径交给 ffmpeg。 - ffmpeg
-i video -i audio -c copy -movflags +faststart out.mp4(bash,在 heredoc 返回临时文件路径后运行)。 - 在
try/finally中调用closeTab,即发即弃——与 gsearch/xsearch 完全一致的清理流程。
为什么在 MediaSource 捕获,而不是在 googlevideo 捕获
已登录的网页版客户端现在通过 SABR
(application/vnd.yt-ump)播放所有内容,这是一种 protobuf 多路复用协议,而不是带有
HTTP 范围请求的独立 itag URL。向 SABR URL 添加 &range= 会返回 31 字节的
sabr.malformed_config 错误;在播放层没有可供范围下载的媒体字节 URL。(yt-dlp 通过伪装成不支持 SABR 的
android_vr 客户端绕过了这个问题——而本技能有意舍弃的正是这套客户端伪装机制。)
解复用后的媒体以明文形式存在的唯一位置是
SourceBuffer.appendBuffer——播放器交给
<video> 播放的 ISO-BMFF 片段。在这里捕获不受 SABR、身份验证或访问门槛的影响:页面完成了所有操作;我们只需记录输出。这
就是字面意义上的“观看并录制”。
文件
所有路径均相对于 <skill-dir>。
scripts/ytdl— Bash 命令行工具(一个browser-harness-jsheredoc,不含#!bun)scripts/setup— 将ytdl和browser-harness-js符号链接到 PATH- (没有
lib/—— 整套求解器/客户端表/下载脚手架都已移除)
陷阱
- MSE hook 必须通过
Page.addScriptToEvaluateOnNewDocument注入,而不是在页面加载后使用Runtime.evaluate。 播放器启动时会获取MediaSource/addSourceBuffer引用;只有在任何页面 JS 运行前注入,才能及时修补它们。hook 在主世界中运行(不设置worldName),播放器也在主世界中。 - 将
appendBuffer修补为每个SourceBuffer实例的自有属性,绝不要修补原型。SourceBuffer.prototype.appendBuffer是不可写的原生方法——在原型上执行sb.appendBuffer = fn会静默无效;执行Object.defineProperty(SourceBuffer.prototype, 'appendBuffer', …)也不会生效。在实例上定义自有属性可以正确遮蔽原型方法。(addSourceBuffer可以通过defineProperty在原型上修补——它是可写的。) - 使用前台标签页。 后台标签页的自动播放不可靠;如果播放器没有启动,就不会向 MediaSource 输入数据,捕获结果也会是空的。使用
createTarget({ url: 'about:blank' })(不要设置background: true)。此外,播放前会禁用自动导航(见下文),这样即使标签页切到后台并受到节流,也不会跳到下一条视频。 - 播放前禁用自动导航,而不是播放后。 YouTube 的
.ytp-autonav-toggle控制自动播放下一条;在调用play()之前先将其关闭(状态由aria-label/data-tooltip-title表示,值为 "Autoplay is on" 或 "... off")。这才是后台标签页场景的真正解决办法:即使标签页受到节流、覆盖范围轮询一直未触发,也不会自动播放下一条视频而跳走。不要试图在收到ended/timeupdate后再阻止自动播放——到那时下一条视频已经加载了。 - 覆盖范围由播放进度驱动,而不是由 Range 请求驱动。 播放器只会追加正在播放的片段,因此 ytdl 会以 16× 速度播放,并等待整个时间轴都已缓冲(
buffered.end >= duration-0.5)。不要尝试提取 URL 再通过 Range 请求下载——请参阅“为何要在 MediaSource 捕获”。对于很长的视频,耗时受实际时长/16 限制;1 小时的视频以 16× 播放约需 4 分钟。 - 覆盖范围循环必须在满足条件时锁存,而不是反复检查。 时间轴首次完全缓冲时就会退出,之后不再重新评估——因此 YouTube 自动播放下一条视频(这会重置
currentTime)不会造成影响:捕获已经冻结。不要把循环改成每个轮询周期都检查currentTime ≈ duration;自动播放会将currentTime重置为约 0,使检查结果再次变为 false,导致循环一直运行到看门狗触发。__capDone会在锁存时冻结捕获,避免将下一条视频的片段加入缓冲区。 - 分片 WebM 捕获可能会让 ffmpeg 输出
[matroska,webm] File ended prematurely,这属于无害警告。 MSE 捕获的 opus/vp9 片段会在 EBML 元素中途结束(实时捕获不会正常关闭)。ffmpeg 仍会正确混流完整时长(已通过解码测试,退出码为 0);该警告指的是输入容器的封装格式,而不是数据缺失。mp4 捕获(AV1/H264)的缓冲区不会出现此问题。 - 强制指定画质只能尽力而为。
player.setPlaybackQualityRange(q,q)对大多数内容都有效,但 SABR 在服务端管理码率,可能会忽略该设置。--info会显示getAvailableQualityLevels();返回的 JSON 中包含actualQuality,可用于查看实际播放的画质。如果强制画质未生效,捕获的就是播放器自行选择的画质。 --info会打开观看页面(加载一次页面),以读取标题/时长/画质——这并非零成本,但只是一次普通的观看页面访问,不会产生探测风暴。- 字节数据通过 CDP 以 base64 形式传输,通过
Runtime.evaluate的returnByValue分成 256 KB(3 的倍数)切片,再用Buffer.from(b64,'base64')解码并追加。切片大小能被 3 整除,因此每个切片的 base64 都可以独立解码(中间不会出现=填充)。不要在非 3 的倍数偏移处切片,否则拼接后的内容会解码为乱码。 - 捕获的缓冲区必须在播放期间写入磁盘,而不是播放结束后;同时,每个 CDP 响应都要保持较小。 覆盖范围循环中会以约 1 秒的间隔调用
__drainNew(i,maxBytes)——页面端的(_chunkIdx,_chunkOff)游标会追踪已写出的字节,因此每次调用只会返回自上次调用以来追加的内容,并对其进行 base64 编码;REPL 会将内容追加到每个缓冲区对应的临时文件中。到锁存时,几乎所有媒体数据都已写入磁盘;暂停后的处理只需拉取末尾数据,因此收到锁存信号后标签页会立即关闭(finally中的 closeTab 是 fire-and-forget,因此要等片段返回后才能执行;在循环期间排空数据可以让片段快速返回)。暂停后再一次性拉取全部内容会阻塞返回,并让标签页在整个数 MB 的排空过程中保持打开——这就是“视频停止后标签页仍打开约 30 秒”的原因。字节数据存放在页面内存中,必须在标签页关闭前移出;因此,要在锁存时关闭标签页,就必须在播放期间排空数据。 - 即使遇到数 MB 的片段,
__drainNew每次也绝不能输出超过maxBytes。 一个 1440p fmp4 片段通常有 1–4 MB,远超 256 KB 的切片上限。原来的排空逻辑中有一个if (to===from) force one whole chunk through分支,会将整个片段放进一个 CDP 帧——这种单次数 MB 的returnByValue会在 Dia 上关闭调试套接字(1006)。修复方法是将过大的片段拆分到两次排空中:本次调用返回片段开头部分,并让_chunkOff指向片段内部,留待下次调用处理。绝不要重新引入强制输出整个片段的逻辑。 - 在
drainOne中,必须先追加切片,再检查done。__drainNew会在返回最后一段字节的同一次调用中返回done:true(游标在此次调用期间到达末尾),因此若在追加之前执行if (s.done) return,就会静默丢弃最后一个切片,导致混流文件被截断/损坏(随后 ffmpeg 会输出 EBMLexceeds max length/Invalid data)。始终先追加s.b64,再检查s.done。 - 绝不要发送 4 MB 的
returnByValue帧。 某些浏览器中,单个 4 MB base64 响应(一个 CDP 帧中约 5.6 MB 的 JSON)会关闭调试 WebSocket(已在 Dia 上复现:第一个 4 MB 切片会导致套接字断开rs→3,而 256 KB 切片则正常)。这曾伪装成“捕获在视频结束前停止”的问题,因为暂停后拉取数据所用的 4 MB 切片会立即关闭套接字。切片大小现为 256 KB(262143,可被 3 整除);请保持较小。__drainNew会在每次调用中强制执行此限制(将大块内容拆分到多次排空);hook 中保留了__pullBuffer(按绝对偏移切片拉取)作为经过验证的参考/备用实现,但它不在主要执行路径上——排空和最后的尾部数据都使用__drainNew。 - 输出容器取决于视频 mime 类型。
video/mp4→.mp4;video/webm→.webm。ffmpeg 使用-c copy,因此 WebM 视频会混流到.webm文件中。如果编解码器与容器不匹配,ffmpeg 的 copy 操作会失败——这是编解码器/容器问题,而不是捕获问题。 - 直接输出的文件不兼容 QuickTime/iOS。 YouTube 通常提供 AV1 视频和 Opus 音频;
-c copy会保留这些编码(VLC/浏览器可正常播放),但 QuickTime/AVFoundation/iOS 无法播放它们(没有 AV1-in-MP4 解码器,也完全不支持 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"。 - 每个轮询周期都要重新设置
muted和playbackRate。 YouTube 播放器在切换画质、插入广告或重新初始化时会重置muted/playbackRate;只在开始播放时设置一次会被覆盖,届时会听到 16× 速度的花栗鼠音效。覆盖范围轮询每 250 ms 会重新设置两者——如果要修改循环,请保留这一逻辑。 - 开始播放时将
currentTime重置为 0。 登录账号会在不带t=的普通watch?v=IDURL 中恢复上次观看位置(“从上次停止处继续”)——如果账号之前看过部分视频,播放器会从中途开始,而 MSE 捕获只会包含恢复播放点之后的内容(由于播放器会提前缓冲到结尾,buffered.end >= dur-0.5检查仍会通过)。extract_id已经会移除 URL 中任何t=/start=参数,但恢复位置保存在账号侧而非 URL 中,因此开始播放时还会在元数据加载后设置v.currentTime = 0。请保留此逻辑——否则从上次位置恢复的视频会缺少开头部分。 - 运行 ffmpeg 时使用
-y(覆盖)。 再次处理同一视频时会覆盖之前的输出,而不会停在 ffmpeg 的交互式Overwrite? [y/N]提示处;该提示在非交互环境中会导致失败,并静默留下过期文件。不要删掉-y。 - YouTube Shorts(
/shorts/<id>)会被规范化为watch?v=<id>。 Shorts 本质上就是视频;Shorts 页面只是围绕同一个#movie_player的另一套 UI 外壳,因此extract_id会从youtube.com/shorts/<id>提取 11 个字符的 ID,其余流程则会在普通观看播放器中捕获视频——自动导航开关、画质强制设置和 MSE-hook 选择器都针对观看页面。不要尝试直接驱动 Shorts 外壳:其滑动切换下一条的自动导航使用不同控件(不存在.ytp-autonav-toggle),且其布局会隐藏 ytdl 所依赖的控件。 - 直播/Premiere 使用 HLS,而不是 MediaSource 片段——此工具不支持。