以前作ったYouTubeのコメントからタイムスタンプを抽出するブックマークレットが、意外と重宝している。ただし動画を開くと毎回手動でクリックする必要があり、追加で読み込んだコメントは一覧に反映されない。もっと当たり前に使いたくて、Chrome拡張機能として作り直すことにした。

つくったもの

動画ページを開くと、右上にパネルが自動で表示される。動画に公式のチャプターが設定されていればまず上段に一覧が出て、その下にコメント欄から拾ったタイムスタンプが並ぶ。どちらの行もクリックすると、その時刻に動画がジャンプする。

YouTube動画ページの右上に自動表示されるタイムスタンプ抽出パネル。上段にチャプター一覧、下段にコメント由来のタイムスタンプ一覧が並ぶ
動画ページに自動表示されるパネル

コピーボタンで一覧をテキストとしてコピーできる。×で閉じても、次の動画に移ればまた自動で表示される。今見ている動画だけ隠したいときはツールバーのアイコンをクリックすれば手動でオン・オフできる。

タイムスタンプの抽出ロジックはそのまま移植した

時刻を拾う正規表現、前後1秒以内の重複をまとめる処理、ラベルが空のときに次の行から補完する処理は、元のブックマークレットのコードをほぼそのまま移植した。変わったのは実行のきっかけとデータの受け渡し方の部分だけで、コアのロジックに手を入れる必要はなかった。

これができたのは、抽出処理を最初から「コメント欄のDOMを受け取ってタイムスタンプの配列を返すだけの関数」として書いていたから。ブックマークレットではボタンを押した瞬間に1回呼んでいた関数を、拡張機能では MutationObserver のコールバックから何度も呼ぶように変えただけで、関数の中身には触れていない。実行タイミングとロジックが最初から分離されていたのが、そのまま移植できた理由になる。

動画を切り替えてもパネルを自動表示させる

content scriptは既定では1回のページ読み込みにつき1回しか実行されない。ところがYouTubeはSPAで、動画を切り替えても実際にはページ全体をロードし直さない。素直に実装すると「最初に開いた動画では動くが、関連動画をクリックして遷移すると反応しなくなる」という状態になる。

URLの変化を setInterval でポーリングして検知する手もある。ただし遅延が出るうえに、常時ポーリングを回し続けるのは気持ち悪い。YouTube自身が遷移完了時に発火する yt-navigate-finish というイベントを、そのままフックにした。

// YouTubeはSPAなので、content_scriptsはページ遷移のたびには再実行されない。
// YouTube自身が発火するこのイベントが、遷移検知として一番確実だった
function onNavigate() {
  if (location.pathname !== '/watch') {
    stopObserver();
    return;
  }
  startObserver();
  scheduleRender();
}

window.addEventListener('yt-navigate-finish', onNavigate);
onNavigate(); // 初回ロード分

location.pathname !== '/watch' のチェックを入れているのは、検索結果やチャンネルページへ遷移したときにコメント監視を止めるため。これがないと、動画ページを離れたあとも MutationObserver が無関係なページのDOM変化を拾い続けることになる。

コメントの追加読み込みにパネル表示を追従させる

コメント欄は最初から全件読み込まれているわけではなく、スクロールに応じて追加取得される。一度抽出して終わりだと、後から読み込まれた分のタイムスタンプが一覧に反映されない。MutationObserver でコメント欄のDOM変化を監視し、変化があれば再抽出する形にした。

let debounceTimer = null;

function scheduleRender() {
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(render, 500);
}

function startObserver() {
  const target = document.querySelector('ytd-comments') || document.body;
  const observer = new MutationObserver(scheduleRender);
  observer.observe(target, { childList: true, subtree: true });
}

監視対象は document.querySelector('ytd-comments') || document.body にしている。パネルを表示した時点では、コメント欄自体がまだDOMに挿入されていないことがあるためだ。いったん document.body 全体を監視しておけば、後からコメント欄が挿入されてきた瞬間もそのまま拾える。

コメントは連続して差し込まれることがあるので、変化のたびに毎回再抽出すると無駄が多い。500ms のデバウンスを挟んで、DOM変化が落ち着いたタイミングでまとめて再抽出するようにした。

チャプター情報はDOMではなく内部データにあった

コメント由来のタイムスタンプに加えて、動画に公式で設定されているチャプターも一覧に出したくなった。ところがチャプターパネルは自分で開くまでDOMにレンダリングされないため、コメントと同じやり方(DOMを見て抽出)が使えない。

調べてみると、チャプター情報はページ内部の ytInitialData というJS変数の中に、動画の読み込み時点からすでに入っていた。ただしcontent scriptは既定でページ本体とJSの実行環境が分離されているため、window.ytInitialData を直接読もうとしても undefined になる。world: "MAIN" のcontent scriptを追加し、postMessage で通常のcontent script側に受け渡す構成で解決したが、この部分の仕組みは別のTipsにまとめた

実装した直後は、チャプターが一覧にまったく出ないという症状にぶつかった。原因は yt-navigate-finish イベントの detail.response の構造がロードのタイミングによって違うことだった。

// 修正前:detail.response 直下にチャプターがある前提で読んでいた
window.addEventListener('yt-navigate-finish', (e) => {
  lastData = e?.detail?.response;
  post();
});

// 修正後:SPA遷移時は response.response、初回フルロード時は response 直下、
// どちらでもない場合は window.ytInitialData にフォールバック
window.addEventListener('yt-navigate-finish', (e) => {
  const r = e?.detail?.response;
  lastData = r?.response?.playerOverlays ? r.response
    : r?.playerOverlays ? r
    : window.ytInitialData;
  post();
});

SPA遷移時は detail.response.response の中にチャプターがあるのに対し、初回のフルロード時は detail.response の直下に入っている。片方の形しか想定していなかったので、遷移のたびに抽出結果が空になっていた。ブックマークレットのようにワンショットで完結する仕組みと違い、拡張機能はイベント経由でデータをやり取りする分、実機で構造を確認しながら潰していく必要があった。

やってみて

ブックマークレットから拡張機能にする作業は、抽出ロジック自体はほぼ流用できた。差分は実質「SPA遷移のフック」「MutationObserverによる追従」「MAIN worldとのメッセージング」の3点だけだった。逆に言うと、ブックマークレットが「開いたら自動で動く」体験を持てないのはこの3点が原理的に無いからで、拡張機能にする意味がどこにあるのかがはっきりした。