Open39

chrome拡張機能

fuutotfuutot

Chrome拡張機能を開発する上で勉強したことをまとめる

fuutotfuutot

拡張機能は大きく4つの要素からなる

  • マニフェスト
    • 重要な定義や宣言を行う
    • manifest.jsonを使う
  • Service worker
    • ブラウザのイベントを処理
    • バックグラウンド動作に特化
  • Content script
    • DOMを利用
      • ページ内容の読み取り、変更
  • Toolbar action
    • 拡張機能ツールバーのアイコンをクリックした時の動作を管理
      • 処理を実行したり、ポップアップを開いたり
fuutotfuutot
  • manifest
    • chrome-extensionディレクトリのmanifest.tsが対応している
  • Service worker
    • chrome-extensionディレクトリのsrc/backgroundディレクトリが対応している
  • Content script
    • Pagesディレクトリが対応
    • 特にcontent-uiディレクトリが関係?
    • contentディレクトリはコンソールに関係?
  • Toolbar action
    • Pagesディレクトリのpopupディレクトリが対応

https://github.com/Jonghakseo/chrome-extension-boilerplate-react-vite?tab=readme-ov-file#boilerplate-structure-

fuutotfuutot

Chrome拡張機能では、Storage APIを使ってユーザーデータを管理することができる
ストレージ領域は4つに分かれており、使用用途に応じて使い分ける必要がある
使用するためには、manifest.jsonpermissionsにstorageを追加する必要があるので注意

fuutotfuutot

サービスワーカーを利用して、右クリックした時に出てくるコンテキストメニューの表示をいじることができる
chrome.contextMenus APIを使用する
manifestのpermissionにcontextMenusを追加する必要あり

fuutotfuutot

自分にはレベルが高かったのでもっとシンプルな例を探す

fuutotfuutot

ディレクトリ構造は以下の通り

.
├── icon.png
├── manifest.json
└── popup.html

アイコンがクリックされたらpopup.htmlが表示される仕組み

fuutotfuutot

manifest.jsonの内容は以下の通り

{
    "name": "Hello Extension of the World!",
    "description": "A simple extension that greets the user.",
    "version": "1.0",
    "manifest_version": 3,
    "action": {
        "default_popup": "popup.html",
        "default_icon": "icon.png"
    }
}

default_popuppopup.htmlを指定することで表示するようにできる

fuutotfuutot

このmanifest.jsonは必ずプロジェクトルートに置かないといけない

fuutotfuutot

manifest.jsonで必須のキーは、manifest_version, name, versionのみ

fuutotfuutot

アイコンは開発中は必須ではないが、拡張機能をChromeウェブストアで配布する場合は必須
サイズ別に、表示される場所が違う

fuutotfuutot

コンテンツスクリプトはmanifest.jsoncontent_scriptsキーで指定できる
ファイル形式と、1つ以上のマッチパターンを指定できる

{
  "content_scripts": [
    {
      "js": ["scripts/content.js"],
      "matches": [
        "https://developer.chrome.com/docs/extensions/*",
        "https://developer.chrome.com/docs/webstore/*"
      ]
    }
  ]
}
fuutotfuutot

マッチパターンは以下のような形式

<scheme>://<host>/<path>
  • scheme
    • http
    • https
    • * (http or https)
  • host
    • ホスト名
      • www.example.comなど
      • サブドメイン照合のためには*.example.comなどとする
  • path
    • URLパス
      • /exampleなど

具体例

https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns?hl=ja

fuutotfuutot

content.jsは以下のような形

content.js
function renderReadingTime(article) {
  if (!article) {
    return;
  }

  // 文字数をもとに、読むのにかかる時間を計算
  const text = article.textContent;
  const charCount = [...text].length;
  const readingCharsPerMinute = 400;
  const readingTime = Math.round(charCount / readingCharsPerMinute);
  
  // 表示するバッジを作成
  const badge = document.createElement("p");
  // Use the same styling as the publish information in an article's header
  badge.classList.add("color-secondary-text", "type--caption");
  badge.textContent = `⏱️ ${readingTime} min read`;

  // 親要素を特定し、バッジを挿入
  const heading = article.querySelector("h1");
  const date = article.querySelector("time")?.parentNode;
  (date ?? heading).insertAdjacentElement("afterend", badge);
}

renderReadingTime(document.querySelector("article"));

// 記事が動的に追加される場合に備えて、MutationObserverを使用して監視
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    // 追加されたノードをチェック
    for (const node of mutation.addedNodes) {
      if (node instanceof Element && node.tagName === 'ARTICLE') {
        renderReadingTime(node);
      }
    }
  }
});

observer.observe(document.querySelector('devsite-content'), {
  childList: true
});
fuutotfuutot

これをZennに適用してみる
まずは、URLパターンを変える
zennの記事は以下のようなURLパターンだった

https://zenn.dev/<username>/articles/<randomID?>

なので、以下のような設定で良さそう

manifest.json
{
"content_scripts":[
    {
        "js": ["scripts/content.js"],
        "matches": [
            "https://zenn.dev/*/articles/*"
        ]
    }
  ]
}
fuutotfuutot

次にcontent.jsを書き換える
と思ったが、そのままで動いている
zennの記事も articleタグの子に記事を持っている
ただ、Read nextなども含まれているため、正確さを重要視するなら手を加える必要がありそう
あと、obserberはいらないかも

