このリポジトリは、YouTube のライブチャットをニコニコ風の弾幕として動画に重ねる成果物を 2 つ含みます。
| 成果物 | 場所 | コメントの取得方法 | サーバーとの通信 |
|---|---|---|---|
| Chrome 拡張 | extension/(デスクトップ、Chrome ウェブストアで公開中) |
YouTube が描画済みのチャット iframe の DOM を読む(MutationObserver) |
なし |
| モバイル PWA | web/ + worker/(iOS Safari、Android Chrome) |
YouTube の InnerTube API(youtubei/v1)を Cloudflare Worker 経由でポーリング |
中継のみ |
どちらもライブチャットとチャットリプレイ(過去ライブのチャット)が対象です。通常動画の下にあるコメント欄は読みません。
最後は同じ処理に合流します。データの形は docs/CONTRACT.ja.md にあります。
ChatMessage -> NG フィルタ (filter.js) -> 採点 (scoring.js) -> buildRenderPlan() -> DanmakuOverlay.push() (danmaku.js)
採点・フィルタ・描画は常に端末側で動きます。Worker はチャットを中継するだけで、採点・重複排除・描画はしません。
YouTube の視聴ページには、この拡張の部品が 3 つ載ります。これにバックグラウンドの service worker が加わります。
www.youtube.com/watch?v=… (トップフレーム) content.js -> initRenderer()
├─ ytd-live-chat-frame > iframe /live_chat content.js -> initChatExtractor()
│ (YouTube のチャット。チャットを閉じている間は拡張が置く不可視の代役)
└─ .html5-video-player > iframe stage.html stage.js + danmaku.js
(拡張のオリジン。専用のレンダラープロセス)
バックグラウンド service worker background.mjs -> background.js
manifest.json は content.js を 2 か所に注入します。/watch* と /live/* のトップフレーム(描画側)と、all_frames: true の /live_chat*(抽出側)です。役割は content.js の末尾で location.pathname を見て決めます。
拡張は自分でチャットを取りに行きません。YouTube 自身のチャット iframe がチャットを取得して DOM に描画し、拡張はその DOM ノードを読みます。そのため API キーも CORS 回避も要らず、拡張から YouTube へのリクエストもありません。視聴者が YouTube 上でブロックしたユーザーは DOM に来ないので、弾幕にも出ません。
/live_chat iframe (content.js, initChatExtractor)
yt-live-chat-item-list-renderer #items を MutationObserver で監視
+ 2.5 秒ごとの scan(): #items が作り直されていれば付け直し、末尾 80 件を読み直す
|
processChatNode(node)
isUserChatMessageNode バナー・ティッカー・固定メッセージ・削除済みを除外
extractMessageParts テキストとカスタム絵文字 <img> を parts に分解
author / kind / authorType / amount / paidColor / チャンネル ID
isOfficialChatText YouTube 自身の案内文を除外
SYCFilter.apply NG リスト: 除外、または伏せ字/置換
scorer.score -> buildRenderPlan
| chrome.runtime.sendMessage({ type: "smart-comment:chat-message" })
v
background.js
送信元が www.youtube.com のタブか確認; sanitizeRenderPayload
| chrome.tabs.sendMessage(tabId, { type: "smart-comment:render-message" }, { frameId: 0 })
v
トップフレーム (content.js, initRenderer)
sanitizeRenderPayload; 端末内翻訳(任意、translate.js)
| stage.push -> postMessage({ type: "push" }) をステージへ
v
stage.html (stage.js)
event.source と origin を確認; sanitizeRenderPayload
DanmakuOverlay.push -> canvas
ペイロードは中継のたびに(background、トップフレーム、ステージで)サニタイズし直します。どの段も別のコンテキストから受け取るからです。
processChatNode() が読む要素:
| YouTube の要素 | kind |
|---|---|
yt-live-chat-text-message-renderer |
text |
yt-live-chat-paid-message-renderer |
paid |
yt-live-chat-membership-item-renderer |
membership |
| 要素の中 | 変換先 |
|---|---|
#message(テキストノードと <img> 絵文字) |
text、parts({t} がテキスト、{u, a} が画像) |
#author-name |
author。その external-channel-id(なければ要素の author-external-channel-id)が NG リストで使うチャンネル ID |
author-type 属性、なければバッジの [type=…] |
authorType: owner / moderator / member / normal |
#purchase-amount、--yt-live-chat-paid-message-*-color |
amount、paidColor(スーパーチャット) |
補足:
- 重複. 処理済みノードは
processedChatNodes(WeakSet)が覚えます。テキストが入ってから初めて記録するので、YouTube が後から中身を埋めるプレースホルダもcharacterDataの変化で拾えます。 - チャット iframe の中だけで監視する理由. 視聴ページ全体を監視するとカクつきの原因になっていました。トップフレームにはチャットのノードがありません。
- チャットを閉じたとき. YouTube はチャットを閉じると iframe を破棄します。そこで
createChatSource()が 1×1 の不可視 iframe を追加します。URL は YouTube の iframe が最後に表示していたもの(リプレイは continuation が必要なため)、チャットを閉じた状態で開いたライブなら/live_chat?is_popout=1&v=<id>です。リプレイでは動画のtimeupdateを、YouTube のページが自分のチャット iframe に送るのと同じyt-player-video-progressメッセージとして転送します。ytd-live-chat-frameのcollapsed属性を見るMutationObserverが代役を出し入れします。 - 「デフォルトのチャットを隠す」. この設定は CSS で YouTube のチャットを 1px に縮めるだけです。iframe は読み込まれたままなので、抽出は続きます。
コードの場所:
| ファイル | 関数 | 役割 |
|---|---|---|
extension/manifest.json |
content_scripts |
どのフレームで何を動かすか |
extension/content.js |
initChatExtractor, findChatItemsRoot |
監視と定期的な付け直し |
processChatNode, isUserChatMessageNode, isOfficialChatText |
ノード 1 件からペイロード 1 件へ | |
extractMessageParts, extractAuthorType, extractAmount, extractPaidColor, extractAuthorChannelId |
DOM の読み取り | |
safeRuntimeSend |
チャット iframe から background へ | |
createChatSource |
不可視の代役チャット iframe | |
initRenderer, createStage |
トップフレーム: トグルボタン、ステージ、ポインタ入力、PiP | |
extension/background.js |
onMessage リスナー, isAllowedSender |
チャット iframe からトップフレームへの中継 |
extension/stage.js |
message リスナー |
ステージ側: push、clear、start/stop、当たり判定、ピン留め、ユーザー非表示 |
extension/sanitize.js |
sanitizeRenderPayload, sanitizeMessageParts |
ペイロードと絵文字 URL の検証 |
extension/filter.js |
SYCFilter.apply |
NG ユーザー・チャンネル・語句・正規表現 |
extension/scoring.js |
createFallbackScorer, buildRenderPlan |
段と優先度(docs/SCORING.ja.md) |
extension/danmaku.js |
DanmakuOverlay |
canvas エンジン |
エンジンは YouTube のページ内では動きません。stage.html は拡張のページで、content.js がプレイヤーの上に iframe として重ねます。web_accessible_resources はこの 1 件だけで、https://www.youtube.com/* に限り、use_dynamic_url で配信します。ページ内で動かしていた頃は、エンジンのフレームループが YouTube 自身のページ処理を毎 vsync 走らせ、YouTube が日常的に出す 0.5 秒級のタスクのたびに止まっていました。ステージでは専用のプロセスとフレームクロックを持ちます(docs/PERFORMANCE.ja.md の「ステージフレーム」)。
- ステージは設定を
chrome.storageから直接読みます(SYCSettings.load/onChange)。トップフレームが送るのはコメントとポインタ入力だけです。 - ステージはポインタイベントを受けません。トップフレームがプレイヤー上でイベントを(キャプチャフェーズで)受け、
hover/select/drag*を送り、ステージがポインタの下に何があるかを返します。右クリックメニューはページ側で DOM API とtextContentだけで組み立てます。「このユーザーを非表示」はステージが投稿者を NG リストへ書き込み、チャット iframe のSYCFilterがストレージの変更を受けて以後そのユーザーを除外します。 - ピクチャーインピクチャー(ベータ)は
<video>要素を Document Picture-in-Picture のウィンドウへ移し、そこに専用のステージを置きます。このためステージはparent.openerからのメッセージも受け付けます。 - エンジン内部ではコメントを共有のスプライトアトラスに描くので、定常状態では何も確保しません(docs/PERFORMANCE.ja.md の「スプライトアトラス」)。
settings.jsはスキーマです。options.htmlとpopup.htmlはここからフォームを描き、toEngineConfig()がエンジン用の設定に変換します。設定はchrome.storage.syncに保存します。filter.jsは NG リスト(ユーザー、チャンネル ID、語句、正規表現、除外/伏せ字/置換のモード)をchrome.storage.localにだけ保存します。background.jsはキーボードコマンドtoggle-overlayとopen-optionsも扱います。
| 環境 | 拡張機能 | ユーザースクリプト | 素の Web アプリ |
|---|---|---|---|
| iOS Safari | Xcode でアプリ化し App Store 配布が必要 | Userscripts アプリ経由 | 可 |
| Android Chrome | 不可 | 不可 | 可 |
両方を満たせるのは Web アプリだけです。PWA は自前のサイトに YouTube IFrame Player を埋め込み、その上に弾幕 canvas を重ねます。YouTube 公式アプリは拡張できません。
バックグラウンド再生はしません。埋め込みプレイヤーは画面ロックやアプリ切り替えで止まります(OS と YouTube の制約)。PWA が使うのは Wake Lock API(前面にいる間は画面を点けておく)と Media Session API(ロック画面のメタ情報と操作)だけです。弾幕は画面を見ているときに使うものなので、これで割り切ります。
ページから InnerTube(youtubei/v1)を直接呼ぶと CORS で弾かれます。公式の YouTube Data API は CORS 可ですが、ライブチャットのポーリングで無料枠がすぐ尽きます。そこでステートレスな Cloudflare Worker が InnerTube の呼び出しを中継し、CORS ヘッダを付けます。それ以外はすべて端末側です。
端末 (PWA) Cloudflare Worker YouTube InnerTube
---------- ----------------- -----------------
app.ts client.start(videoId)
chat-client.ts
GET /api/livechat?video=<id> ---> index.ts handle() -> fetchEnvelope()
resolveLiveChat() ----------------> POST youtubei/v1/next
<---------------- conversationBar.liveChatRenderer
.continuations[]
pollLiveChat(cont) ----------------> POST live_chat/get_live_chat
<---------------- actions[]
parseAction() -> ChatMessage[]
<--- { messages, continuation, timeoutMs, ended, isReplay }
onMessages(messages)
待つ(待ち時間は step() が決める)
GET /api/livechat?cont=<token> -> (以後 continuation で繰り返し)
-
解決.
?video=<id>のとき、resolveLiveChat()がnextを POST し、contents.twoColumnWatchNextResults.conversationBar.liveChatRendererを読みます。ここで最初の continuation と、リプレイかどうかが分かります。最初のポーリングも同じリクエスト内で行います。 -
ライブのポーリング.
pollLiveChat()が continuation 付きでlive_chat/get_live_chatを POST し、continuationContents.liveChatContinuationを読みます。actions[] addChatItemAction.item (新しいメッセージ) replaceChatItemAction.replacementItem (プレースホルダの差し替え) liveChatTextMessageRenderer -> text liveChatPaidMessageRenderer, liveChatPaidStickerRenderer -> paid liveChatMembershipItemRenderer -> membership liveChatSponsorshipsGiftPurchaseAnnouncementRenderer -> membership それ以外(バナー、投票など) -> 捨てる continuations[] 次の token と timeoutMs(250〜30000 ms にクランプ) token がない -> ended: true、端末は停止投稿者バッジから
authorTypeを決め、メッセージの runs をテキストと絵文字のpartsにします。 -
リプレイのポーリング.
?cont=<token>&offset=<ms>&replay=1のとき、pollReplay()がcurrentPlayerState.playerOffsetMs付きでlive_chat/get_live_chat_replayを POST し、replayChatItemActionを展開して各メッセージにoffsetMsを付けます。continuation は使い回し、再生位置で読む範囲が動きます。リプレイの終わりはプレイヤーが判断するので、中継はリプレイに対して常にended: falseを返します。端末は再生位置の 8 秒前から 1.5 秒先までのメッセージだけを表示し(playback.tsのmakeFate)、後ろへシークしたらリセットします。 -
端末のループ.
chat-client.tsのcreateLiveChatClient()が、純粋な状態機械step()を中心にポーリングを回します。- 正常: サーバーの
timeoutMsだけ待つ - 静か(0 件が続く): 1 回ごとに
quietGrowth倍に延ばし、最大 40 秒 - エラー: ジッタ付き指数バックオフ、最大 30 秒
- 4 回続けて失敗、または
410を受けたら即: 動画 ID から解決し直す(continuation の失効) - タブが非表示、または動画が一時停止中: ポーリングしない
ended、または404(ライブチャットなし): 停止
- 正常: サーバーの
-
処理の流れ.
app.tsのonMessages():sanitizeChatMessage(URL 検証)→ リプレイの時間窓と既読 ID の確認 →filter.apply→ コメント一覧とmakeRenderer()(pipeline.ts: 採点、buildRenderPlan)→overlay.push()。
コードの場所:
| ファイル | 関数 | 役割 |
|---|---|---|
worker/src/index.ts |
handle, readParams, fetchEnvelope |
ルーティング、入力検証、エッジキャッシュ、同時リクエストの集約、レート制限 |
worker/src/innertube.ts |
resolveLiveChat, pollLiveChat, pollReplay |
InnerTube 呼び出し |
postWithRetry, postOnce |
1 回 3.5 秒、タイムアウト/5xx のみ 1 回再試行 | |
extractContinuation, parseAction, RENDERERS, runsToParts, authorTypeFromBadges |
レスポンスの純粋変換 | |
web/chat-client.ts |
createLiveChatClient, step, buildUrl |
適応ポーリング |
web/app.ts |
onMessages |
結線: プレイヤー、クライアント、フィルタ、一覧、オーバーレイ |
web/pipeline.ts |
makeRenderer, renderBatch |
ChatMessage から弾幕ペイロードへ |
web/playback.ts |
makeFate, createSeenTracker |
リプレイの時間窓、ID での重複排除 |
web/player.ts, web/lifecycle.ts |
YouTube IFrame API、Wake Lock、Media Session |
- 入力.
videoは 11 文字の ID、contは長さと文字種を制限したトークンに限ります。replay=1なしのcont+offsetは拒否します。 - エッジキャッシュ. エンベロープを
caches.defaultにs-maxage = floor(timeoutMs / 1000)で保存します。キーはvideo/cont/ 3 秒単位に丸めた offset だけです。同じ配信を同じ Cloudflare 拠点で見ている視聴者は、上流への呼び出しを 1 本で共有します。timeoutMs < 1000のポーリングと終端のエンベロープはキャッシュしません。 - キャッシュ前の同時リクエスト. 同じキーで処理中のリクエストは、isolate 内で 1 つの Promise を共有します。
- 上流のエラー.
429(YouTube のレート制限)、reResolve: true付きの410(continuation への 4xx、つまり失効)、502のいずれかで返し、1 秒キャッシュします。上流が詰まったときに全視聴者が一斉に叩かないためです。404はライブチャットがないことを表します。 - 再試行. Worker はタイムアウト/5xx のときだけ 1 回再試行するので、最悪でも 7 秒前後で返ります。
429は再試行しません。続く不調は端末のバックオフに任せます(2026-06-20 の実測: InnerTube には Cloudflare の egress から 1 回 1 秒ほどで届きます。ときどき数秒止められることがありますが、恒常的な IP ブロックではありません)。 - 任意の制御.
ALLOWED_ORIGINSは他のブラウザオリジンを拒否し、RATE_LIMIT_PER_MINUTE(既定 120)は isolate ごとにクライアント IP を制限します。 - クライアントバージョン. InnerTube には公開の
WEBクライアントとして、既定ではバージョン2.20240814.00.00で呼びます。コードを変えずにINNERTUBE_CLIENT_VERSION変数で上書きできます。GET /health?deep=1&video=<id>はnextを試しに呼び、バージョンが拒否されるようになったことを視聴者より先に検知します。 - 無料枠. ポーリング 1 回が Worker の 1 リクエストです(キャッシュに当たっても同じ)。10 秒間隔なら 1 視聴者で 1 日 8,640 リクエストなので、Workers Free の 1 日 10 万件では常時視聴で約 11 人、1 時間の視聴なら約 278 回分です。エッジキャッシュが守るのは YouTube と egress IP で、この枠ではありません。リクエストを減らしているのは、非表示中の停止と静かなチャットでの間隔延長です。次の段階の WebSocket + Durable Object による単一化は、docs/PLAN.ja.md の条件を満たしてから行います。
- canvas はラッパー内でプレイヤー iframe の兄弟要素として置き、
pointer-events: noneにします。タップは YouTube の操作部に届きます。 sw.jsは静的なシェルだけをキャッシュし、チャット API はキャッシュしません。store.jsはchrome.storageと同じ形のlocalStorageシムで、settings.jsとfilter.jsを無改変で動かします。ui.ts/controls.tsは同じスキーマからタッチ向けの設定シートを描きます。?perf=1で HUD(perf.ts)を出します: fps、表示中のコメント数、取りこぼし、フレーム p95、Long Task。重いレンダラー最適化は、実機が docs/PLAN.ja.md の基準を満たさなかったときだけ入れます。
エンジン類のファイルには shared/ ディレクトリもビルド工程もありません。globalThis.SYC* を公開するクラシックスクリプトを両方のツリーに置いています。
| ファイル | 拡張と web の関係 |
|---|---|
scoring.js, filter.js |
バイト単位で同一。違えば npm run check:shared-drift が失敗 |
emoji.js |
同一 |
translate.js |
同じロジック。web 側は oxfmt で整形 |
danmaku.js |
意図的な分岐。それぞれ向けに調整 |
settings.js |
意図的な分岐。スキーマは同じで既定値が違う |
既定値の違い(SYCSettings.PROFILE が対象を示し、テストがこの値を固定します。別のテストでエンジンの DEFAULTS と toEngineConfig(DEFAULTS) の一致も守っています):
| 設定 | 拡張(デスクトップ) | web(モバイル) |
|---|---|---|
fontPx |
24 | 18 |
maxActive |
2000 | 250 |
maxQueue |
2400 | 1000 |
spawnPerFrame |
10 | 6 |
renderScalePct |
75 | 60 |
dedup |
オフ | オン |
listEnabled |
— | オン(web のみ) |
メッセージや採点の形を変えるときは、必ず docs/CONTRACT.ja.md も一緒に変えます。
| 対象 | 依存しているもの | 最初に確かめる方法 |
|---|---|---|
| 拡張・抽出 | 要素名 yt-live-chat-*-renderer、#items、#message、#author-name、author-type 属性 |
ライブ配信で npm run test:ext:youtube |
| 拡張・ページ | ytd-live-chat-frame[collapsed]、.html5-video-player、.ytp-right-controls、yt-player-video-progress メッセージ |
同上 |
| 中継 | twoColumnWatchNextResults.conversationBar.liveChatRenderer、RENDERERS のキー、continuation データのキー、クライアントバージョン |
/health?deep=1、node worker/test/probe.mjs |
.
├── extension/ Chrome MV3 拡張(素の JS、ビルド工程なし)
│ ├── manifest.json, background.mjs, background.js
│ ├── content.js チャット抽出(チャット iframe)+ ステージの管理(トップフレーム)
│ ├── stage.html, stage.js エンジンが動くフレーム
│ ├── danmaku.js, scoring.js, filter.js, settings.js, sanitize.js, emoji.js, translate.js
│ ├── options.*, popup.*, _locales/, icons/
│ └── test/
├── web/ モバイル PWA(TypeScript モジュール + 共有のクラシックスクリプト)。web/dist にビルド
├── worker/ Cloudflare Worker の中継(src/index.ts, src/innertube.ts, test/)
├── bench/ レンダラーのベンチマーク、カクつきのトレース、ブラウザ e2e(bench/e2e)
├── sandbox/ チャットを模擬するローカルページ(npm run sandbox)
├── scripts/ テスト実行、セキュリティゲート、パッケージング、ストア提出、バージョン更新
└── docs/ 下表
| ドキュメント | 内容 |
|---|---|
| README.ja.md | インストール、現状、開発コマンド |
| docs/CONTRACT.ja.md | ChatMessage、ポーリングのエンベロープ、ScoreInput、ScoreResult、レンダープラン |
| docs/SCORING.ja.md | コメントの採点方法と例 |
| docs/PERFORMANCE.ja.md | 計測したボトルネックと対策 |
| docs/SECURITY.ja.md | 脅威モデルとリリース時のセキュリティゲート |
| docs/PRIVACY.ja.md | プライバシーポリシー(ストア掲載からリンク) |
| docs/RELEASE.ja.md | リリースの流れ、ストア自動化、ストア掲載情報 |
| docs/PLAN.ja.md | 残作業、着手条件、移行メモ |
| CONTRIBUTING.md, AGENTS.md | チェック、守る境界、担当 |