ytdl について
ytdl は browser-harness-js を使う YouTube ダウンロード用コマンドラインツールです。ウェブプレーヤーからメディアを取り込み、ffmpeg で音声と映像を結合します。画質指定や音声のみの保存に対応します。
主な機能
- 01YouTube のウェブプレーヤーからメディアを取得
- 02複数の画質と音声のみのモードに対応
- 03タイトル、長さ、画質候補を表示
- 04ffmpeg で音声と映像を結合
- 05出力名と保存先ディレクトリを指定
活用シーン
- 01YouTube 動画のローカル保存
- 02指定画質での動画ダウンロード
- 03音声のみの抽出
- 04動画情報を確認して画質を選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ monotykamary/ browser-harness-js --skill ytdlインストール後、Claude Code で/ytdlを入力して読み込まれたか確認します。
同梱ファイルscripts/setupscripts/ytdl
詳細
ytdl — ブラウザー標準の YouTube ダウンローダー
gsearch/xsearch とまったく同じ、薄い browser-harness-js の heredoc です。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)ストリームとして配信するため、1 つのファイルにまとめる必要があります。再エンコードは行われず(-c copy)、出力は YouTube 独自のコーデックのまま(ロスレスで)保持されます。QuickTime/iOS に対応したファイルが必要な場合は、出力を 1 ステップで自分で再エンコードしてください。Traps を参照してください。
仕組み
すべてを 1 つの browser-harness-js <<EOF heredoc 内で実行します。gsearch 形式です。
- 接続:ブラウザーの共有 CDP セッションに接続します(または
session.connect()を使用します)。 Page.addScriptToEvaluateOnNewDocument— ページの JavaScript が実行される前に MSE フックを注入します。MediaSource.prototype.addSourceBufferをラップし(Object.definePropertyを使用)、各SourceBufferに MIME タイプ(video/mp4 と audio/mp4)をタグ付けします。- 各 SourceBuffer に独自の
appendBufferプロパティを定義し、元のメソッドを呼び出す前にバイト列(デマックス済み ISO-BMFF フラグメント)を記録します。独自プロパティが必要です。SourceBuffer.prototype.appendBufferは書き込み不可のネイティブメソッドなので、プロトタイプに再代入しても何も起こりません。
Target.createTargetでフォアグラウンドに表示 → 視聴 URL にPage.navigateで移動 →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。プレーヤーは先読みしてバッファーします)。その後、ページ側の 1 回の呼び出しでキャプチャを停止し、同時に一時停止します。タイムライン全体が揃った最初の時点でループを抜け、再確認はしません。そのため、自動再生で次の動画に移っても問題ありません。すでに停止済みです。__capDoneフラグにより、カバー範囲が完成した瞬間にフックは追加分をそのまま通し、記録を停止します。これ以降のデータがキャプチャ済みバッファーに入ることはありません。同じポーリングでは、毎回mutedとplaybackRate=16も再設定します。画質切り替え、広告、再初期化の際にプレーヤーがこれらを上書きし、キャプチャ中に 16× の音声がミュート解除されることがあるためです。 - 再生中にキャプチャ済みバッファーをディスクに書き出します(カバー範囲のポーリングと並行して約 1 秒ごと)。ページ側の
__drainNew(i,maxBytes)は、前回の呼び出し以降に追加されたバイト列をmaxBytesまで返し、バッファーごとの(_chunkIdx,_chunkOff)カーソルを進めて、base64 でエンコードします。REPL はBuffer.from(b64,'base64')でデコードし、各スライスをバッファーごとの一時ファイルにfs.appendFileSyncで追記します。単一の fmp4 セグメントがmaxBytesを超える場合でも、__drainNewが返すデータ量はmaxBytesを超えません。大きすぎるチャンクは 2 回の drain に分割されます(1440p のセグメントは多くの場合 1~4 MB)。これはソケットが切断されるクラッシュを解消するための修正です。ラッチ時点では、メディアのほとんどがすでにディスクに書き込まれているため、一時停止後の処理では残りの部分だけを取得し、直後にタブを閉じます。書き出した中から最も大きい動画ファイルと音声ファイルを選び(画質切り替え時にプレーヤーが複数の MediaSource を作成することがあり、小さな初期化セグメントの重複ファイルはリンク解除されます)、そのパスを ffmpeg に渡します。 - ffmpeg
-i video -i audio -c copy -movflags +faststart out.mp4(heredoc の終了後に、一時ファイルのパスを使って bash で実行します)。 try/finallyでcloseTabを呼び出します。fire-and-forget 方式で、gsearch/xsearch と同じティアダウンです。
googlevideo ではなく MediaSource でキャプチャする理由
ログイン済みのウェブクライアントでは、現在、すべてのコンテンツが SABR
(application/vnd.yt-ump)経由で再生されます。これは protobuf の多重化方式であり、個別の itag URL と
HTTP レンジではありません。SABR URL に &range= を追加すると、31 バイトの
sabr.malformed_config エラーが返されます。再生レイヤーには、レンジダウンロード可能なメディアバイトの URL がありません。(yt-dlp は、SABR を使わない
android_vr クライアントになりすますことで、この問題を回避します。このスキルでは、クライアントなりすましの仕組みを意図的に省いています。)
デマルチプレックスされたメディアがそのまま存在する場所は、
SourceBuffer.appendBuffer だけです。これは、プレーヤーが再生のために
<video> に渡す ISO-BMFF フラグメントです。ここでキャプチャすれば、SABR、認証、ゲート処理に依存しません。ページがすべてを実行し、その出力を記録するだけです。まさに「視聴して、記録する」です。
ファイル
すべてのパスは <skill-dir> からの相対パスです。
scripts/ytdl— bash CLI(browser-harness-jsの heredoc。#!bunはありません)scripts/setup—ytdlとbrowser-harness-jsを PATH にシンボリックリンクします- (
lib/はありません。ソルバー、クライアントテーブル、ダウンロードの仕組みはすべてなくなりました)
落とし穴
- MSE フックは、ページ読み込み後の
Runtime.evaluateではなく、Page.addScriptToEvaluateOnNewDocumentを使って注入する必要があります。 プレーヤーは起動時にMediaSource/addSourceBufferの参照を取得します。ページ上の JavaScript が実行される前に注入することだけが、間に合うようにパッチを適用する方法です。フックはプレーヤーが動作するメインワールドで実行します(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に反応して自動再生を抑止しようとしないでください。その時点では、次の動画はすでに読み込まれています。 - カバレッジはレンジリクエストではなく、再生によって進みます。 プレーヤーは再生したセグメントだけを追加するため、ytdl は 16 倍速で最後まで再生し、タイムライン全体がバッファされるまで待ちます(
buffered.end >= duration-0.5)。URL を取得してレンジリクエストでダウンロードしようとしないでください。「Why capture at MediaSource」を参照してください。非常に長い動画では実時間の 1/16 ほどかかります。1 時間の動画なら、16 倍速再生で約 4 分です。 - カバレッジのループは再判定せず、条件成立時にラッチする必要があります。 タイムライン全体がバッファされた最初の時点でループを抜け、以後は再評価しません。そのため、YouTube が次の動画を自動再生して
currentTimeがリセットされても問題ありません。キャプチャはすでに固定されています。毎回currentTime ≈ durationをポーリングするようにループを書き換えないでください。自動再生でcurrentTimeが約 0 に戻ると判定が再び偽になり、ウォッチドッグが動くまでループし続けます。__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は視聴ページを開きます(ページ読み込み 1 回)。タイトル/再生時間/画質を読み取るために必要で、無料ではありませんが、通常の視聴ページへのアクセスであり、プローブを大量に実行するものではありません。- **CDP の境界を越えるバイト列は、
Runtime.evaluateのreturnByValueを使い、256 KB(3 の倍数)のスライスに base64 エンコードされて送られます。Buffer.from(b64,'base64')でデコードして追記します。各スライスのサイズは 3 の倍数なので、個別にデコードしても途中に=パディングが入りません。3 の倍数でないオフセットでスライスしないでください。連結したデータをデコードすると破損します。 - キャプチャしたバッファは再生中にディスクへ書き出し、各 CDP レスポンスを小さく保ちます。
__drainNew(i,maxBytes)はカバレッジループ内で約 1 秒ごとに呼び出されます。ページ側の(_chunkIdx,_chunkOff)カーソルがすでに書き出したバイト数を追跡するため、各呼び出しでは前回以降に追加されたデータだけを base64 エンコードして返し、REPL がバッファごとの一時ファイルに追記します。ラッチする頃にはメディアの大半がディスク上にあります。ポーズ後の処理では末尾だけを取得するため、ラッチ通知を受けてすぐにタブを閉じられます(finally内の closeTab は fire-and-forget なので、スニペットが返るまでは実行できません。ループ中にデータを書き出しておけば、戻りが速くなります)。一時停止後に全データを取得すると、処理が終わるまで戻らず、数 MB の書き出し中ずっとタブが開いたままになります。これが「動画停止後もタブが約 30 秒開いたまま」という症状の原因でした。データはページメモリ上にあり、書き出すまでタブを閉じられないため、ラッチ時にタブを閉じるには再生中の書き出しが必要です。 __drainNewは、セグメントが数 MB あってもmaxBytesを超えるデータを返してはいけません。 1440p の fmp4 セグメントは多くの場合 1~4 MB あり、256 KB のスライス上限を大幅に超えます。元の drain にはif (to===from) force one whole chunk throughという分岐があり、セグメント全体を 1 つの CDP フレームで返していました。この単一の数 MB のreturnByValueによって、Dia ではデバッグソケットが切断されます(1006)。修正後は大きすぎるチャンクを複数回の drain に分割します。この呼び出しでは先頭部分を返し、次回の呼び出しに備えて_chunkOffをその途中に設定します。チャンク全体を強制的に 1 回で返す処理は、絶対に再導入しないでください。drainOneでは、doneを判定する前にスライスを追記します。__drainNewは、最後のバイトを返す呼び出しと同時にdone:trueを返します(カーソルが呼び出し中に末尾へ到達するため)。そのため、追記前にif (s.done) returnすると最後のスライスが黙って破棄され、mux 済みファイルが切り詰められたり破損したりします(その後 ffmpeg は EBML のexceeds max length/Invalid dataをログに出します)。必ず先にs.b64を追記し、その後でs.doneを確認してください。- 4 MB の
returnByValueフレームは絶対に送らないでください。 1 つの 4 MB base64 レスポンス(1 つの CDP フレームで約 5.6 MB の JSON)は、一部のブラウザーでデバッグ WebSocket を切断します(Dia で再現済み。最初の 4 MB スライスでソケットがrs→3の状態で切断され、256 KB のスライスでは切断されません)。この問題は「動画の終了前にキャプチャが止まる」という失敗のように見えていました。原因はポーズ後の取得で、4 MB スライスがソケットを即座に切断していました。スライスサイズは現在 256 KB(262143、3 の倍数)です。このサイズを小さく保ってください。__drainNewは大きなチャンクを複数回の drain に分割して呼び出しごとの上限を守ります。絶対オフセット指定のスライス取得を行う__pullBufferは、検証済みの参照/フォールバックとしてフックに残されていますが、通常の処理経路では使われません。drain と最後の末尾取得には、どちらも__drainNewを使います。 - 出力コンテナは動画の MIME タイプに従います。
video/mp4→.mp4、video/webm→.webmです。ffmpeg は-c copyで実行されるため、webm 動画は.webmに多重化されます。コーデックとコンテナが一致しない場合、ffmpeg のコピー処理は失敗します。これはキャプチャではなく、コーデックとコンテナの問題です。 - そのままの出力は QuickTime/iOS に適していません。 YouTube は通常、AV1 動画と Opus 音声を配信します。
-c copyはそれらを維持するため、VLC やブラウザーでは再生できますが、QuickTime/AVFoundation/iOS では再生できません(AV1 の MP4 デコーダーがなく、Opus はまったくサポートされていません)。このスキルは意図的にそのままのストリームコピーを行い、ロスレスで、サイズや画質のペナルティもありません(テストでは再エンコードによりサイズが 4 倍になり、非可逆でした)。QuickTime/iOS で再生する必要がある場合は、次の 1 ステップで出力をご自身で再エンコードしてください。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をリセットします。再生開始時に 1 回だけ設定すると上書きされ、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>に正規化されます。 Short は通常の動画です。Shorts ページは同じ#movie_playerを囲む別の UI シェルにすぎません。そのためextract_idはyoutube.com/shorts/<id>から 11 文字の ID を取得し、以降のパイプラインは通常の視聴プレーヤーでキャプチャします。オートナビの切り替え、画質の強制、MSE フックのセレクターはすべて視聴ページを対象にしています。Shorts のシェルを直接操作しようとしないでください。スワイプによる次の動画への移動は別のコントロールで行われ(.ytp-autonav-toggleはありません)、レイアウトも ytdl が必要とするコントロールを隠しています。 - ライブ配信/プレミアでは MediaSource セグメントではなく HLS が使われます。このスキルではサポートされていません。