🐕

SPA サイトで使っていたブックマークレットを拡張機能化した話

2024/03/27に公開

はじめに

こんにちは、hamaguchi です。
今日はブックマークレットを拡張機能化した話を書いてみます。
拡張機能化といっても所定のスクリプトを特定の URL で実行するように書くだけでしょ?と思っていましたが、今回の対象のサイトは SPA で動的に変わるページに対してスクリプトを実行する必要があり、ちょっとした工夫が必要でした。

ちょっとした計算が自動でされると便利なページがあり、ブックマークレット化して使っていましたが、使っていくうちに自動で反映して欲しいという気持ちになったので Chrome の拡張機能として実装してみました。
その中で、以下の2点の知見が得られたので、今回はそれについて書いていきます。

  • 単純なスクリプトを実行する Chrome 拡張機能の構成
  • SPA のサイトでページ遷移時にスクリプトを実行する際の注意点

実行する中身については本題と逸れるため簡略化して説明していますが、具体的な中身が気になる場合は以下のリポジトリを参照してください。
https://github.com/takuyayukat/chrome_extension-freee_overtime

作った拡張機能を一般に公開する場合には、Chrome ウェブストアで登録料 ($5) を支払って開発者として登録し、作った拡張機能に問題がないか審査を受ける必要がありますが、今回は Chrome の拡張機能管理でデベロッパーモードにしてソースコードからインストールしてしまったためその辺りのフローについては割愛します。

ブックマークレットとは

ブックマーク機能を使って JavaScript を実行することができるというブラウザの機能で、JavaScript のコードを URL に埋め込んでブックマークに登録することで、そのブックマークを開くことで任意の JavaScript を実行することができます。
HTMLの要素を取得したり、CSSを変更したり、ページの内容を書き換えたり、 JavaScript でできることならなんでもできます。(ブラウザが許容する文字数を超える場合にはサーバーにスクリプトファイルを置いて呼び出すなどの工夫が必要になる場合もあります)
任意のコードが実行できるということは悪意のある作者が作成したブックマークレットを実行すると、ユーザーの情報を盗むなどの悪影響を及ぼす可能性があるため、中身を読んで大丈夫だと判断したものか信頼できるソースからもらったもののみ以外は実行しないようにしましょう。

とりあえずやってみる

以下のような文字列をURLとしてブックマークに登録し、ブックマークを開くことで任意の JavaScript を実行することができます。
この場合 hoge というアラートが表示されます。

javascript:alert('hoge')

無名関数で囲む

上記のブックマークレットでは変数を使うこともなく単純なアラートを表示するだけの処理でしたが、複雑な処理をする際には変数や関数を定義したくなります。
サイト側でグローバルな変数を使用していることは最近はあまりないですが、同じ名前の変数を定義してしまうとサイト側の挙動に影響を与えてしまったり、定義済みの変数を再度定義しようとしてエラーになるなどの可能性があるため、無名関数を定義してその中で処理を行うと安全です。
また、最後の () がない状態では関数を定義しているだけで実行せず、 () をつけることで実行しています。
ネットで探してきた便利なブックマークレットもこの形をしていることが多いのではないでしょうか。

javascript:(function(){alert('hoge')})();

使っていたブックマークレット

以下のコードは、実際に使っていたブックマークレットを簡略化、整形したもので、このコードをブックマークレットとしてブックマークに登録し、実行することで欲しい数字が計算・表示されるようにしていました。
実際のブックマークレットのコードは ChatGPT に聞いて作ってもらったそうです。
便利な世の中ですね。

中身については本筋とは関係がないのでざっくり説明すると以下のようになります。

  1. 無名関数の定義
  2. 使用したい要素を取得
  3. 表示したい項目を計算
  4. 表示用の要素を作成し、挿入
  5. 無名関数の実行
javascript: (
  function () {
    const someItems = document.getElementsByClassName('hogehoge');
    let value = 0;
    for (let i = 0; i < someItems.length; i++) {
      value += parseInt(someItems[i].textContent);
    };

    const domWrap = document.createElement('div');
    const domChild = document.createElement('div');
    const domHr = document.createElement('hr');
    domChild.className = someItems[0].className;
    domChild.textContent = value.toString();
    domWrap.appendChild(domHr);
    domWrap.appendChild(domChild);
    someItems[0].parentNode.appendChild(domWrap);
  }
)()

多重実行された場合の挙動を確認

上記のブックマークレットは、何度もクリックするとクリックするたびに要素が追加されていきます。(冪等性がない状態)
手動で実行するならそれでも特に問題にはなりませんが、自動で実行される仕組みを作る場合には何度実行しても同じ結果になる方が望ましいです。(冪等性がある状態)
別のタイミングで要素を空にする、削除するなどの対応も考えられますが、今回は上書きされるようなイメージで、要素を追加する前に既存の要素があれば削除するという形に修正しました。

    domWrap.appendChild(domChild);
