コンテンツスクリプト

コンテンツスクリプトは、特定のウェブページのコンテキストで実行される拡張機能の一部です(拡張機能の一部であるバックグラウンドスクリプトや、ウェブサイト自体の一部であるスクリプト、例えば <script> 要素みたいなものと対をなすような)。

バックグラウンドスクリプト (en-US)はすべての WebExtension JavaScript API にアクセスできますが、ウェブページのコンテンツに直接アクセスすることはできません。だからあなたの拡張機能がそれを行う必要がある場合は、コンテンツスクリプトが必要です。

通常のウェブページで読み込まれたスクリプトと同様に、コンテンツスクリプトは、標準の DOM API を使用してページのコンテンツを読み取り、変更することができます。

コンテンツスクリプトは、WebExtension API の小さなサブセットにしかアクセスできませんが、メッセージングシステムを使用して バックグラウンドスクリプトと通信し、WebExtension API に間接的にアクセスすることができます。

メモ: コンテンツスクリプトは次のドメインでブロックされます。

  • accounts-static.cdn.mozilla.net
  • accounts.firefox.com
  • addons.cdn.mozilla.net
  • addons.mozilla.org
  • api.accounts.firefox.com
  • content.cdn.mozilla.net
  • discovery.addons.mozilla.org
  • input.mozilla.org
  • install.mozilla.org
  • oauth.accounts.firefox.com
  • profile.accounts.firefox.com
  • support.mozilla.org
  • sync.services.mozilla.com
  • testpilot.firefox.com

これらドメインのページにコンテンツスクリプトを挿入しようとすると、そのスクリプトは失敗し、ページは CSP エラーをログに記録します。

これらの制限は addons.mozilla.org を含んでいるので、ユーザーはインストール直後に拡張機能を使用しようとし、それが動作しないことに気付くかもしれません。 適切な警告を追加したり、オンボーディングページを追加して、ユーザーを addons.mozilla.org から遠ざけたりするとよいでしょう。

コンテンツスクリプトの読み込み

次の 3 つの方法のいずれかを使用して、ウェブページにコンテンツスクリプトを読み込むことができます。

  1. インストール時に、URL パターンに一致するページ内へ。
  2. 実行時に、URL パターンに一致するページ内へ。
    • : contentScripts API を使って、URL が指定されたパターンに一致するページを読み込むたびにコンテンツスクリプトを読み込むようブラウザーに依頼できます。これは方法 1 と似ていますが、実行時にコンテンツスクリプトを追加/削除できる点が異なります。)
  3. 実行時に、特定のタブへ。

フレームごと、拡張機能ごとのグローバルスコープしかありません。これは 1 つのコンテンツスクリプトの変数は、読み込み方にかかわらず、他のコンテンツスクリプトからアクセスできることになります。

方法 (1) と (2) では一致パターンを使って表現された URL のスクリプトだけを読み込みできます。

方法 (3) では、拡張機能と一緒にパッケージされたページのスクリプトも読み込みできますが、"about:debugging" や "about:addons"のような権限つきページにはスクリプトを読み込めません。

メモ: ダイナミック JS モジュールインポートがコンテンツスクリプトで動作するようになりました。詳しくはFirefox バグ 1536094を参照してください。 moz-extension スキームを持つ URL のみが許可され、データ URL は除外されます (Firefox バグ 1587336)。

コンテンツスクリプト環境

DOM アクセス

コンテンツスクリプトは、普通のページスクリプトと同様に、ページの DOM にアクセスして修正できます。ページスクリプトにてなされた DOM の変更を見ることもできます。

しかし、コンテンツスクリプトは DOM の「きれいな」見た目を取得します。すなわち、

  • コンテンツスクリプトはページスクリプトにて定義された JavaScript 変数を見ることができない
  • ページスクリプトが組み込み DOM プロパティを再定義した場合、コンテンツスクリプトはそのプロパティの(再定義後でなく)オリジナル値を見ている

Firefox では、この挙動は Xray vision (en-US) と呼ばれます。

次のようなウェブページを考えてみてください。

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
  </head>

  <body>
    <script src="page-scripts/page-script.js"></script>
  </body>
</html>

"page-script.js" スクリプトは次を実行します。

js
// page-script.js

// add a new element to the DOM
let p = document.createElement("p");
p.textContent = "This paragraph was added by a page script.";
p.setAttribute("id", "page-script-para");
document.body.appendChild(p);

