🧩

Google Chrome拡張機能開発のチュートリアルを実施してみた(コンテンツスクリプト自動実行編)

に公開

チュートリアルの概要

前回はアイコンをクリックすると"Hello Extensions"と表示される簡単なGoogle Chromeの拡張機能を作成しました。
https://zenn.dev/judenfly/articles/judenfly-20250806
今回もGoogle公式のチュートリアルに従い、特定のWebページを読み終えるまでの時間を計算して表示する拡張機能を作成します。
https://developer.chrome.com/docs/extensions/get-started/tutorial/scripts-on-every-tab?hl=ja
チュートリアルの流れは以下の通りです。

  • Step 1. マニフェストファイルを作成
  • Step 2. アイコンをダウンロード
  • Step 3. コンテンツスクリプトファイルを作成
  • Step 4. 動作を確認

チュートリアル完了後、Google Chromeの拡張機能やウェブストアに関する開発者向けドキュメントを開くと、以下のように読了までの時間が表示されます。

Step 1. マニフェストファイルを作成

まず、拡張機能のルートディレクトリreading-timeを作成し、移動します。
次に、reading-timeディレクトリ直下に以下のようなマニフェストファイルmanifest.jsonを作成します。

manifest.json
{
    "manifest_version": 3,
    "name": "Reading time",
    "version": "1.0",
    "description": "Add the reading time to Chrome Extension documentation articles",
    "icons": {
        "16": "images/icon-16.png",
        "32": "images/icon-32.png",
        "48": "images/icon-48.png",
        "128": "images/icon-128.png"
    },
    "content_scripts": [
        {
            "js": ["scripts/content.js"],
            "matches": [
                "https://developer.chrome.com/docs/extensions/*",
                "https://developer.chrome.com/docs/webstore/*"
            ]
        }
    ]
}
  • manifest_version
  • name
  • version
  • description

の4フィールドの説明は前回の記事に記載しているので省略します。
iconsフィールドは拡張機能のアイコンのサイズと相対パスの辞書です。
画像は透過(透明な背景)をサポートしているPNG形式が推奨されています。
今回用意する16×16px、32×32px、48×48px、128×128pxのアイコンの用途は以下の通りです。

画像サイズ(px) 用途
16×16 拡張機能の管理ページや右クリックした時に表示されるメニューのファビコン用[1]
32×32 Windows用
48×48 拡張機能の管理ページ用
128×128 インストール時やGoogle Chromeウェブストア用

content_scriptsフィールドではコンテンツスクリプト(ユーザが開いたWebページに挿入するJavaScriptまたはCSSファイル)の相対パスや挙動を定義します。
今回はコンテンツスクリプトをJavaScriptで作成するため、jsフィールドが存在しますが、CSSで作成する場合はcssフィールドを用意します。
これらのフィールドの値はコンテンツスクリプトの相対パスの配列で、実行したい順序で記載します。
matchesフィールドではコンテンツスクリプトを実行するページを記載します。
今回はGoogle Chromeの拡張機能やウェブストアに関する開発者向けドキュメントを対象とします。

Step 2. アイコンをダウンロード

マニフェストファイルに記載した4つの異なるサイズのアイコンを用意します。
まず、アイコン用のディレクトリimagesreading-timeディレクトリ直下に作成して移動します。
次に、Google ChromeのGitHubリポジトリからアイコンをダウンロードし、マニフェストファイルの定義通りに命名します。

Invoke-WebRequest -Uri "https://raw.githubusercontent.com/GoogleChrome/chrome-extensions-samples/main/functional-samples/tutorial.reading-time/images/icon-16.png" -OutFile "icon-16.png"
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/GoogleChrome/chrome-extensions-samples/main/functional-samples/tutorial.reading-time/images/icon-32.png" -OutFile "icon-32.png"
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/GoogleChrome/chrome-extensions-samples/main/functional-samples/tutorial.reading-time/images/icon-48.png" -OutFile "icon-48.png"
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/GoogleChrome/chrome-extensions-samples/main/functional-samples/tutorial.reading-time/images/icon-128.png" -OutFile "icon-128.png"

Step 3. コンテンツスクリプトファイルを作成

マニフェストファイルに記載したコンテンツスクリプトを作成します。
まず、コンテンツスクリプト用のディレクトリscriptsreading-timeディレクトリ直下に作成して移動します。
次に、以下のようなコンテンツスクリプトcontent.jsを作成します。

