判読困難な識別子の説明を埋め込むChrome拡張機能を作ってみた話

サムネイル

はじめに

こんにちは、レコメンドチームで機械学習エンジニアをしている24新卒の菊谷です。

最近、業務の補助ツールとしてChrome拡張機能を作りましたので、経緯や作り方を紹介したいと思います。

背景

レコメンドチームではレコメンドIDと呼ばれる識別子を管理しています。レコメンドIDはレコメンドが表示される場所やレコメンドの結果の保存先DBのパーテーションキー、レコメンドAPIのパラメータなどに紐付く大変重要な文字列です。

重要な識別子ではあるのですが、少し困った点があります。

それは、ランダムな英数字を組み合わせて発行されるために、識別子がどのレコメンドに対応しているかの類推ができない点です。例えば「r1aih9rg」のような値でこのままでは類推できないため、必要に応じて対応関係が記載されているファイルを確認する必要があります。

この確認作業が特に面倒になるのがレビュー時です。 このレコメンドIDは設定用のファイルなどに頻出するのですが、IDが問題ないかについてはレビュー画面から離れて別途対応ファイルを参照しなくてはなりません。レビュー時にチェックすべき箇所は他にもありますし、面倒さも相まって、正しいかの確認はしないという判断になってしまう可能性もありえます。

以上の背景のもと、確認の面倒さを少しでも減らせるようなChrome拡張機能を作成することにしました。

作成する拡張機能について

面倒と思う部分はレビュー画面以外のページに移って作業しなければならない点でした。このことから、作成するChrome拡張機能ではレビュー画面内で識別子に対応する情報を表示することで、レビュー画面外に移らずに確認できることを目指します。

識別子に対応する情報の表示方法はいろいろ考えられますが、今回は識別子にカーソルを当てると補足説明を表示できるツールチップを使うことにしました。このツールチップによって、以下のように補足説明を表示できます。

拡張機能のサンプル

それでは実際に拡張機能を作っていきます!

拡張機能の作成

事前準備

拡張機能を作るうえで必ず用意しなければならないのが設定ファイルのmanifest.jsonです。manifest.jsonは拡張機能の名前や説明、バージョン情報、使用するスクリプトのパスなどのメタデータを記載します。

{
    "manifest_version": 3,
    "name": "ID-Viewer",
    "description": "識別子にカーソルを当てると補足説明を表示させる拡張機能",
    "version": "1.0",
    "content_scripts": [
        {
          "js": ["scripts/content.js"],
          "css": ["styles.css"],
          "matches": [
            "<all_urls>"
          ]
        }
    ]
}

ここではCSSとJavaScriptを使うので、content_scriptsに使用するファイルパスを与えています。また、matchesにはスクリプトを適用するWebページのURLパターンを指定します。今回は開発フェーズなので<all_urls>を与えており、これはすべてのページに適用することを意味します。

使用するstyles.cssについてはツールチップのクラス(tooltipクラスとtootip-textクラス)を記載します。以下に素敵な見た目のツールチップのCSSがあるので、そのまま使用させていただきました。

最後にscripts/content.jsを追加しておきます。こちらについては後ほど実装するので空のファイルです。

フォルダ構成としては以下となっています。

id-viewer
├── manifest.json
├── scripts
│   └── content.js
└── styles.css

拡張機能のパッケージ化