// define a new property on the window
window.foo = "This global variable was added by a page script";

// redefine the built-in window.confirm() function
window.confirm = () => {
  alert("The page script has also redefined 'confirm'");
};

今度は拡張機能がページにコンテンツスクリプトを挿入します。

js
// content-script.js

// can access and modify the DOM
let pageScriptPara = document.getElementById("page-script-para");
pageScriptPara.style.backgroundColor = "blue";

// can't see properties added by page-script.js
console.log(window.foo); // undefined

// sees the original form of redefined properties
window.confirm("Are you sure?"); // calls the original window.confirm()

逆も同様で、ページスクリプトはコンテンツスクリプトが追加した JavaScript のプロパティを見ることができません。

これは、コンテンツスクリプトが、ページスクリプトからの変数と衝突することを心配することなく、予測可能な動作をする DOM プロパティに頼っていることを意味しています。

この動作の実用的な結果の一つは、コンテンツスクリプトが、ページによって読み込まれたいかなる JavaScript ライブラリーにもアクセスできないことです。そのため、例えばページに jQuery が記載されていても、コンテンツスクリプトはそれを見ることができません。

コンテンツスクリプトが JavaScript ライブラリーを使用する必要がある場合、そのライブラリー自体は、それを使用したいコンテンツスクリプトと 並べて 挿入すべきです。

json
"content_scripts": [
  {
    "matches": ["*://*.mozilla.org/*"],
    "js": ["jquery.js", "content-script.js"]
  }
]

メモ: Firefox ではコンテンツスクリプトからページスクリプトによって生成された JavaScript オブジェクトにアクセスしたり、ページスクリプトにコンテンツスクリプトの JavaScript オブジェクトを公開できるようにする API が提供されます。

詳しくはページスクリプトとオブジェクトを共有する (en-US)のページを見てください。

WebExtension API

標準 DOM API に加え、コンテンツスクリプトは次の WebExtension API を使用できます。

extension から:

runtime から:

i18n から:

menus から:

すべてから:

XHR と Fetch

コンテンツスクリプトは通常の window.XMLHttpRequestwindow.fetch() API を使ってリクエストを作成できます。

メモ: Firefox では、コンテンツスクリプトの(例えば、fetch() を使った)リクエストは、拡張機能のコンテキストで起こるので、ページコンテンツを参照する URL を絶対 URL で提供せねばなりません。

Chrome では、リクエストはページのコンテキストで起こるので、相対 URL で行われます。例えば、/apihttps://[現在のページの URL]/api に送られます。

コンテンツスクリプトは拡張機能の他の部分と同一のクロスドメイン権限を取得します。よって拡張機能が manifest.jsonpermissions キーを使ってあるドメインのクロスドメインアクセスを要求している場合、コンテンツスクリプトも同様にそのドメインのアクセスを取得します。

メモ: Manifest V3 を使用する場合、出力先サーバーが CORS を使用してオプトインするとき、コンテンツスクリプトはオリジン間リクエストを実行できます。ただし、コンテンツスクリプトではホスト権限は動作しませんが、通常の拡張ページではまだ動作しています。

これは、コンテンツスクリプトでより特権的な XHR とフェッチインスタンスを公開することによって達成されます。これは、ページ自身からのリクエストのように、Origin および Referer ヘッダーを設定しない副作用があります。これは、クロスオリジンの性質を明らかにしないリクエストを行うにはよく望ましいとされることです。

メモ: Manifest V2 の Firefox では、コンテンツ自身によって送信されたかのように振る舞うリクエストを実行する必要がある拡張機能は、代わりに content.XMLHttpRequestcontent.fetch() を使用することができます。

クロスブラウザー拡張機能にとってこれらの存在は機能検出となります。

Manifest V3 では content.XMLHttpRequestcontent.fetch() が利用できないため、このようなことは起こりえません。

メモ: Chrome ではバージョン 73 から、Firefox ではバージョン 101 からマニフェスト V3 を使用する場合、コンテンツスクリプトは、その中で実行されるページと同じ CORS ポリシーが適用されるようになりました。バックエンドスクリプトのみ、昇格したクロスドメイン特権があります。Chrome Extension コンテンツスクリプトにおける Cross-Origin Requests の変更点を参照してください。

バックグラウンドスクリプトとの通信