+   domWrap.id = 'ex-items';
+   document.getElementById('ex-items')?.remove();
    someItems[0].parentNode.appendChild(domWrap);

このブックマークレットを Chrome 拡張機能として実装していきます。

拡張機能として実装する

Chrome 拡張機能のディレクトリ構成の確認

指定のサイトで任意のスクリプトを実行する拡張機能の場合、最低限のファイル構成は以下のようになります。

..
├── manifest.json
├── content_script.js
└── icon.png

manifest.json の作成

manifest.json
{
  "manifest_version": 3,
  "name": "freee overtime",
  "version": "1.0.0",
  "content_scripts": [
    {
      "matches": [
        "https://some.spa.site.example.com/"
      ],
      "js": [
        "content_script.js"
      ]
    }
  ],
  "description": "chrome extension to display simple calculation result on ***",
  "icons": {
    "128": "icon.png"
  }
}

それぞれの項目について簡単に説明します。

  • manifest_version: マニフェストのバージョン。2024年3月現在は V3 が最新で、V2 の拡張機能の新規公開はできなくなっています。これから作る場合に V2 で作る理由はないので最新のものを使用しましょう。
    manifest_v2_will_be_rejected
  • name: 拡張機能の名前
  • version: 拡張機能のバージョン、公開する場合には公開済みのものよりも大きいバージョンを指定する必要があります。
  • content_scripts: 拡張機能が実行するスクリプトを指定します。matches には実行する URL を指定し、js には実行するスクリプトを指定します。matches には複数の URL やワイルドカードを指定することもできますが、審査に必要な項目を入力する際に詳しい審査が必要となり、公開が遅れる可能性があります。と表示されます。また、# を含む URL の指定はできませんでした。
  • description: 拡張機能の説明
  • icons: 拡張機能のアイコン。128x128px の画像は必須で、そのほかのサイズも配置した場合は Chrome が適切なサイズのものを使用してくれます。128px の画像さえあれば拡張機能管理や URL 欄横のファビコンにも表示されますが、自動でのリサイズが嫌な場合はサイズ毎の画像を指定しましょう。

インストール

拡張機能の管理からデベロッパーモードの有効化を行うことで、ソースコードから拡張機能のインストールを行うことができます。
現在はパッケージとして配布してインストールするという使い方はできないようで、ソースコードを共有してインストールする形でやってしまいました。(本当は組織内のみの限定公開のアプリケーションとして配布すべきなんだろうと思いますがとりあえずなし)

content_script.js にブックマークレットからスクリプトを移植

manifest.jsoncontent_scripts で指定したファイルは指定した URL を開いた際に実行されます。
まずはここにブックマークレットのスクリプトを移植してみます。

content_script.js
(
  function () {
    // ...
  }
)()

ブラウザで該当ページを開くと無効なプロパティの参照でエラーが発生してうまく動きませんでした。
何がいけないのでしょうか?

console
Uncaught TypeError: Cannot read properties of undefined (reading 'className')

見つからない要素のプロパティを参照しようとしてエラーが発生する問題を修正

SPA のサイトの場合にはページ遷移時にサーバーから HTML を取得しているわけではなく、遷移先の URL や遷移先で使用するデータを API から取得し、そのデータを元にページを動的に生成しています。
そのため、content_script.js が実行されるタイミングではまだ要素が生成されていないことがあり、今回のケースでは見つからない HTML の要素に対して className を参照しようとしたため、エラーが発生してしまいました。
手動でブックマークレットをぽちぽちしているときには全部見えてから押すため問題ありませんでしたが、自動で実行させようとするとタイミングを考慮する必要が出てきます。

上記の問題を解決するためには、対象の要素が見つからないときは処理を中断するという条件を追加する必要がありそうです。

content_script.js
(
  function () {
    const someItems = document.getElementsByClassName('hogehoge');
    if (!someItems.length) return;

    // ...
  }
)()

エラーになることは回避できました。
しかし次はリンクを踏んでもしてもスクリプトが実行されない問題が発生しました。

また、無名関数で囲む必要がなさそうなため、次からは中身だけを記述していきます。

ページを遷移してもスクリプトが実行されない問題の修正

content_script.js は HTML ドキュメントの読み込み後に一度だけ実行されます。
サーバーサイドで HTML を構築して返却する形のサイトであればページ移動のたびに実行されるため問題ありませんが、SPA のサイトではHTML ドキュメントの読み込みは最初にページを開いたときだけで、その後はボタンやリンクのクリックなどをトリガーに JavaScript がページを書き換えて表示を変えていくため HTML ドキュメントの再読込みは行われず、content_script.js が再度実行されることはありません。

