Chrome拡張の作り方をManifest V3で。content scriptと権限でつまずかない最短ルート
Chrome拡張をManifest V3で作る手順を実例で。content script・service worker・popupの役割、permissionsの絞り方、ストア公開までコピペで動くコード付き。
「Chrome拡張なんて、HTMLとJSをちょっと書けば1時間で終わるでしょ」
僕も最初はそう思っていました。で、AIに「選択した文字をハイライトする拡張作って」と雑に頼んだら、たしかに動くコードは出てきた。<all_urls> で全サイトに割り込み、tabs 権限を握り、外部CDNからスクリプトを読む、いわば全部入りの拡張です。
そのままChromeに読み込んだら、インストール画面に「あなたのすべてのウェブサイト上の全データの読み取りと変更」という、見るからにヤバそうな警告がドカンと出ました。自分で使うだけならまだしも、これをストア審査に出したら一発で止まります。
拡張機能って、見た目は小さいのに中身はちゃんとした「小さなプロダクト」なんですよね。今日はそこを、Manifest V3の作法に沿って、つまずきやすいポイントだけ拾いながら作っていきます。
この記事の要点
- Chrome拡張はManifest V3が現在の標準。
manifest.jsonを入口に、content script・service worker・popup の3役で組み立てる。 - 権限は
permissions(何ができるか)とhost_permissions(どのサイトを触れるか)の2種類。最初は両方をギリギリまで絞るのがコツ。広げると警告も審査の説明も重くなる。 - service worker は常駐しない。状態はメモリではなく
chrome.storageに置く。 - content script はページのDOMを触れるが、拡張APIを全部は呼べない。足りない処理は メッセージング で service worker に頼む。
- この記事のコードは
https://example.com/*だけで動く最小構成。コピペして「Load unpacked」すればそのまま読み込める。
まず3つの役割を、たとえ話で理解する
Manifest V3、service worker、content script——名前だけ見ると身構えますが、役割はシンプルです。お店にたとえると、こうなります。
- manifest.json は、お店の営業許可証。「この店は何屋で、どこに出店していて、何をやっていいか」を役所(Chrome)に届け出る書類です。
- content script は、店先に立つ接客係。お客さん(Webページ)に直接触れて、文字を読んだり色を塗ったりします。ただし金庫(拡張API)は開けられません。
- service worker は、奥の事務所にいる裏方。普段は休憩していて、注文(イベント)が来たときだけ起きて処理し、終わるとまた寝ます。
- popup は、ツールバーのアイコンを押すと出る小さな窓。設定のオン・オフみたいな操作をここで受けます(今回の最小構成では省きますが、後半で足し方を説明します)。
接客係と裏方は直接お金のやり取りができないので、**伝票(メッセージ)**を回します。これが「メッセージング(message passing)」で、JSONを送り合うだけのシンプルな仕組みです。
この4つの分担さえ頭に入れば、あとはどこに何を書くかで迷いません。逆にここが曖昧なまま書き始めると、「content scriptで chrome.tabs 呼んだら動かない」みたいな、よくあるハマり方をします。
今回作るもの:example.comで選択テキストをハイライト
題材は小さいほどいいです。https://example.com 上で、右クリックメニューから選択した文字をマーカーで光らせる拡張を作ります。
あえてpopupもオプション画面も入れません。context menu(右クリックメニュー)、service worker、content script、storage、メッセージングだけに絞ります。小さく作るほど、権限の説明もストア審査も楽になるからです。
ファイル構成はこれだけ。このまま作れば、Chromeの「Load unpacked(パッケージ化されていない拡張機能を読み込む)」で読み込めます。
mv3-highlighter/
manifest.json
service-worker.js
content-script.js
package.json
playwright-extension-smoke.mjs
manifest.json:営業許可証を最小で書く
manifest.json が拡張の入口です。Chrome公式のManifestリファレンスによると、すべての拡張にこのファイルが必要で、manifest_version、name、version を最低限定義します。
ポイントは content_scripts.matches を https://example.com/* だけに限定し、host_permissions を一切足さないことです。content scriptの静的マッチで対象を絞れているので、広い host_permissions はいりません。
{
"manifest_version": 3,
"name": "Claude Code MV3 Highlighter",
"version": "0.1.0",
"description": "Highlights selected text on example.com from a context menu.",
"action": {
"default_title": "MV3 Highlighter"
},
"permissions": ["contextMenus", "storage"],
"background": {
"service_worker": "service-worker.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["https://example.com/*"],
"js": ["content-script.js"],
"run_at": "document_idle"
}
]
}
permissionsとhost_permissionsの違いで毎回つまずく
ここが拡張開発でいちばん混乱するところなので、表で整理します。Chrome公式の権限の宣言ガイドでも、permissions と host_permissions は別物として扱われています。
permissions: 「何ができるか」。storage、contextMenus、tabsのような決まった名前のリスト。host_permissions: 「どのサイトを触れるか」。https://example.com/*のようなURLパターン。
| 項目 | 今回の指定 | 理由 | あえて入れないもの |
|---|---|---|---|
| API permissions | contextMenus, storage | 右クリックメニューと設定保存だけに使う | tabs, scripting, downloads |
| 対象ページ | https://example.com/*(content_scripts側) | デモを1ドメインに絞る | <all_urls> |
| host permissions | なし | content scriptの静的マッチで足りる | 広い host_permissions |
| 外部コード | なし | MV3はリモートコード実行を禁止 | CDNスクリプト、eval |
なぜここまで絞るのか。権限を増やすほど、ユーザーのインストール警告が重くなり、審査での説明も増えるからです。「あとで使うかも」で tabs を入れた瞬間、「閲覧履歴の読み取り」みたいな警告が追加されます。冒頭の僕の失敗がまさにこれでした。
もし「ユーザーが今見ているタブだけ、ボタンを押したときに触りたい」なら、activeTab という権限が便利です。これは常時アクセスではなく、ユーザーが拡張を起動した瞬間だけ現在のタブへのアクセスを与えるので、警告がほぼ出ません。広い権限を入れる前に、まず activeTab で足りないかを考える癖をつけると安全です。
service workerでイベントを受ける(常駐しないのが肝)
MV3のservice workerは、Manifest V2の古いbackground pageと違って常駐しません。公式ドキュメントが説明するとおり、イベントが来たときに起きて、処理が終わると眠ります。
だから let cache = {} のようなメモリ上の変数に大事な状態を置くと、次のイベントのときには消えています。設定や状態は chrome.storage.local に保存し、起動するたびに読み直す。これがMV3の基本姿勢です。ストレージAPIの詳しい仕様はchrome.storageを見てください。
const MENU_ID = "highlight-selection";
const DEFAULT_SETTINGS = {
enabled: true,
color: "#fff176"
};
// 状態はメモリに持たず、毎回ストレージから読む
async function readSettings() {
return chrome.storage.local.get(DEFAULT_SETTINGS);
}
// インストール時に初期設定を保存し、右クリックメニューを登録する
chrome.runtime.onInstalled.addListener(async () => {
const settings = await readSettings();
await chrome.storage.local.set(settings);
chrome.contextMenus.create({
id: MENU_ID,
title: "Highlight selected text",
contexts: ["selection"],
documentUrlPatterns: ["https://example.com/*"]
});
});
// メニューがクリックされたら、対象タブのcontent scriptへ伝票を送る
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== MENU_ID || !tab?.id || !info.selectionText) {
return;
}
const settings = await readSettings();
await chrome.tabs.sendMessage(tab.id, {
type: "HIGHLIGHT_SELECTION",
text: info.selectionText.slice(0, 120),
enabled: settings.enabled,
color: settings.color
});
});
// popupなどからの問い合わせ・保存に応答する
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "GET_SETTINGS") {
readSettings().then(sendResponse);
return true; // 非同期で返すのでチャンネルを開いたままにする
}
if (message?.type === "SAVE_SETTINGS") {
const nextSettings = {
enabled: Boolean(message.enabled),
color: String(message.color || DEFAULT_SETTINGS.color)
};
chrome.storage.local.set(nextSettings).then(() => {
sendResponse({ ok: true, settings: nextSettings });
});
return true;
}
return false;
});
ここでの一番の落とし穴は、sendResponse を非同期で呼ぶのに return true を忘れることです。これをやると、応答が返る前に通信チャンネルが閉じて、The message port closed before a response was received というエラーが出ます。Chromeのメッセージングでは、非同期で返すときだけ return true でチャンネルを開いたままにする——これは丸暗記でいいです。詳しくはChromeのMessage passingを参照してください。
content scriptでDOMを安全に触る
content scriptはページのDOMを読んだり書き換えたりできます。でも拡張APIを全部は呼べません。chrome.storage の一部や chrome.runtime.sendMessage は使えても、chrome.tabs のような強い権限は基本的に使えない。足りない処理はメッセージングでservice workerに依頼します。
もうひとつ大事なのが、content scriptはページ本体のJavaScriptとは別の実行環境で動くこと。これは安全のための分離ですが、「ページ側の変数を直接読めない」というつまずきにもなります。接客係はお客さんに触れても、お客さんの財布の中身までは覗けない、と思えば腑に落ちます。content scriptの制約はChromeのcontent scriptsガイドとMDNのContent scriptsが詳しいです。
const HIGHLIGHT_CLASS = "cc-mv3-highlight";
// テスト用の目印。あとでPlaywrightがこれを見て「入った」と判定する
document.documentElement.dataset.ccMv3Highlighter = "ready";
// フォームやscript/styleなど、触ると壊れる場所はスキップする
function shouldSkipNode(node) {
const parent = node.parentElement;
if (!parent) return true;
return Boolean(
parent.closest(
`script, style, textarea, input, [contenteditable="true"], .${HIGHLIGHT_CLASS}`
)
);
}
// 既存のハイライトを元のテキストに戻す(先に用意しておくのが大事)
function clearHighlights() {
for (const mark of document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)) {
const text = document.createTextNode(mark.textContent || "");
mark.replaceWith(text);
text.parentElement?.normalize();
}
}
function createMark(text, color) {
const mark = document.createElement("mark");
mark.className = HIGHLIGHT_CLASS;
mark.textContent = text;
mark.style.backgroundColor = color;
mark.style.color = "#111";
mark.style.padding = "0 2px";
return mark;
}
function highlightText(term, color) {
const query = term.trim();
if (!query) return 0;
clearHighlights();
// テキストノードだけを安全に走査する
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.toLowerCase().includes(query.toLowerCase())
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
}
});
const matches = [];
while (walker.nextNode()) {
const node = walker.currentNode;
const index = node.nodeValue.toLowerCase().indexOf(query.toLowerCase());
if (index >= 0) matches.push({ node, index });
}
// 後ろから処理してノード分割のズレを防ぐ
for (const { node, index } of matches.reverse()) {
const selected = node.splitText(index);
selected.splitText(query.length);
selected.replaceWith(createMark(selected.nodeValue, color));
}
return matches.length;
}
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type !== "HIGHLIGHT_SELECTION") {
return false;
}
if (!message.enabled) {
clearHighlights();
sendResponse({ ok: true, count: 0 });
return false;
}
const count = highlightText(String(message.text || ""), message.color || "#fff176");
sendResponse({ ok: true, count });
return false;
});
正直に言うと、このコードは複雑なHTMLに完全対応した万能ハイライトではありません。1つのテキストノード内の最初の一致だけを対象にし、フォーム・script・style・contenteditableは避けています。実プロダクトなら、複数一致、Shadow DOM、後から差し込まれる本文、既存の mark との衝突を追加でテストします。最小で動かして、必要になったら広げる。この順番が結局いちばん早いです。
Playwrightで「ちゃんと読み込まれたか」を自動チェックする
拡張の完全なE2Eテストは正直しんどいです。でも「Chromeが拡張を読み込んだ」「content scriptが対象ページに入った」くらいは自動化できます。ここを自動チェックにしておくと、コードをいじるたびに手で確認する手間が消えます。
Playwrightの永続コンテキストlaunchPersistentContextを使って、拡張を読み込んだ状態のブラウザを立ち上げます。
{
"type": "module",
"scripts": {
"smoke": "node playwright-extension-smoke.mjs"
},
"devDependencies": {
"playwright": "^1.52.0"
}
}
import { chromium } from "playwright";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const extensionPath = path.resolve(__dirname);
const userDataDir = path.resolve(__dirname, ".pw-extension-profile");
// 拡張を読み込んだ状態のChromeを起動する
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false,
args: [
`--disable-extensions-except=${extensionPath}`,
`--load-extension=${extensionPath}`
]
});
const page = await context.newPage();
await page.goto("https://example.com/");
// content scriptが付けた目印が現れるまで待つ
await page.waitForFunction(() => {
return document.documentElement.dataset.ccMv3Highlighter === "ready";
});
console.log("Extension content script is loaded on https://example.com/");
await page.waitForTimeout(3000);
await context.close();
実行はこれだけです。
npm install
npm run smoke
自動チェックを通したら、手動確認も一度はやっておきます。chrome://extensions でDeveloper mode(デベロッパーモード)をオンにし、「Load unpacked」から mv3-highlighter フォルダを選ぶ。https://example.com/ を開いて見出しの一部を選択し、右クリックから「Highlight selected text」を実行。マーカーが付いて、Service workerのコンソールにエラーが出ていなければ、最初の合格です。
僕がやらかした落とし穴5つ
拡張開発で実際に踏んだ地雷を、正直に並べます。
1つ目は、tabs 権限をなんとなく入れたこと。 今回のコードはcontext menuのクリックイベントから tab.id を受け取ってメッセージを送るだけなので、tabs はいりません。なのに「便利そう」で入れたら、警告が一段重くなりました。タブのURLやタイトルを広く読む必要が出るまで入れない、が正解です。
2つ目は、content scriptに秘密情報を置いたこと。 content scriptはページのDOMに触れる以上、広告やユーザー入力、ページの改変の影響をモロに受けます。APIキーやClaudeのトークン、顧客データをここに置くのは厳禁。必要なら service worker側で最小限だけ扱います。
3つ目は、service workerの寿命を勘違いしたこと。 さっき書いたとおり、MV3のservice workerは処理後に止まります。let cache = {} に頼ったら次のイベントで空っぽ。設定・キュー・最終実行時刻はstorageに置いて、起動時に読み直す設計にしましょう。
4つ目は、innerHTML でDOMを雑に書き換えたこと。 一括置換は手っ取り早いですが、イベントハンドラやアクセシビリティ、既存UIを巻き込んで壊します。今回はテキストノードを分割して mark を差し込む形にしていますが、それでも完璧ではありません。
5つ目は、ストア提出の直前に権限を広げたこと。 開発中に <all_urls> で動かして、審査前に戻し忘れる——これが一番やりがちです。最初から狭いドメインで作り、対象を増やすときは「なぜ必要か」をREADMEや審査メモに残しておくと、後の自分が助かります。
popupや設定画面を足したくなったら
最小構成では省きましたが、「色をユーザーに選ばせたい」「オン・オフを切り替えたい」となったらpopupの出番です。難しくありません。manifest.json の action に1行足すだけです。
"action": {
"default_title": "MV3 Highlighter",
"default_popup": "popup.html"
}
あとは popup.html を用意し、その中のスクリプトから chrome.runtime.sendMessage({ type: "SAVE_SETTINGS", enabled: true, color: "#fff176" }) を呼べば、さっきservice workerに書いた SAVE_SETTINGS の受け口がそのまま使えます。popupは開いている間だけ生きる短命なページなので、ここでも状態はstorage経由でやり取りするのが鉄則です。
ストア公開前にそろえるもの
Chrome Web Storeに出す前は、コード以外の品質もチェックします。アイコン、説明文、プライバシーポリシー、スクリーンショット、権限の説明、利用データの扱い。このあたりはChromeの公開準備ガイドに沿ってそろえます。
ZIP化のときは、テスト用のprofileや node_modules を絶対に含めないこと。余計なファイルが入ると審査の手間も増えます。
zip -r mv3-highlighter.zip manifest.json service-worker.js content-script.js
Windowsなら、PowerShellでこう書きます。
Compress-Archive -Path manifest.json,service-worker.js,content-script.js -DestinationPath mv3-highlighter.zip -Force
提出前のチェックリストはこれです。
manifest.jsonのdescriptionが実際の機能を誇張していない(審査担当も読みます)permissionsとcontent_scripts.matchesが最小になっている- service workerの非同期
sendResponseにreturn trueがある - content scriptがフォーム・script・style・contenteditableを壊さない
- storageに秘密情報や不要な個人情報を保存していない
- Playwright smokeと手動チェックの両方を通した
このブログの運用と組み合わせるなら、Claude Codeへの指示の出し方はClaude Code生産性Tips、権限まわりの考え方はClaude Codeセキュリティベストプラクティス、CLIとの連携はClaude Code CLIツール開発が参考になります。CLIで記事を検査し、拡張機能でブラウザ上の表示を確認する、という流れも作れます。
よくある質問
Q. Manifest V2はもう使えませんか?
A. Chromeは段階的にMV2を打ち切っており、新規に作るならMV3一択です。これから学ぶなら最初からMV3で書きましょう。古い記事のMV2サンプルをコピペすると、background.page や常駐前提の書き方で動かなくてハマります。
Q. content scriptで chrome.tabs を呼んだらエラーになります。
A. content scriptは拡張APIを全部は呼べません。chrome.tabs のような強い権限はservice worker側の役目です。content scriptからは chrome.runtime.sendMessage でservice workerに依頼し、結果を受け取る形にしてください。
Q. service workerに書いた変数が、次の処理で消えています。
A. それがMV3の正しい挙動です。service workerはイベント処理が終わると止まり、メモリ上の変数はリセットされます。残したい値は chrome.storage.local に保存し、起動時に読み直してください。
Q. host_permissions と content_scriptsの matches はどう違いますか?
A. content_scriptsの matches は「どのページに自動でスクリプトを差し込むか」、host_permissions は「fetch や chrome.scripting で能動的にどのサイトへアクセスするか」です。今回のように静的に差し込むだけなら matches で足り、host_permissions はいりません。
Q. 権限の警告を減らすにはどうすればいいですか?
A. まず本当に必要な権限だけに絞る。次に、常時アクセスではなくユーザー操作時だけでよいなら activeTab を検討する。さらに後から有効化できる optional_permissions を使えば、インストール時の警告をランタイムの同意に移せます。
実際に試した結果
この記事のコードを一通り動かしてみて、最初に引っかかったのは——意外にもコードそのものではなく、権限の説明でした。
<all_urls> を使えば動作確認は一瞬です。でも「なぜ全サイトへのアクセスが必要なの?」と聞かれたら、答えに詰まる。https://example.com/* に絞り、context menuとstorageだけで作り直したら、Chromeの拡張管理画面でも警告がほとんど出なくなり、コードレビューでの指摘も「ここをこう直せ」と具体的になりました。
MV3拡張は「動いた」をゴールにしないほうがいいです。**「説明できる権限で動いた」**を合格ラインにすると、公開前の手戻りが驚くほど減ります。小さく作って、必要になったら広げる。遠回りに見えて、これが一番速い、というのが今の実感です。
型をまとめて手に入れたい人は教材一覧を、自分のリポジトリで権限レビューやMV3設計を一緒に詰めたい人は研修・導入相談ものぞいてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。