コンテンツスクリプトは WebExtension の API のほとんどを直接使用することはできませんが、メッセージング API を使用して拡張機能のバックグラウンドスクリプトと通信できるため、バックグラウンドスクリプトが使用できるのと同じ API にすべて間接的にアクセスすることができます。

バックグラウンドスクリプトとコンテンツスクリプトの間の通信には、基本的な 2 つのパターンがあります。

  • 単発のメッセージ(オプションのレスポンス付き)を送信することができます。
  • 両者の間に長寿命のコネクションを設定し、そのコネクションを使用してメッセージを交換することができます。

単発のメッセージ

レスポンスが必須でない単発のを送るには、次の API を使います。

コンテンツスクリプト内 バックグラウンドスクリプト内
メッセージの送信 browser.runtime.sendMessage() browser.tabs.sendMessage() (en-US)
メッセージの受信 browser.runtime.onMessage browser.runtime.onMessage

例えば、ウェブページでのクリックイベントを待ち受けするコンテンツスクリプトがここにあります。

クリックがリンク上である場合、ターゲット URL をバックグラウンドページにメッセージします。

js
// content-script.js

window.addEventListener("click", notifyExtension);

function notifyExtension(e) {
  if (e.target.tagName !== "A") {
    return;
  }
  browser.runtime.sendMessage({ url: e.target.href });
}

バックグラウンドスクリプトはこのメッセージを待ち受けして、notifications API を使って通知を表示します。

js
// background-script.js

browser.runtime.onMessage.addListener(notify);

function notify(message) {
  browser.notifications.create({
    type: "basic",
    iconUrl: browser.extension.getURL("link.png"),
    title: "You clicked a link!",
    message: message.url,
  });
}

この例のコードは GitHub の notify-link-clicks-i18n のサンプルから簡単に適用できます。

コネクションベースのメッセージ

バックグラウンドスクリプトとコンテンツスクリプトの間で多くのメッセージを交換する場合、単発のメッセージの送信は面倒になることがあります。そこで、 2 つのコンテキスト間でより詳しい接続を確立し、この接続を使用してメッセージを交換するという方法があります。

いずれの側にも runtime.Port (en-US) オブジェクトがあり、メッセージ交換に使うことができます。

コネクションを作成するには次のようにします。

これは runtime.Port (en-US) オブジェクトを返します。

それぞれがポートを持ったら、両方が、

  • runtime.Port.postMessage() でメッセージを送って
  • runtime.Port.onMessage でメッセージを受信できるようになる。

例えば、ロードしたらすぐに、このコンテンツスクリプトは、

  • バックグラウンドに接続し
  • myPort 変数に Port を格納する
  • myPort のメッセージを待ち受けする (ログに出す)
  • ユーザーがドキュメントをクリックしたとき、バックグラウンドスクリプトに myPort を使ってメッセージを送る
js
// content-script.js

let myPort = browser.runtime.connect({ name: "port-from-cs" });
myPort.postMessage({ greeting: "hello from content script" });

myPort.onMessage.addListener((m) => {
  console.log("In content script, received message from background script: ");
  console.log(m.greeting);
});

document.body.addEventListener("click", () => {
  myPort.postMessage({ greeting: "they clicked the page!" });
});

対応するバックグラウンドスクリプトは、

  • コンテンツスクリプトからの通信試行を待ち受けする
  • 通信試行を受け取ったとき、
    • portFromCS という名前の変数にポートを格納する
    • そのポートを使ってコンテンツスクリプトにメッセージを送る
    • ポートに届いたメッセージを待ち受けしてログに出す
  • ユーザーが拡張機能のブラウザーアクションをクリックしたとき、portFromCS を使ってコンテンツスクリプトにメッセージを送る
js
// background-script.js

let portFromCS;

function connected(p) {
  portFromCS = p;
  portFromCS.postMessage({ greeting: "hi there content script!" });
  portFromCS.onMessage.addListener((m) => {
    portFromCS.postMessage({
      greeting: `In background script, received message from content script: ${m.greeting}`,
    });
  });
}

browser.runtime.onConnect.addListener(connected);

browser.browserAction.onClicked.addListener(() => {
  portFromCS.postMessage({ greeting: "they clicked the button!" });
});

複数のコンテンツスクリプト

同時に複数のコンテンツスクリプトが通信する場合、各接続を配列に格納するのが良いかもしれません。

js
// background-script.js

