HB DevTools

Knowledge

初めてのChrome拡張機能開発:GitHub PR に LGTM 猫画像をワンクリック挿入する拡張を作った記録

Manifest V3・Content Script・Background Script の3層構造でGitHubのCSP制限を突破する方法、MutationObserverによるSPA対応ボタン注入、カーソル位置への正確なテキスト挿入など、初めてChrome拡張を作る中で得た知見をまとめます。

Chrome拡張機能Manifest V3JavaScript

1. きっかけと要件

GitHub のPRレビューで LGTM コメントをする際、毎回 lgtmeow.com を開いて 画像URLをコピーして貼り付ける、という手間が積み重なっていました。 この作業を GitHub のコメント欄から離れずに完結させるため、 Chrome拡張機能として LGTM Cat Inserter を作ることにしました。 Chrome拡張機能を作るのはこれが初めてです。

要件はシンプルです。GitHub の Markdown ツールバーにボタンを追加し、 クリックすると猫のLGTM画像が5枚表示され、選んだ画像が![LGTM](URL) 形式でコメント欄のカーソル位置に挿入される、 というものです。画像の取得先は lgtmeow.com の公式APIを使います。

2. 3層アーキテクチャとGitHub CSPの壁

最初に Content Script から直接 lgtmeow.com の API をfetch しようとしましたが、これは動作しません。 GitHub は HTTP レスポンスヘッダーに強力な Content Security Policy(CSP) を付与しており、 接続先ドメインを許可リストで制限しています。lgtmeow.com はそのリストにないため、ブラウザがリクエストをブロックします。

ここで重要なのが、CSP は「どのページが読み込んだスクリプトか」によって適用されるという点です。 Content Script はページの DOM に注入されるため、たとえ拡張機能のファイルであっても 「GitHub のページ上で動いているスクリプト」として扱われ、GitHub の CSP に従います。ファイルを別ファイルに分けただけでは CSP を回避できません。

CSP の管轄外になるのが Background Script(Service Worker) です。 Background Script は どのタブにも属さない拡張機能専用のコンテキスト(オリジン: chrome-extension://...)で独立して動作するため、 GitHub の CSP ルールがそもそも届きません。

ブラウザ全体
├── タブ(github.com)
│   └── content.js が注入されて動く  ← github.com の CSP が適用される
└── 拡張機能 Service Worker           ← どのタブにも属さず独立して動く
    └── background.js                   ← github.com の CSP は無関係

直感的なたとえとして、会社(GitHub)の規則「社内から外部に電話をかけるときは許可リストの番号だけ」を想像してください。 Content Script は社内に派遣された外部スタッフなので社内規則に縛られますが、 Background Script は社外にいる本社の社員なので、その規則の管轄外です。どこで・誰として動いているかが決め手であり、ファイルの分け方の問題ではありません。

この非対称性を活かして、Content Script から chrome.runtime.sendMessageでメッセージを送り、Background Script が代わりに外部 API を叩いてレスポンスを返す、 という3層構造で設計します。chrome.runtime.sendMessage はブラウザ拡張機能の 内部メッセージバスであり HTTP 通信ではないため、CSP の connect-src 対象になりません。

  • Manifest V3: 権限管理と各スクリプトの紐付け
  • Content Script: GitHubのDOM操作(ボタン注入・ダイアログ表示・テキスト挿入)。CSP 適用対象
  • Background Script: 外部APIへのリクエストを代行。CSP管轄外のコンテキストで動作

3. Manifest V3 の設定

manifest.json では、動作させるURLと必要な権限を宣言します。host_permissions に GitHub と lgtmeow.com の両方を指定することがポイントです。 Content Script が inject される対象ページは content_scriptsmatches で指定し、CSS も合わせて注入できます。

{
  "manifest_version": 3,
  "name": "LGTM Cat Inserter",
  "version": "1.0",
  "permissions": ["activeTab"],
  "host_permissions": [
    "https://github.com/*",
    "https://lgtmeow.com/*",
    "https://*.lgtmeow.com/*"
  ],
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["https://github.com/*"],
      "js": ["content.js"],
      "css": ["styles.css"]
    }
  ]
}