content.js
function renderReadingTime(article) {
  if (!article) {
    return;
  }

  // 文字数をもとに、読むのにかかる時間を計算
  const text = article.textContent;
  const charCount = [...text].length;
  const readingCharsPerMinute = 400;
  const readingTime = Math.round(charCount / readingCharsPerMinute);
  
  // 表示するバッジを作成
  const badge = document.createElement("p");
  // Use the same styling as the publish information in an article's header
  badge.classList.add("color-secondary-text", "type--caption");
  badge.textContent = `⏱️ ${readingTime} min read`;

  // 親要素を特定し、バッジを挿入
  const heading = article.querySelector("h1");
  const date = article.querySelector("time")?.parentNode;
  (date ?? heading).insertAdjacentElement("afterend", badge);
}

renderReadingTime(document.querySelector("article"));
fuutotfuutot

Service Workerはbackgroundservice_workerで指定する

{
  ...
  "background": {
    "service_worker": "background.js"
  },
  ...
}
fuutotfuutot

runtime.onInstalled()を使用することで、インストール時に初期状態を設定することができる

background.js
chrome.runtime.onInstalled.addListener(() => {
  chrome.action.setBadgeText({
    text: "OFF",
  });
});
fuutotfuutot

manifest.jsonpermissionsキーを追加
拡張機能が何ができるかを宣言する

{
  ...
  "permissions": ["activeTab"],
  ...
}

この例だと、現在アクティブなタブにアクセスできる

fuutotfuutot

URLが意図しているものかを確認し、バッジテキストを変更する

const extensions = 'https://developer.chrome.com/docs/extensions';
const webstore = 'https://developer.chrome.com/docs/webstore';

chrome.action.onClicked.addListener(async (tab) => {
  // バッジテキストを特定のURLに基づいて切り替え
  if (tab.url.startsWith(extensions) || tab.url.startsWith(webstore)) {
    const prevState = await chrome.action.getBadgeText({ tabId: tab.id });
    const nextState = prevState === 'ON' ? 'OFF' : 'ON';

    await chrome.action.setBadgeText({
      tabId: tab.id,
      text: nextState,
    });
  }
});

getBadgeText()はタブを指定して、バッジテキストを取得している
https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText

fuutotfuutot

permissionsにscriptingを追加し、スクリプトを実行する

{
  ...
  "permissions": ["activeTab", "scripting"],
  ...
}
fuutotfuutot

実行する内容は、CSSを使ったレイアウトの変更

const extensions = 'https://developer.chrome.com/docs/extensions';
const webstore = 'https://developer.chrome.com/docs/webstore';

chrome.action.onClicked.addListener(async (tab) => {
  // バッジテキストを特定のURLに基づいて切り替え
  if (tab.url.startsWith(extensions) || tab.url.startsWith(webstore)) {
    const prevState = await chrome.action.getBadgeText({ tabId: tab.id });
    const nextState = prevState === 'ON' ? 'OFF' : 'ON';

    await chrome.action.setBadgeText({
      tabId: tab.id,
      text: nextState,
    });

    // ページの状態に合わせてページのレイアウトを変更
    if (nextState === 'ON') {
      await chrome.scripting.insertCSS({
        files: ['styles/focus-mode.css'],
        target: { tabId: tab.id },
      });
    } else if (nextState === 'OFF') {
        await chrome.scripting.removeCSS({
          files: ['styles/focus-mode.css'],
          target: { tabId: tab.id },
        });
    }
  }
});
fuutotfuutot

キーボードショートカットを割り当てるためには、commandsキーをmanifestに追加する

{
  ...
  "commands": {
    "_execute_action": {
      "suggested_key": {
        "default": "Ctrl+B",
        "mac": "Command+B"
      }
    }
  }
}

_execute_actionaction.onClickedイベントと同じコードを実行するらしい

fuutotfuutot

同じボタン要素を使い回しているとエラーが発生する?

Uncaught (in promise) The message port closed before a response was received.
// ボタン要素を作成
const button = document.createElement('button');
button.textContent = '新規Scrap作成';

// ボタンを追加するターゲット要素を取得
const targetSelector = '.ThreadEditor_buttons__Y_Bk5';
const targets = document.querySelectorAll(targetSelector);

// ターゲット要素が存在する場合、ボタンを追加
if (targets.length > 0) {
  targets.forEach(target => {
    target.appendChild(button);
  });
} else {
  console.log(`ターゲット要素が見つかりません: ${targetSelector}`);
}

// ボタンのクリックイベント
button.addEventListener('click', () => {
  alert('ボタンがクリックされました');
});
fuutotfuutot

ターゲットごとにボタンを作成してもエラーが出る
SPAアプリケーションだから?
変更を監視する必要があるかも

fuutotfuutot

そもそも拡張機能を導入していなくてもエラーが出てた

fuutotfuutot

変更を監視するように変更したらUIが変更しても対応できるようになった

// 変更を監視して、ターゲット要素が追加されたらボタンを追加する
const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    mutation.addedNodes.forEach(node => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        const newTargets = node.querySelectorAll(targetSelector);
        newTargets.forEach(target => {
          // 要素ごとにボタンを作成して追加
          const button = document.createElement('button');
          button.textContent = buttonText;
          button.classList.add(...buttonClasses);
          button.addEventListener('click', () => {
            alert('ボタンがクリックされました');
          });
          target.appendChild(button);
        });
      }
    });
  });
});

// ドキュメントの変更を監視
const observeSelector = ".ContainerUndo_undoInSM__1vdc1";  // できるだけ監視対象を絞る
observer.observe(document.querySelector(observeSelector), { childList: true, subtree: true });