let ports = [];

function connected(p) {
  ports[p.sender.tab.id] = p;
  // …
}

browser.runtime.onConnect.addListener(connected);

browser.browserAction.onClicked.addListener(() => {
  ports.forEach((p) => {
    p.postMessage({ greeting: "they clicked the button!" });
  });
});

単発メッセージとコネクションベースのメッセージとの選択

単発とコネクションベースのメッセージの選択は、拡張機能がどうメッセージを利用すると期待されるかに依存します。

推奨されるベストプラクティスは、次の通りです。

  • 単発メッセージを使用する場合
    • メッセージに 1 つだけの応答がある場合
    • メッセージの受信を少しのスクリプトが待ち受けする場合(runtime.onMessage 呼び出し)
  • コネクションベースのメッセージを使用する場合
    • スクリプトが、複数のメッセージを交換するセッションに関わる場合
    • 拡張機能がタスクの進捗や、タスクが中断されたのを知る必要がある場合、または初期化されたタスクをメッセージング経由で中断したい場合

ウェブページとの通信

既定では、コンテンツスクリプトはページスクリプトが作成したオブジェクトにアクセスできませんが、DOM window.postMessagewindow.addEventListener API を使ってページスクリプトと通信できます。

例えば:

js
// page-script.js

let messenger = document.getElementById("from-page-script");

messenger.addEventListener("click", messageContentScript);

function messageContentScript() {
  window.postMessage(
    {
      direction: "from-page-script",
      message: "Message from the page",
    },
    "*",
  );
}
js
// content-script.js

window.addEventListener("message", (event) => {
  if (
    event.source === window &&
    event?.data?.direction === "from-page-script"
  ) {
    alert(`Content script received message: "${event.data.message}"`);
  }
});

これの完全な動作サンプルは、GitHub のデモページに行って指示に従ってください。

警告: この方法で信頼できないウェブコンテンツと相互作用するには細心の注意が必要です! 拡張機能は強力な力を持つコードの権限があり、敵意のあるウェブページは簡単にこの力にアクセスします。

細かい例を作るには、メッセージを受け取ったコンテンツスクリプトがこのようなことを行うと仮定してください:

js
// content-script.js

window.addEventListener("message", (event) => {
  if (
    event.source === window &&
    event?.data?.direction === "from-page-script"
  ) {
    eval(event.data.message);
  }
});

今やページスクリプトはコンテンツスクリプトのすべての権限でコードを実行できます。

コンテンツスクリプト内で eval() を使う

メモ: eval() はマニフェスト V3 では利用できません。

Chrome では

eval は常にページコンテキストではなくてコンテンツスクリプトのコンテキストで動作します。

Firefox では

eval() を呼ぶ場合、コンテンツスクリプトのコンテキストで動作します。

window.eval() を呼ぶ場合、ページのコンテキストで動作します。

例えば、こんなコンテンツスクリプトを考えてみます。

js
// content-script.js

window.eval("window.x = 1;");
eval("window.y = 2");

console.log(`In content script, window.x: ${window.x}`);
console.log(`In content script, window.y: ${window.y}`);

window.postMessage(
  {
    message: "check",
  },
  "*",
);

このコードは単に変数 x と y を、window.eval()eval() を用いて作成し、値をログに出し、ページにメッセージします。

メッセージの受信に際し、ページスクリプトは同じ変数をログに出します。

js
window.addEventListener("message", (event) => {
  if (event.source === window && event.data && event.data.message === "check") {
    console.log(`In page script, window.x: ${window.x}`);
    console.log(`In page script, window.y: ${window.y}`);
  }
});

Chrome では、こんな出力が生成されます:

In content script, window.x: 1
In content script, window.y: 2
In page script, window.x: undefined
In page script, window.y: undefined

Firefox では、こんな出力が生成されます:

In content script, window.x: undefined
In content script, window.y: 2
In page script, window.x: 1
In page script, window.y: undefined

同じことは setTimeout()setInterval()Function() にも言えます。

警告: ページのコンテキストでコードを実行するときは特に注意してください!

ページの環境が悪意をはらんだウェブページにコントロールされ、期待しない方法であなたが操作するオブジェクトを再定義するかもしれません。

js
// page.js redefines console.log

let original = console.log;

console.log = () => {
  original(true);
};
js
// content-script.js calls the redefined version

window.eval("console.log(false)");