content.js
function renderReadingTime(article) {
    if (!article) {
        return;
    }
    const text = article.textContent;
    const wordMatchRegExp = /[^\s]+/g;
    const words = text.matchAll(wordMatchRegExp);
    const wordCount = [...words].length;
    const readingTime = Math.round(wordCount / 200);

    const badge = document.createElement("p");
    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"));

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
});
コードの解説
function renderReadingTime(article) {

関数renderReadingTimeを定義します。
引数のarticleHTMLElementオブジェクト(HTMLタグを表すオブジェクト)で、ここではarticleタグを想定しています。

    if (!article) {
        return;
    }

もし、articleタグが無いなら何もしないで終えます。

    const text = article.textContent;

articleタグに囲まれたテキスト部分を変数textに格納します。

    const wordMatchRegExp = /[^\s]+/g;
  • /で囲まれた部分は正規表現のパターンです。
  • \sは空白文字(改行なども含む)です。
  • ^は否定です。[2]
  • []は間に挟まれた部分のどれか1文字という意味です。
  • +は直前のパターンを1回以上繰り返すという意味です。
  • gは合致する部分を全て探索するフラグです。

つまり、/[^\s]+/gは空白文字以外が1回以上続く部分(要するに単語)を全て探索する正規表現です。
この正規表現を変数wordMatchRegExpに格納します。

    const words = text.matchAll(wordMatchRegExp);

articleタグのテキスト部分の単語のイテレータを変数wordsに格納します。

    const wordCount = [...words].length;

イテレータwordsを配列に変換し、その長さ(要するに単語数)を変数wordCountに格納します。

    const readingTime = Math.round(wordCount / 200);

200単語を1分で読むという想定で、articleタグの単語数を200で割って四捨五入した値を変数readingTimeに格納します。

    const badge = document.createElement("p");

pタグを作成して変数badgeに格納します。

    badge.classList.add("color-secondary-text", "type--caption");

pタグにクラスcolor-secondary-texttype--captionを追加します。

  • color-secondary-textはテキストの色をセカンダリカラーにするクラスです。
  • type--captionはテキストをキャプション用にするクラスです。

どちらのクラスも今回の拡張機能の対象としているWebサイトで使われています。

    badge.textContent = `⏱️ ${readingTime} min read`;

pタグのテキストとして「⏱️ <articleタグの単語数を200で割って四捨五入した値> min read」を指定します。

    const heading = article.querySelector("h1");

articleタグ内のh1タグを変数headingに格納します。
h1タグが無ければheadingはnullです。

    const date = article.querySelector("time")?.parentNode;

articleタグ内のtimeタグの1つ外側のタグを変数dateに格納します。
timeタグが無ければdateはundefinedです。

    (date ?? heading).insertAdjacentElement("afterend", badge);
}

dateがundefinedでなければ(要はtimeタグがあれば)timeタグの1つ外側のタグの終了直後にpタグを挿入します。
もし、dateがundefinedならば、h1タグの終了直後にpタグを挿入します。
ここで、関数renderReadingTimeの定義は終了です。

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

Webページのarticleタグを引数として関数renderReadingTimeを実行します。
つまり、Webページのtimeタグの1つ外側のタグの終了直後またはh1タグの終了直後に「⏱️ <articleタグの単語数を200で割って四捨五入した値> min read」を挿入します。
上記のコードだけでも読了時間は上手く表示されそうです。
しかし、今回の拡張機能の対象としているWebページはシングルページアプリケーションであり、左メニューをクリックして記事を切り替えてもページ全体は切り替わりません。
そのため、上記のコードのままでは新しく表示した記事においてコンテンツスクリプトが実行されず、読了時間が表示されません。[3]
そこで、HTMLドキュメントの構造の変化を監視する仕組みを導入します。

const observer = new MutationObserver((mutations) => {

MutationObserverコンストラクタは、HTMLドキュメントの構造の変化をトリガーとして指定したコールバック関数を実行するオブザーバを作成して返します。
ここでのコールバック関数は(mutations) => {からrenderReadingTime(node);}}}}まで(アロー関数)です。

    for (const mutation of mutations) {

コールバック関数の引数mutationsMutationRecordオブジェクトの配列で、ここではその要素を変数mutationに順に取り出します。
MutationRecordオブジェクトはHTMLドキュメントの構造の変化の情報を持っています。

        for (const node of mutation.addedNodes) {

mutation.addedNodesにはHTMLドキュメントに新しく追加された部分が入っており、ここではその要素を変数nodeに順に取り出します。

            if (node instanceof Element && node.tagName === 'ARTICLE') {

HTMLドキュメントに新しく追加された部分がHTML要素かつタグ名がARTICLEであれば、後続の処理を実施します。

                renderReadingTime(node);
            }
        }
    }
});

articleタグが追加されたということは読了時間を再計算・再表示する必要があるので、renderReadingTime関数を呼び出して実行します。
ここでコールバック関数の定義は終了です。

observer.observe(document.querySelector('devsite-content'), {

observeメソッドを使用し、HTMLドキュメントの構造の変更の監視を開始します。
監視対象はdevsite-contentタグ配下です。

    childList: true
});

devsite-contentの子要素の追加・削除のみを監視します。

Step 4. 動作を確認

拡張機能が完成したので、Google Chromeに読み込みます。
なお、拡張機能の読み込み方法は前回の記事に記載しているので省略します。
作成した拡張機能が有効になっている状態で、今回のチュートリアルのページ[4]を開くと、コンテンツスクリプトが実行されて読了までの時間が表示されます。

今回のチュートリアルはこれにて終了です。

脚注
  1. iconsフィールドの説明ページの日本語版に、16×16pxのアイコンがkubectl(!?)の拡張機能のページのファビコンとして使用されるかのような記載がありますが、英語版だとkubectlという単語は出てきません。謎機械翻訳です。 ↩︎

  2. 今回は[]内の先頭に^があるため否定となりますが、そうでなければ行頭を意味します。 ↩︎

  3. 正確に言うと、左メニューから記事を切り替えると読了時間を表示していた部分ごと切り替わるので、読了時間が表示されなくなります。 ↩︎

  4. 今回のチュートリアルのWebページのURLはhttps://developer.chrome.com/docs/extensions/から始まっており、マニフェストファイルのmatchesフィールドで定義したコンテンツスクリプトの実行場所に合致します。 ↩︎

Discussion