まずは、Google Chromeの拡張機能ページ(chrome://extensions)を開きます。次に画面右上のデベロッパーモードを有効化します。

続いて「パッケージ化されていない拡張機能を読み込む」というボタンを押して、id-viewerフォルダを選択します。

拡張機能の読み込み

すると、すべての拡張機能の下に読み込んだパッケージが反映されます。

ファイルを変更した際は、追加したパッケージの再読み込みを押すことで変更内容の反映ができます。

拡張機能の再読み込み

スクリプトの実装

ここでは、scripts/content.jsを編集していきます。

やりたいことはWebページ内にある識別子を見つけてツールチップを埋め込むことです。全体の流れとしては次の通りです。

  1. ページ全体からテキストノードのみをフィルタリングする。
  2. テキストノードからテキストを取り出し、正規表現でテキスト内に識別子を含むテキストノードのみを取得する。
  3. 識別子を含むテキストノードに対して、識別子の文字列はツールチップの要素ノードに、それ以外はテキストノードとして元のテキストノードと置き換える。

以降では、簡単な例として、ページのBody要素が以下で構成されている場合を考えます。識別子は「hogefuga_id」としましょう。

<body>           <!-- 要素ノード -->
    拡張機能の例   <!-- テキストノード -->
    hogefuga_id  <!-- テキストノード -->
</body>

Webページは文書の内容をノードによるツリー構造によって表現し、<body>などは要素ノード、テキスト部分はテキストノードと呼ばれます。そのため、「hogefuga_id」はテキストノードに含まれるテキストに該当します。

ここで行いたいことは、識別子を持つテキストノードをツールチップのノードに置き換えることです。「hogefuga_id」に対応する説明を「hogefuga_idの説明」にした場合、「hogefuga_id」のテキストノードをstyles.cssで定義されているツールチップのクラスで装飾すると次のようになります。

<span class="tooltip">
    <span class="tooltip-text">hogefuga_idの説明</span>
    hogefuga_id
</span>

ここでのtooltiptooltip-textクラスは要素ノードをツールチップとして機能させるためのスタイルを適用しています。tooltipはツールチップの表示位置の相対化とカーソルを当てた時にポインタが変化し、tooltip-textは見た目の定義とカーソルを当てるまではテキストが非表示になる設定がされています。これにより、識別子にマウスカーソルを合わせると説明文が表示されるようになります。

識別子に対して上記の変換ができると、先ほどのGIFのようなツールチップによる補足説明機能が実現できます。テキストからツールチップの要素ノードを作成する関数については次のように書けます。

function createTooltipElement(text, tooltipText) {
    const tooltipElement = document.createElement("span");
    tooltipElement.className = "tooltip";
    tooltipElement.textContent = text;

    const tooltipTextElement = document.createElement("span");
    tooltipTextElement.className = "tooltip-text";
    tooltipTextElement.textContent = tooltipText;

    tooltipElement.appendChild(tooltipTextElement);
    return tooltipElement;
}

使い方としては、引数textには識別子の文字列、引数tooltipTextに補足説明の文字列を渡します。

この関数を使うには識別子を見つける必要があるため、識別子を含むテキストノードを集めるための処理を作ります。ページ内のテキストノードについてはdocument.createTreeWalkerの引数にNodeFilter.SHOW_TEXTを与え、nextNode()メソッドを繰り返すことで取得できます。

さらに現在のノードであるtextNodeが持つテキストはtextNode.textContentで取得可能です。このテキストに対して正規表現で識別子見つけて、textNodesの要素として追加していきます。コードにすると以下の通りです。

    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
    const textNodes = [];
    const regex = new RegExp(`(${Object.keys(tooltipTextMap).join("|")})`, "g");
    while (walker.nextNode()) {
        const textNode = walker.currentNode;
        const text = textNode.textContent;
        if (regex.test(text)) {
            textNodes.push(walker.currentNode);
        }
    }

ここでのtooltipTextMapはキーに識別子、値に補足説明を持つMapオブジェクトです。テキストノードのテキストがtooltipTextMapの持つキー(識別子)と一致した場合は、そのテキストノードをtextNodesに追加します。

続いて、取得したテキストノードをツールチップの要素ノードからなる新しいノードに置き換えていきます。テキストノードには識別子以外の文字列も含まれているため、正規表現を使って識別子とそれ以外の文字列に分割します。分割した文字列は識別子ならツールチップに変換し、それ以外ならテキストノードを作成します。そして、createDocumentFragmentで作成した新しい仮の親ノードに対してそれぞれを子ノードとして追加します。その後、元のテキストノードを置き換えるという一連の操作をtextNodesの数だけ繰り返します。

    textNodes.forEach(textNode => {
        const text = textNode.textContent;
        const parentNode = textNode.parentNode;
        const fragment = document.createDocumentFragment();
        text.split(regex).forEach(part => {
            if (tooltipTextMap[part]) {
                const tooltipElement = createTooltipElement(part, tooltipTextMap[part]);
                fragment.appendChild(tooltipElement);
            }
            else {
                fragment.appendChild(document.createTextNode(part));
            }
        });
        parentNode.replaceChild(fragment, textNode);
    });

2つコードはreplaceTextNodesToTooltips関数としてまとめました。ページが読み込み終わったタイミングでtooltipTextMapを定義し、replaceTextNodesToTooltips関数を呼ぶようにします。

window.onload = function() {
    const tooltipTextMap = {
        "hogefuga_id": "hogefuga_idの説明"
    };
    replaceTextNodesToTooltips(tooltipTextMap);
}

以上のコードをscripts/content.jsとして保存します。最終的なscripts/content.jsは次の通りです。

function createTooltipElement(text, tooltipText) {
    const tooltipElement = document.createElement("span");
    tooltipElement.className = "tooltip";
    tooltipElement.textContent = text;

    const tooltipTextElement = document.createElement("span");
    tooltipTextElement.className = "tooltip-text";
    tooltipTextElement.textContent = tooltipText;

    tooltipElement.appendChild(tooltipTextElement);
    return tooltipElement;
}

function replaceTextNodesToTooltips(tooltipTextMap) {
    const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
    const textNodes = [];
    const regex = new RegExp(`(${Object.keys(tooltipTextMap).join("|")})`, "g");
    while (walker.nextNode()) {
        const textNode = walker.currentNode;
        const text = textNode.textContent;
        if (regex.test(text)) {
            textNodes.push(walker.currentNode);
        }
    }

    textNodes.forEach(textNode => {
        const text = textNode.textContent;
        const parentNode = textNode.parentNode;
        const fragment = document.createDocumentFragment();
        text.split(regex).forEach(part => {
            console.log(part);
            if (tooltipTextMap[part]) {
                const tooltipElement = createTooltipElement(part, tooltipTextMap[part]);
                fragment.appendChild(tooltipElement);
            }
            else {
                fragment.appendChild(document.createTextNode(part));
            }
        });
        parentNode.replaceChild(fragment, textNode);
    });
}

window.onload = function() {
    const tooltipTextMap = {
        "hogefuga_id": "hogefuga_idの説明"
    };
    replaceTextNodesToTooltips(tooltipTextMap);
}

保存後は拡張機能ページの再読み込みを押してパッケージに反映をすれば完成です。

動作確認

以下をsample.htmlとして保存し、Chrome上で開きます。

<html lang="ja">
    <head>
        <meta charset=”utf-8″>
        <title>拡張機能のサンプル</title>
        <link rel="stylesheet" href="styles.css" type="text/css">
    </head>
    <body>
        拡張機能の例
        hogefuga_id
    </body>
</html>

「hogefuga_id」の部分にカーソルを当てた際に補足説明が表示されれば成功です。

おわりに

本記事では、判読困難な識別子をレビュー時に確認する作業が面倒であるという問題を解決するために、Chrome拡張機能を作成する方法を紹介しました。運用はこれからですが、この拡張機能を活用することで作業効率やレビューの正確性が高まると考えています。

この開発を通して、Chrome拡張機能の作成自体は想定よりも簡単で、日々の作業を効率化するツールは自作可能なことを実感しました。今後も業務の効率を上げるための取り組みを進めていきたいと思っています。

もしわかりにくい識別子でお困りでしたら、ぜひ皆さんの開発環境でも導入してみてください!

最後に、DMMグループでは一緒に働いてくれる仲間を募集しています!ご興味のある方は、ぜひ下記の募集ページをご確認ください!

dmm-corp.com