Manifest V2 では background.page でHTMLを指定する方式でしたが、 V3では service_worker で JS ファイルを直接指定します。 Service Worker はページとは独立したライフサイクルで動作し、 イベント(chrome.runtime.onMessage)を受け取ったときだけ起動します。

4. フォルダ構成

Chrome拡張機能はビルドツール不要で動作します。 必要なファイルをフォルダにまとめて、Chrome の拡張機能管理ページから 「パッケージ化されていない拡張機能を読み込む」で直接ディレクトリを指定するだけです。

lgtm-cat-extension/
├── manifest.json   # 設定ファイル(権限・Service Worker定義)
├── background.js   # API通信代行ロジック
├── content.js      # GitHub上のUI制御・画像挿入
├── styles.css      # ダイアログ・グリッド・ボタンの意匠
└── icons/          # 拡張機能用アイコン画像

React や TypeScript を使わず素の JavaScript + CSS で完結しています。 シンプルなツールであれば npm install も webpack も不要で、 ファイルを数枚書くだけで拡張機能として動かせる手軽さは Chrome 拡張の大きな魅力です。

icons フォルダには拡張機能管理ページや Chrome ツールバーに表示されるアイコン画像を置きます。manifest.jsonicons フィールドで各サイズ(16px・48px・128px)を指定します。 アイコンがない場合は Chrome のデフォルトアイコンが使われますが、 識別しやすさのため用意しておくことを推奨します。

5. Content Script: ボタン注入とMutationObserver

GitHub は React ベースの SPA です。ページ遷移しても URL は変わりますが、 フルリロードは発生しません。そのため、ページ読み込み時に一度だけボタンを注入する実装では、 別のPRに移動するとボタンが消えてしまいます。

この問題を解決するのが MutationObserver です。document.body を監視対象にし、DOM に変化があるたびにinjectButton() を呼び出します。 関数の中でボタンが既に存在するかチェックしているため、二重注入は起きません。

function injectButton() {
  const toolbars = document.querySelectorAll('markdown-toolbar')
  toolbars.forEach((toolbar) => {
    // 既に注入済みならスキップ
    if (toolbar.querySelector('.lgtm-cat-btn')) return

    const btn = document.createElement('button')
    btn.type = 'button'
    btn.className = 'lgtm-cat-btn'
    btn.innerHTML = `<svg .../>  LGTM`

    // ツールバーと同じフォームの textarea を取得
    const container = toolbar.closest('.js-previewable-comment-form')
    if (!container) return
    const textarea = container.querySelector('textarea')

    btn.onclick = (e) => {
      e.preventDefault()
      showLGTMDialog(btn, textarea)
    }
    toolbar.appendChild(btn)
  })
}

// SPA対応: DOM変化のたびに注入を試みる
const observer = new MutationObserver(injectButton)
observer.observe(document.body, { childList: true, subtree: true })
injectButton() // 初回実行

ボタンの注入先は markdown-toolbar カスタム要素の末尾です。 GitHub の各コメントフォームはそれぞれ独立した markdown-toolbar を持つため、querySelectorAll で全件取得して処理します。

6. Background Script: CSPを回避したAPI通信

Manifest V3 では、Background Script は Service Worker として動作します。 Service Worker とは、ブラウザがページとは独立して動かすバックグラウンドスクリプトの仕組みで、 もともとはウェブのオフライン対応(キャッシュ管理)のために作られた技術です。 Chrome 拡張機能の Manifest V3 では、この仕組みをバックグラウンド処理の実行基盤として採用しています。

重要な特徴は「どのページにも属さない」点です。 常駐するのではなく、chrome.runtime.onMessage などのイベントを受け取ったときだけ起動し、 処理が終わるとスリープ状態に戻ります。 これにより CSP の管轄外で動作しつつ、メモリ使用量も抑えられます。

Background Script はシンプルなメッセージリスナーです。 Content Script から { action: "fetchLGTMImages" } を受け取るとlgtmeow.com の API を fetch し、結果をそのまま返します。

chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'fetchLGTMImages') {
    fetch('https://lgtmeow.com/api/lgtm-images')
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP Error: ${res.status}`)
        return res.json()
      })
      .then((data) => sendResponse({ success: true, data }))
      .catch((error) => sendResponse({ success: false, error: error.message }))

    return true // 非同期で sendResponse を呼ぶために必要
  }
})

return true が必須という点は注意が必要です。sendResponse を非同期(Promiseチェーン内)で呼ぶ場合、 リスナー関数が true を返さないとメッセージチャンネルが即座に閉じられ、sendResponse が無効になります。 これを忘れると Content Script 側で responseundefined になります。

7. React 管理 textarea への挿入と execCommand

最初の実装では挿入するMarkdownを `\n\n![LGTM](url)` としていました。 コメント欄が空の状態で画像を選ぶと、先頭に空行が2行入った状態で挿入されてしまう問題があります。 これは textarea.selectionStart で「カーソルの直前の文字」を確認し、 接頭改行の有無を切り替えることで解決できます。

しかし実装を進めると、もう一つ大きなハマりポイントがありました。 テキストは挿入できているのに Comment ボタンが非活性のままになる問題です。

GitHub のコメントフォームは React で管理されており、 Comment ボタンの活性状態は React の内部 state(textarea の value)が空かどうかで決まります。 Content Script から textarea.value = newValue と直接代入しても、 React は自分が管理していないところで値が書き換わったことを検知できないため、 内部 state は空のまま、ボタンも非活性のままになります。

試行錯誤として以下の2つを試しましたが、どちらも効果がありませんでした。

  • textarea.dispatchEvent(new Event('input', { bubbles: true })) を追加 → React の合成イベントシステムには到達せず、内部 state は更新されない
  • Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').setでネイティブ setter を経由して代入 → GitHub が使う React のバージョン・設定では依然として検知されなかった

最終的に有効だったのは document.execCommand('insertText') です。 この API はブラウザのネイティブなテキスト編集機構を通じて文字列を挿入するため、 React の合成イベントも含めてすべての変更検知を確実にトリガーします。

function insertMarkdown(textarea, imageUrl) {
  const markdown = `![LGTM](${imageUrl})`
  const before = textarea.value.substring(0, textarea.selectionStart)

  // カーソル直前が空 or 改行済みなら接頭なし、そうでなければ改行1つのみ
  const prefix = before.length > 0 && !before.endsWith('\n') ? '\n' : ''

  // execCommand はブラウザネイティブのテキスト挿入を経由するため
  // React の内部状態が確実に更新され、Comment ボタンが活性化する
  textarea.focus()
  document.execCommand('insertText', false, prefix + markdown)
}

execCommand は MDN では deprecated 扱いですが、 Chrome では現在も動作しており、Content Script からページの React 管理 textarea に 書き込む用途では最も信頼性の高い方法です。textarea.value への直接代入と異なり、ブラウザの Undo スタックにも正しく記録されます。

8. UI/UX の細かい調整

動作実装の後、使いやすさのために以下の調整を加えました。

  • ダイアログの画面中央配置: 最初はボタンの直下に position: absolute で表示していましたが、 ボタンが画面右端にあるためダイアログが画面外にはみ出すことがありました。position: fixed + left: 50% +transform: translateX(-50%) に変更することで、 常にビューポート中央に表示されるようになります。fixed にすることで top の計算からwindow.scrollY も不要になります。
  • 5枚横並び表示:flex-wrap: wrap の2列グリッドでは縦スクロールが発生し、 5枚目が見えにくくなっていました。flex-wrap: nowrap + 各画像に flex: 1 を指定することで、 ダイアログ幅に合わせて5枚が均等幅で1行に収まります。 画像の縦横比はバラバラなので object-fit: cover で高さを固定して揃えます。
  • Refreshボタンを押すとダイアログが閉じる問題: Refreshクリック時に container.innerHTML を書き換えると、 Refreshボタン要素がDOMから外れます。その後バブルアップした click イベントが 「ダイアログ外クリック」の closeHandler に到達し、dialog.contains(e.target)false を返して閉じてしまいます。 Refreshボタンの onclicke.stopPropagation() を追加するだけで解決します。
  • GitHubスタイルへの上書きと !important: GitHub はツールバーボタンに対して強いCSSを持っています。 カスタムCSSでボタンの背景色や文字色を上書きしようとすると、 GitHub 側のスタイルに負けて反映されないことがあります。backgroundcolorborder など 上書きしたいプロパティに !important を付けることで確実に適用できます。