mcpField
Y
ytdl

ytdl

github.com/monotykamary创意与媒体464 次安装2 天前更新
代码仓库

关于 ytdl

ytdl 是基于 browser-harness-js 的 YouTube 下载命令行工具,借助网页播放器播放并捕获媒体流,再用 ffmpeg 合并音视频。适用于按指定画质、最佳可用画质或仅音频保存视频。

主要功能

  1. 01从 YouTube 网页播放器捕获媒体
  2. 02支持多种画质及仅音频模式
  3. 03查看标题、时长和可用画质
  4. 04以 ffmpeg 合并音视频流
  5. 05可指定输出名称和目录

适用场景

  1. 01将 YouTube 视频保存到本地
  2. 02下载指定画质的视频
  3. 03仅提取视频音频
  4. 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 风格:

  1. 连接到浏览器共享的 CDP 会话(或使用 session.connect())。
  2. Page.addScriptToEvaluateOnNewDocument — 在任何页面 JS 运行之前注入 MSE 钩子:
    • 包装 MediaSource.prototype.addSourceBuffer(通过 Object.defineProperty),为每个 SourceBuffer 标记其 mime 类型(video/mp4 或 audio/mp4)。
    • 在每个 SourceBuffer 上定义一个自有的 appendBuffer 属性,在调用原始方法前记录字节(解复用后的 ISO-BMFF 片段)。必须使用自有属性——SourceBuffer.prototype.appendBuffer 是不可写的原生方法,因此重新赋值原型只会静默失败。
  3. Target.createTarget 前台标签页 → 使用 Page.navigate 导航到观看 URL → 等待 networkIdle,然后轮询 #movie_player + <video>。必须使用前台标签页:后台标签页的自动播放不稳定;如果播放器始终无法启动,MediaSource 就不会收到数据,也就无法捕获任何内容。
  4. 停用自动导航,然后强制设置画质(尽力调用 player.setPlaybackQualityRange(q,q)),并以静音、16× 倍速播放,让播放器快速获取并追加每个片段。播放前先点击 .ytp-autonav-toggle 关闭自动导航(根据 aria-label / data-tooltip-title 读取状态:"Autoplay is on" → "... off"),这样播放器绝不会自动播放下一个视频——这是解决后台标签页问题的真正方法,因为即使节流后的标签页未能锁定,也没有下一个视频可供其跳转。播放器自己的 SABR 客户端会在播放过程中请求整个时间线。这就是完整的模式:加速观看视频。
  5. 等待整个时间线都已缓冲(buffered.end >= duration-0.5;播放器会提前缓冲),然后在一次页面端调用中原子地冻结捕获并暂停。时间线首次完整时就会退出,之后不会再次检查,因此自动播放进入下一个视频也无关紧要——我们已经停止了。覆盖率完成的瞬间,__capDone 标记会让钩子停止记录并直接传递追加操作,因此此后没有任何内容会进入捕获缓冲区。同一轮轮询中还会重新断言 muted + playbackRate=16——播放器可能会在切换画质、播放广告或重新初始化时覆盖这些设置,导致捕获期间 16× 播放的音频取消静音。
  6. 在播放期间将捕获的缓冲区写入磁盘(与覆盖率轮询交错进行,约每 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。
  7. ffmpeg -i video -i audio -c copy -movflags +faststart out.mp4(bash,在 heredoc 返回临时文件路径后运行)。
  8. 在 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-js heredoc,不含 #!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 会输出 EBML exceeds 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=ID URL 中恢复上次观看位置(“从上次停止处继续”)——如果账号之前看过部分视频,播放器会从中途开始,而 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 片段——此工具不支持。

评论

支持 Markdown