Chrome拡張機能でページの情報を取りたいとき、DOMを見れば足りることが多い。ただしSPAのサイトでは、実際のデータがDOMではなくページ内部のJS変数(window.xxx)にしか存在しないことがある。YouTubeの window.ytInitialData はその代表例で、content scriptから直接参照しようとしても undefined が返ってくる。この現象の原因と対処法をまとめる。

content scriptは既定でページと隔離されている

content scriptはページと同じDOMを参照できるが、JSの実行環境(world)はページ本体とは別になっている。これは「isolated world」と呼ばれる仕組みで、拡張機能がページのグローバル変数を汚さない、逆にページ側のスクリプトが拡張機能の内部処理を覗けない、というセキュリティ上の意図的な設計になっている。

そのため、ページが <script> タグの中で window.ytInitialData = {...} のように変数を定義していても、content scriptから window.ytInitialData を読もうとすると undefined になる。同じ window という名前でも、content script側から見えているのは拡張機能専用の別の実行環境の window になる。

MAIN worldでcontent scriptを動かす

Manifest V3 では、content scriptを world: "MAIN" 指定でページ本体と同じ実行環境に注入できる。manifest.jsoncontent_scripts に、通常のisolated world用と、MAIN world用の2つのエントリを並べて書く。

{
  "content_scripts": [
    {
      "matches": ["https://www.youtube.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    },
    {
      "matches": ["https://www.youtube.com/*"],
      "js": ["injected.js"],
      "run_at": "document_idle",
      "world": "MAIN"
    }
  ]
}

MAIN world側のスクリプト(injected.js)は、ページ自身が書いたスクリプトと同じ扱いになる。だから window.ytInitialData に普通にアクセスできる。

MAIN worldはchrome.*が使えない

ここで注意が必要なのは、MAIN worldはページと同じ実行環境になる代わりに、chrome.runtimechrome.storage といった拡張機能のAPIへのアクセスをほぼ失う点。せっかく取れたデータも、そのままでは拡張機能側の処理(保存する・他のUIに渡すなど)につなげられない。

そこで、MAIN worldからisolated worldへデータを橋渡しする手段として window.postMessage を使う。両方とも同じページの window を経由するので、DOM経由でイベントをやり取りするのと同じ感覚で使える。

// injected.js(world: "MAIN" で実行される)
// ページと同じ実行環境なので window.ytInitialData に直接触れる
(() => {
  function post() {
    window.postMessage(
      { type: 'my-ext-data', payload: window.ytInitialData },
      location.origin
    );
  }
  post();
})();
// content.js(isolated world = 通常のcontent script)
window.addEventListener('message', (e) => {
  // 自分自身のwindowから、想定した type のメッセージだけ受け取る
  if (e.source !== window || !e.data || e.data.type !== 'my-ext-data') return;
  console.log(e.data.payload); // ここで初めて chrome.* API も使える
});

受信側で e.source !== windowe.data.type のチェックを入れているのは、他のiframeやページ内の別スクリプトが送ってくる無関係な message イベントを拾わないため。postMessage の送信先も '*' ではなく location.origin を指定し、想定外のオリジンには送らないようにしている。

読み込みタイミングの罠

実際に組んでみるとハマりやすいのが読み込み順の問題。MAIN world側のスクリプトが先に実行されてデータを1回だけpostMessageしても、isolated world側のリスナーの登録がそれより後になると取りこぼす。SPAのページ遷移やスクリプトの実行順は保証されないので、「1回送って終わり」の設計は事故りやすい。

対処は単純で、isolated world側が起動したタイミングで「再送してほしい」というリクエストを投げ、MAIN world側はそれを受けて改めてpostMessageする形にする。

// injected.js 側:isolated world からの再送要求にも応える
window.addEventListener('message', (e) => {
  if (e.source === window && e.data?.type === 'my-ext-request') post();
});
post(); // 初回分

// content.js 側:起動したらまず再送を要求する
window.postMessage({ type: 'my-ext-request' }, location.origin);

これで、isolated world側のリスナー登録が多少遅れても、明示的に再送要求すれば必ず最新のデータを受け取れる。YouTubeのようにSPA遷移でページ内データが更新されるサイトでは、遷移イベントのたびに同じ流れでpostMessageし直す構成にしておくと安定する。

関連記事

よくある質問

isolated world と MAIN world の違いは?
isolated worldは通常のcontent scriptが動く環境で、ページのDOMは共有するがJSの実行環境(グローバル変数・関数)はページ側と分離されている。MAIN worldはページ自身のJSと同じ実行環境で、ページが定義したwindow上の変数に直接アクセスできる。
MAIN worldのスクリプトから chrome.storage は使える?
使えない。MAIN worldはページと同じ実行環境になる代わりに、ほとんどのchrome.* APIへのアクセスを失う。データはpostMessageなどでisolated world側のcontent scriptに渡し、chrome.* APIはisolated world側で呼び出す必要がある。
postMessageを使うときのセキュリティ上の注意点は?
送信先は targetOrigin に location.origin を指定し、任意のオリジンへ送らない。受信側では event.source が window自身であること、event.data.type が想定した値であることを確認してから処理する。他のスクリプトやiframeからの偽装メッセージを拾わないようにするため。
この仕組みはYouTube以外のサイトでも使える?
使える。SPA(Single Page Application)で、実際のデータがDOMに描画される前段階でページ内部のJS変数に保持されているサイトなら同じ手法が使える。変数名や構造はサイトごとに異なるので、DevToolsのConsoleで window を調べて探す必要がある。