Imported from limura/CaptionSpeaker (
AGENTS.md). Install upstream withnpx skills add limura/CaptionSpeaker. Copyright stays with the author.
AGENTS.md
このファイルは、このリポジトリで作業するコーディングエージェント(Claude Code / Codex など)向けのガイダンスです。
(このリポジトリの開発者は日本語話者です。応答・説明・新規ドキュメントはすべて日本語で記述してください。)
概要
CaptionSpeaker は、YouTube の字幕を Web Speech API(speechSynthesis)で読み上げる Manifest V3 の Chrome 拡張機能です。動画にユーザーが選んだ音声の言語の字幕があるとき、再生に同期して字幕を読み上げ、字幕を「耳で聞ける」ようにします。Chrome ウェブストアで公開されています。
ディレクトリ構成が入れ子になっている点に注意してください。リポジトリ直下にはツール・テスト・ドキュメントが置かれ、実際に配布する拡張機能本体は CaptionSpeaker/ サブディレクトリ(manifest・各スクリプト・ロケール・アイコン)にあります。直下の CaptionSpeaker.zip はパッケージ済みの成果物です。
コマンド
npm test # テスト全体を実行 (node --test)
node --test --test-name-pattern="GetVideoId" # 名前を指定して単一テストを実行
./makeZip.sh # ウェブストア提出用に CaptionSpeaker.zip を再生成
テストは Node 組み込みのテストランナーだけで動きます。依存パッケージはインストール不要です(package.json に依存関係はありません)。
ブラウザで動作確認する場合: chrome://extensions を開き、デベロッパーモードを有効にして「パッケージ化されていない拡張機能を読み込む」から CaptionSpeaker/ ディレクトリを読み込み、YouTube の動画を開きます。chrome-devtools-mcp を使ったテスト手順と注意点はプロジェクトメモリを参照してください。
アーキテクチャ
3 つの実行コンテキストが chrome.storage.sync を介して状態を共有します。
- CaptionSpeaker/background.js(Service Worker)— ツールバーのアクションを管理します。クリックすると
isEnabledフラグを切り替え、アイコンを差し替えます(明色 = ON、暗色 = OFF)。enableEvent/disableEventのキーボードコマンドも処理します。重要な点として、youtube.com/api/timedtext*に対するchrome.webRequest.onCompletedリスナーを動かしており、観測した URL をurl_accessedメッセージとしてコンテンツスクリプトに転送します。これは、コンテンツスクリプトが自力で組み立てられない字幕 URL を発見するための仕組みです。 - CaptionSpeaker/contentScript.js —
*://*.youtube.com/*のすべてのフレームに注入されます(埋め込みプレイヤーでも動くようにするため)。ロジックの約 95% がここにあります。字幕の取得、再生に同期した読み上げ、YouTube の SPA 遷移への対応を担います。 - CaptionSpeaker/options.html + options.js — 設定ページです。
chrome.storage.syncを読み書きし、SettingsUpdatedのランタイムメッセージでアクティブなタブに変更を通知します。音声一覧はspeechSynthesis.getVoices()から取得します(onvoiceschangedイベントで利用可能になります)。
設定はすべて chrome.storage.sync に保存され、コンテンツスクリプトは chrome.storage.onChanged で変更にリアルタイムに反応します。主なフラグ: isEnabled、lang/voice/pitch/rate/volume、isStopIfNewSpeech、isDisableSpeechIfSameLocaleVideo、isDisableSpeechIfChaptionDisabled、isDisableSpeechEmbeddedSite、isOverrideOriginalVolumeEnabled/overrideOriginalVolumeMagnification、isSpeechWithoutSyncEnabled。
字幕の取得 — メタデータは DOM、本文は timedtext のみ
FetchCaptionData が入口です。
- メタデータ —
GetPlayerResponseFromDomが視聴ページ埋め込みのytInitialPlayerResponse(JSON)を DOM から読み、updateCaptionFetchMetadataが動画長(videoLengthSeconds)と字幕言語(guessedOriginalCaptionLanguage)を取り出します。videoDetails.videoIdが現在の動画と一致するときだけ採用します(SPA 遷移で前動画のデータが残るのを防ぐため)。 - 字幕本文 —
FetchCaptionDataFromTimedTextのみで取得します。background がurl_accessedで集めてaccessUrlListに溜めた、player 自身がリクエストしたtimedtextURL を使い、tlangパラメータを書き換えます。まだ 1 つも観測できていない場合は、字幕ボタンをクリックして(doubleClickWithDelay)player にリクエストさせます。shouldSkipTimedTextCollectionは字幕のない動画(例: ライブ配信)で字幕ボタンを延々と押し続けないようにします。
なぜ player_response から直接 URL を組み立てないのか: 以前は
ytcfgを解析してyoutubei/v1/playerへ POST し、字幕 URL を自前生成していたが、調査の結果 (a) その POST 応答には字幕トラックが含まれず常に DOM 側へフォールバックしていた、(b)timedtextの取得には player が生成するpo_token(pot) が必須で自前生成は非現実的(BotGuard) と判明したため廃止した(脆い ytcfg パーサも同時に撤去)。pot を得る唯一の現実的手段が「player 自身にリクエストさせて観測する」timedtext 経路なので、doubleClickWithDelayはその起点として必要。
取得した JSON は CaptionDataToTimeDict で "m:ss" のタイムスタンプをキーとする辞書 {tStartMs, segment, time} に正規化されます。1 行分のセグメントを連結し、インラインの HTML タグ(<b>、<i> など)を除去します。
再生との同期
StartVideoTimeChecker が 250ms ごとにポーリングし → CheckVideoCurrentTime が video.currentTime を "m:ss" 形式にして字幕辞書を引き、新しく到達したセグメントを AddSpeechQueue で読み上げます。prevSpeakTime/prevCheckVideoTimeText で重複を防ぎます。別モードの isSpeechWithoutSyncEnabled(SpeechAllWithoutSync)は同期せずにトランスクリプト全体を一度に読み上げます。
YouTube の SPA 遷移とプレイヤー状態
YouTube はチャンネル → 視聴ページの遷移時に通常のナビゲーションイベントを発火しないため、2 つの MutationObserver で補います。
ToplevelObserver(document.bodyを監視)— URL・動画 ID の変化を検知し、字幕の再取得と時間チェッカーを再起動します。screenObserver(#movie_playerを監視)— class やsrcの変化を見て、プレイヤーの状態(一時停止・シーク・動画切り替え)に合わせて読み上げを一時停止・再開・キャンセルします。
speakTargetUrl は speechSynthesis.cancel() をガードし、古いフレームが現在の URL の読み上げをキャンセルしてしまわないようにします。
規約と制約
- コード内のコメントは日本語です。 既存ファイルを編集するときはそれに合わせてください。
- テストは各スクリプトを
vm.runInNewContextで読み込みます(test/contentScript.test.js、test/options.test.js、test/background.test.js)。ソース文字列の末尾に内部関数を列挙したmodule.exports = { … }を付け足し、手作りでモックしたdocument/window/chromeの上で実行します。関数をテスト可能にするには、対象スクリプト内でトップレベルのfunction宣言である必要があり(入れ子やvarに代入したアロー関数は不可)、その export 一覧に追加します。スクリプトが触るブラウザのグローバルは各load〜Environment内でモックする必要があります。 - コンテンツスクリプトの可変状態は
stateオブジェクトに集約しています。 以前トップレベルに散らばっていたvar/let(captionData・videoLengthSeconds・accessUrlListなど)を 1 つのconst state = { … }にまとめています。テストからはmodule.exportsでstateを公開し、loaded.exports.state.videoLengthSeconds = …のように直接書き換えて検証します(以前のloaded.context.<グローバル名>直書きは廃止)。新しい共有状態を足すときはstateに追加してください。vmで生成された配列は主実行系とプロトタイプが異なるため、deepStrictEqualで配列を比較する前に[...result]でスプレッドします。 - セキュリティ方針(v2.0.12 以降): コンテンツスクリプトと host 権限は
youtube.comのみに限定しています。IsYoutubeUrlは外部由来の URL をfetchする前に必ずガードしなければなりません(SSRF 対策)。コンソール出力はdebugLog/CS_DEBUG(既定で OFF)を通し、視聴 URL や字幕本文が漏れないようにしています。これらのガードは維持してください。 - ローカライズ:
CaptionSpeaker/_locales/{en,ja,zh_CN,zh_TW}/messages.json、default_localeはenです。UI 文字列は manifest 内では__MSG_key__、HTML ではdata-i18n-text/data-i18n-value属性を使い、localizeHtmlPage()がchrome.i18n.getMessageで解決します。新しい文字列はすべてのロケールに追加してください。 - バージョニング: リリースごとに CaptionSpeaker/manifest.json の
versionを上げ、README.md の更新履歴セクションに対応する## Version x.y.zの項目(日本語の変更履歴)を追記します。