この問題を解決する最も簡単かつ強引な解決方法は setInterval を使って一定間隔で実行し続ける(またはsetTimeoutを再起的に呼び出す)ことですが、不要な処理が繰り返し実行されるのが気持ち悪いし、SPA のサイトの場合はページ遷移後にも実行され続けてしまうのも問題です。
とりあえずお試しで動けばいいときや、もうどうしようもないというとき以外は避けたい気持ちでいっぱいです。
適切なタイミングでどう止めるのか、どのようなイベントをきっかけに再開するのかを考える必要がありますし、再実行するイベントが取得できるならそのときに普通に実行すれば良いですよね。
一瞬この方法でいこうかという誘惑が頭をよぎりますが、もっとスマートな方法があるはずです。

今回はobserver を使って要素の変化を監視し、要素が変化した際に URL が変わっているか確認することでページの遷移も検知することにしました。References > Web APIs > MutationObserver > MutationObserver()
使えそうなものがないか HTML を眺めながら画面遷移してみると、body 直下にローディング表示の要素が追加されることがわかったため、observer のターゲットは document.body、オプションは { childList: true } で良さそうです。他には attributes で属性を監視したり、characterData でテキストの内容を監視したり、{ subtree: true, childList: true } として指定する要素配下のすべての子・孫要素の変更を監視するといった指定ができそうです。

この辺りは実際にどのような要素の変化があるのかを確認してちょうどよさそうなのを選ぶ必要がありそうですね。
ひょっとしたらdocument.body 以下のすべての要素を監視するという方法を取らざるを得ない場合もあるかもしれませんが、なるべく軽く済ませたいところです。

content_script.js
let prevUrl = '';

// DOM の変更を監視し、該当のページへ遷移後、ローディングが終わったタイミングで計算・表示
const observer = new MutationObserver(() => {
  const currentUrl = location.href;

  if (document.querySelector('body > .loading')) return; // ローディング中なら中断
  if (prevUrl === currentUrl) return; // 表示中のURLが処理済みなら中断

  main()
  prevUrl = currentUrl; // URLを記録
});

window.addEventListener('load', () => {
  // ローディング中に document.body 直下に追加されるローディング表示の要素を監視
  observer.observe(document.body, { childList: true });
});

// 計算・表示
const main = function () {
  const someItems = document.getElementsByClassName('hogehoge');
  if (!someItems.length) return;

  // ...
}

ひとまずいい感じに動くようになりました。

動かして遊んでみると observer の該当ページ以外に遷移してから戻った場合には実行されない不具合があったため、location.hash の条件を加え、別ページに移動した際にも prevUrl を更新するように修正しました。

紆余曲折を経て出来上がった条件がこちら

content_script.js
(
  function () {
    let prevUrl = '';

    // DOM の変更を監視し、条件にあえば計算・表示
    const observer = new MutationObserver(() => {
      const currentUrl = location.href;

      if (!location.hash.startsWith('#hogehoge')) { // 該当ページ以外なら中断
        prevUrl = currentUrl; // URLを記録
        return;
      }
      if (document.querySelector('body > .loading')) return; // ローディング中なら中断
      if (prevUrl === currentUrl) return; // 表示中のURLが処理済みなら中断

      main()
      prevUrl = currentUrl; // URLを記録
    });

    window.addEventListener('load', () => {
      // ローディング中に document.body 直下に追加されるローディング表示を見るために body 直下の子の要素を監視
      observer.observe(document.body, { childList: true });
    });

    // 計算・表示
    const main = function () {
      // ...
    }
  }
)()

できました!

拡張機能で他にどんなことができる?

Chrome ウェブストアをみると本当にさまざまな拡張機能が提供されています。
アイディア次第で色々なことが出来るので、ちょっと便利になる機能などを作ってみた際には公開してみるのもいいでしょう。
個人的に面白そう、使ってみたいなと思った仕組みをいくつか紹介します。

  • 設定ページの追加、設定データや各種データをストレージへ保存(別デバイスへの同期も可能)
  • Service Worker を使って、ブラウザのページには知らせたくない認証情報を伴う外部 API リクエストを行う

この辺りを使って、ChatGPT のAPIにリクエストを送信するような拡張機能を作ってみるのも面白そうだなぁと思いました。

他には

  • 他のタブの状況を取得、操作

を使用すると、同じ URL を開いているタブがあったら閉じたり通知したりするとかったこともできそうですね。

まとめ

  • ブックマークレットを拡張機能化することで、いつもクリックしていたブックマークレットを自動で実行するように出来た
  • 目で見て好きなタイミングでクリックするブックマークレットと違い、多重実行された時の挙動や実行されるタイミング・条件を考えなければいけなかった
  • SPA のサイトでページ遷移時にスクリプトを実行したい場合には、observer を使って要素の変化や URL の変化を監視することで実現できた
GitHubで編集を提案
SocialPLUS Tech Blog

Discussion