chrome拡張機能
Chrome拡張機能を開発する上で勉強したことをまとめる
Zennに本があった
読んでみる
拡張機能は大きく4つの要素からなる
- マニフェスト
- 重要な定義や宣言を行う
-
manifest.json
を使う
- Service worker
- ブラウザのイベントを処理
- バックグラウンド動作に特化
- Content script
- DOMを利用
- ページ内容の読み取り、変更
- DOMを利用
- Toolbar action
- 拡張機能ツールバーのアイコンをクリックした時の動作を管理
- 処理を実行したり、ポップアップを開いたり
- 拡張機能ツールバーのアイコンをクリックした時の動作を管理
以下のリポジトリを使う
前述した4つの要素がどこにあるのか調べる
- manifest
- chrome-extensionディレクトリのmanifest.tsが対応している
- Service worker
- chrome-extensionディレクトリのsrc/backgroundディレクトリが対応している
- Content script
- Pagesディレクトリが対応
- 特にcontent-uiディレクトリが関係?
- contentディレクトリはコンソールに関係?
- Toolbar action
- Pagesディレクトリのpopupディレクトリが対応
Chrome拡張機能では、Storage APIを使ってユーザーデータを管理することができる
ストレージ領域は4つに分かれており、使用用途に応じて使い分ける必要がある
使用するためには、manifest.json
のpermissions
にstorageを追加する必要があるので注意
サービスワーカーを利用して、右クリックした時に出てくるコンテキストメニューの表示をいじることができる
chrome.contextMenus APIを使用する
manifestのpermissionにcontextMenusを追加する必要あり
自分にはレベルが高かったのでもっとシンプルな例を探す
Hello Worldプロジェクトがあった
ツールバーアイコンをクリックすると、Hello Extensionsと表示されるみたい
ディレクトリ構造は以下の通り
.
├── icon.png
├── manifest.json
└── popup.html
アイコンがクリックされたらpopup.html
が表示される仕組み
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_popup
にpopup.html
を指定することで表示するようにできる
このmanifest.json
は必ずプロジェクトルートに置かないといけない
次はページに推定読書時間を表示する機能を追加するものに取り組む
manifest.json
で必須のキーは、manifest_version
, name
, version
のみ
アイコンは開発中は必須ではないが、拡張機能をChromeウェブストアで配布する場合は必須
サイズ別に、表示される場所が違う
コンテンツスクリプトはmanifest.json
のcontent_scripts
キーで指定できる
ファイル形式と、1つ以上のマッチパターンを指定できる
{
"content_scripts": [
{
"js": ["scripts/content.js"],
"matches": [
"https://developer.chrome.com/docs/extensions/*",
"https://developer.chrome.com/docs/webstore/*"
]
}
]
}
マッチパターンは以下のような形式
<scheme>://<host>/<path>
- scheme
- http
- https
-
*
(http or https)
- host
- ホスト名
- www.example.comなど
- サブドメイン照合のためには
*.example.com
などとする
- ホスト名
- path
- URLパス
- /exampleなど
- URLパス
具体例
-
https://*/
- httpsスキームを使用するすべてのURL
-
https://*/foo*
- httpsスキームを使用し、パスが
foo
で始まるすべてのURL - https://example.com/foo/bar.html, https://www.google.com/fooなど
- httpsスキームを使用し、パスが
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
});
これをZennに適用してみる
まずは、URLパターンを変える
zennの記事は以下のようなURLパターンだった
https://zenn.dev/<username>/articles/<randomID?>
なので、以下のような設定で良さそう
{
"content_scripts":[
{
"js": ["scripts/content.js"],
"matches": [
"https://zenn.dev/*/articles/*"
]
}
]
}
次にcontent.js
を書き換える
と思ったが、そのままで動いている
zennの記事も article
タグの子に記事を持っている
ただ、Read nextなども含まれているため、正確さを重要視するなら手を加える必要がありそう
あと、obserberはいらないかも
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"));
次は、スタイルの変更をやってみる
Service Workerはbackground
のservice_worker
で指定する
{
...
"background": {
"service_worker": "background.js"
},
...
}
runtime.onInstalled()
を使用することで、インストール時に初期状態を設定することができる
chrome.runtime.onInstalled.addListener(() => {
chrome.action.setBadgeText({
text: "OFF",
});
});
バッジとは、拡張機能のアイコンに重ねて表示されるテキストのこと
上のブログラムでは、OFFというバッジを作成している
chrome.action
APIをつかうにはmanifest.json
でaction
キーを設定しないといけないらしい
{
...
"action": {
"default_icon": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",
"48": "images/icon-48.png",
"128": "images/icon-128.png"
}
},
...
}
manifest.json
にpermissions
キーを追加
拡張機能が何ができるかを宣言する
{
...
"permissions": ["activeTab"],
...
}
この例だと、現在アクティブなタブにアクセスできる
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()
はタブを指定して、バッジテキストを取得している
permissionsにscriptingを追加し、スクリプトを実行する
{
...
"permissions": ["activeTab", "scripting"],
...
}
実行する内容は、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 },
});
}
}
});
キーボードショートカットを割り当てるためには、commands
キーをmanifestに追加する
{
...
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Ctrl+B",
"mac": "Command+B"
}
}
}
}
_execute_action
はaction.onClicked
イベントと同じコードを実行するらしい
指定したCSSセレクターに一致する複数の要素を取得するためには、document.querySelectorAll()
を使うといいみたい
const matches = document.querySelectorAll("p");
この例ではpタグの要素を複数取得している
同じボタン要素を使い回しているとエラーが発生する?
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('ボタンがクリックされました');
});
ターゲットごとにボタンを作成してもエラーが出る
SPAアプリケーションだから?
変更を監視する必要があるかも
そもそも拡張機能を導入していなくてもエラーが出てた
変更を監視するように変更したら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 });
MutationObserver
を使用することで、対象の変更を監視し、関数を実行することができる
コンストラクタの引数はコールバック関数で、コールバック関数の第一引数は変更の配列、第二引数はobserver自身を返す
変更を表すMutationRecord
のaddedNodes
属性は追加されたNodeの配列を格納している