LoginSignup
9
8

More than 3 years have passed since last update.

初めてアドオン(WebExtensions)を作ってみたのでハマった点をまとめてみる

Posted at

アドオン(WebExtensions)を初めて作ってみました。その過程で、いろいろハマったので、まとめたいと思います。

作ったもの

Qiine というアドオンを作りました。1 2 いまのところ、Firefox にのみ対応しています。

主な機能は以下のとおりです。

  • LGTM 表示

    • Qiita の記事を開きながらツールバーのアイコンをクリックすると、ポップアップが開きます
    • ポップアップでは、その記事の LGTM を最大 100 件まで表示します
    • 100 件以上表示したい場合や別タブで表示したい場合は、一番下の More をクリックしてください3
  • アクセストークン追加

    • Qiita で発行されたアクセストークンを設定画面に追加すると、API の利用制限を緩和することができます4

Qiineポップアップ.png
Qiineタブ.png
Qiineオプション.png

MDN

今回、MDN の ブラウザー拡張機能 にはかなり助けられました。

初めての拡張機能 はあまりにも簡単なので、ざっと眺める程度にして、 2つめの拡張機能 から作り始めるのが良いように感じました。

リファレンス の海にいきなり飛び込むのもアリだと思いますが、個人的には 逆引きリファレンス を先に見ておけば良かったと思うこともありました(後述)。

web-ext

web-ext は「WebExtensions のビルド・実行・テストを支援するコマンドラインツール」5 です。

2つめの拡張機能 では最後のほうに小さく書かれているだけだったり、入門記事が 英語 しかなかったりと敷居が高いですが、使ってみるとかなり便利だったので、オススメです。

インストール

web-ext は Node.js ベースなので、あらかじめ Node.js をインストールしておく必要があります。

Node.js をインストールしたら、以下のコマンドを実行して web-ext をインストールします。

npm install --global web-ext

実行

アドオンを開発しているディレクトリへ移動し、 web-ext run を実行します。

$ cd アドオンの開発ディレクトリ
$ web-ext run

web-ext run はディレクトリに格納されているアドオンを読み込んで、Firefox を起動します。 about:debugging を開くと、 一時的な拡張機能 としてアドオンが読み込まれていることを確認できます。

about:debugging.png

web-ext run は、デフォルトでは一時プロファイルを作って Firefox を起動しますが、既存のプロファイルを指定することもできます。例えば、 develop というプロファイルを使いたい場合は、以下のように指定します。

$ web-ext run --firefox-profile=develop

反映

web-ext を使わない場合、コードを変更するたびに、 about:debugging を開き、「再読み込み」ボタンを押す必要があります。実際に何回かやってみると分かると思いますが、コードを変更するたびにボタンを押すのは面倒ですし、押し忘れていたときのイライラ感は耐えがたいものです。

web-ext を使う場合、「再読み込み」ボタンを押す必要はありません。コードが変更されたことを web-ext run が検知して、自動的に再読み込みを行ってくれるからです。6

なお、 web-ext run を実行しているときに誤って「再読み込み」ボタンを押すと、同じアドオンが重複して読み込まれてしまうので注意が必要です。

コードチェック

web-ext lint を実行すると、コードをチェックしてくれます。例えば、サニタイズせずに innerHTML に代入しようとしている場合は、以下のような警告が表示されます。7

$ cd アドオンの開発ディレクトリ
$ web-ext lint
{
    "count": 1,
    "summary": {
        "errors": 0,
        "notices": 0,
        "warnings": 1
    },

    (中略)

    "errors": [],
    "notices": [],
    "warnings": [
        {
            "_type": "warning",
            "code": "UNSAFE_VAR_ASSIGNMENT",
            "message": "Unsafe assignment to innerHTML",
            "description": "Due to both security and performance concerns, this may not be set using dynamic values which have not been adequately sanitized. This can lead to security issues or fairly serious performance degradation.",
            "column": 9,
            "file": "src/controller/OptionsController.js",
            "line": 102
        }
    ]
}

ビルド

アドオンが完成して addons.mozilla.org へ登録する際は、zip ファイルで提出することになります。

web-ext build を使うと、 .git など通常パッケージ化する上で不要なファイルを無視して zip 化してくれます。

$ cd アドオンの開発ディレクトリ
$ web-ext build

開発ツール

アドオンといっても、WebExtensions API を除けば、普通の HTML / CSS / JavaScript です。通常の Web 開発と同様に、インスペクターやコンソール、デバッガーなどの 開発ツール を使うことができます。

アドオンで開発ツールを使うには、 about:debugging の「調査」ボタンを押します。

about:debugging_調査.png

ただし、ポップアップを表示する場合は、開発ツールの設定で「ポップアップを自動で隠さない」にチェックを入れておいたほうが良いでしょう( 参考 )。画面の他の場所をクリックしてもポップアップが開いたままになります(ポップアップを閉じたいときは Esc キーを押します)。

開発ツール_ポップアップを自動で隠さない.png

WebExtensions API

アクティブなタブの情報を取得するには

現在開いているタブの URL を取得する場合のように、アクティブなタブの情報を取得したいことがあると思います。そうした場合は tabs.query()MDN )を使います。

const tabs = await browser.tabs.query({active: true, currentWindow: true});
const url = tabs[0].url;

tabs.query() の戻り値は、 tabs.Tab 型( MDN )の配列です。 tabs.Taburl プロパティに URL が格納されています。

なお、メソッドをコールせずに tabs.Tab 型を直接参照することはできません。また、メソッド名が紛らわしいですが、 tabs.getCurrent()MDN )も使えません。バックグラウンドスクリプトやポップアップで使うと undefined が返ってくるからです。MDN にも以下のように注意書きがあります。

Note: This function is only useful in contexts where there is a browser tab, such as an options page.

If you call it from a background script or a popup, it will return undefined.

Tabs API については 逆引きリファレンス に情報がまとめられているので、リファレンスを見る前に、そちらに目を通したほうが良いでしょう。

ポップアップからタブへメッセージを送るには

もしかすると、ここが一番苦戦したところかもしれません。Qiine では、ポップアップを開いた際に Qiita API を使って、以下の情報を取得します。

ポップアップ最下部の More ボタンを押した場合、新しくタブを開きますが、そこにも同じ情報を表示します。すでに取得済みのデータをもう一度 API で取得するのは API を消費しすぎるので避けたいところです。

そこで、ポップアップからタブへデータを受け渡す方法を探しました。コンテンツスクリプトの解説に バックグラウンドスクリプトとの通信 が載っています。どうやらバックグラウンドスクリプトとコンテンツスクリプトのあいだでメッセージを送り合うことで、データを受け渡すことができるようです。

ポップアップはそのどちらでもありませんが、 tabs.sendMessage()MDN )の概要を見ると、ポップアップでも使えるように読める記載がありました。

Sends a single message from the extension's background scripts (or other privileged scripts, such as popup scripts or options page scripts) to any content scripts or extension pages/iframes that belong to the extension and are running in the specified tab.

まず、メッセージの送信元であるポップアップ側を作ります。 tabs.sendMessage() をコールして、指定したタブにメッセージを送信します。第1引数には送信先のタブ ID を、第2引数にはメッセージを指定します。メッセージには、JSON でシリアライズできるオブジェクトなら何でも指定できるようです。

await browser.tabs.sendMessage(tabId, {
    item: this._item,
    lgtms: this._lgtmList,
    lastPageNo: this._lastPageNo
});

次に、メッセージの送信先では runtime.onMessage イベント( MDN )を使って受信します。 addListener() の第1引数に、イベントが発生したときに実行される関数を指定します。送られてきたメッセージは、関数の第1引数(下記のコードでは request )に格納されています。

browser.runtime.onMessage.addListener((request) => {
    const controller = new MoreController();
    controller.init(request.item, request.lgtms, request.lastPageNo);
});

これでうまくいくと思ったのですが... コンソールに Could not establish connection. Receiving end does not exist. と表示されて、うまく動いてくれません。

検索すると、同じ事象に遭遇した方々を見つけました。

どうやら、メッセージ送信時に、送信先のタブの読み込みが完了していないことが原因のようです。タブの読み込み状況は tabs.Tab.status で確認でき、読み込み中の場合は loading を、読み込みが完了した場合は complete を返します。実際に試してみると、たしかに loading のままになっていました。

そこで、teratail の解決策を参考に tabs.onUpdatedMDN )を使うことにしました。これはタブが何らか更新されたときに発火するイベントです。その中には statusloading から complete へ更新された場合も含まれています。このイベントにリスナー関数を追加すれば、「送信先の読み込みが完了しているときにのみメッセージを送信する」こともできそうです。

最終的に書いたコードは以下のとおりです。 tabs.onUpdated にリスナー関数を登録し、送信先のタブの読み込みが完了したときにのみ sendMessage() をコールしています。

async displayMorePage() {
    // 別タブを作成する
    const tab = await browser.tabs.create({
        url: "/page/more/more.html"
    });

    // onUpdated で実行されるリスナー関数
    const listener = async (tabId, changeInfo, tab) => {
        // 読み込み中の場合は何もしない
        if (tab.status !== "complete") {
            return;
        }

        // メッセージを送信する
        await browser.tabs.sendMessage(tabId, {
            item: this._item,
            lgtms: this._lgtmList,
            lastPageNo: this._lastPageNo
        });
    };

    // onUpdated にリスナー関数を追加する
    browser.tabs.onUpdated.addListener(listener, {
        properties: ["status"],
        tabId: tab.id
    });
}

なお、 tabs.onUpdated.addListener() の第2引数は tabId (タブ ID)だけでも問題なさそうですが、念の為、 propertiesstatus を指定しています。こうすることで、 status (タブの読み込み状況)が更新されたときにのみ、リスナー関数が実行されるようにしています。

Qiita API

ここからは、Qiita API に特化した話題を書いていきます。

OAuth

Qiita API には、以下のような 利用制限 があります。

認証している状態ではユーザごとに1時間に1000回まで、認証していない状態ではIPアドレスごとに1時間に60回までリクエストを受け付けます。

Qiine では、Qiita API で一度に取得できる上限の100件単位で LGTM を取得していますが、それでもかなりの量の API を消費することが考えられ、認証認可はぜひ実装したいと思いました。8

もともと、Qiita API には OAuth を使った認可の仕組みが用意されています( API ドキュメント )。また、WebExtensions API でも identity API が用意されており、簡単に OAuth を利用することができます。

そこで、それらを組み合わせて、当初、以下のようなコードを書いていました。最終的には破棄しましたが、参考までに記載しておきます。

document.querySelector("#login").addEventListener("click", () => {
    // Qiita で発行された Client ID、Client Secret
    const clientId = "****************************************";
    const clientSecret = "******************";

    let authUrl = "https://qiita.com/api/v2/oauth/authorize";
    authUrl += "?client_id=" + clientId;
    authUrl += "&scope=read_qiita";

    browser.identity.launchWebAuthFlow({
        interactive: true,
        url: authUrl,
    }).then(authResult => {
        // リダイレクト先の URL から code を抜き出す
        const code = (new URL(authResult)).searchParams.get("code");

        const body = {
            "client_id": clientId,
            "client_secret": clientSecret,
            "code": code
        };

        // アクセストークンを取得する
        fetch("https://qiita.com/api/v2/access_tokens", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify(body),
            mode: "cors"
        }).then(response => {
            if (!response.ok) {
                throw new Error("Fail to Authorize.");
            }

            return response.json();
        }).then(data => {
            const token = data.token;

            // ストレージへトークンを保存する
            browser.storage.local.set({
                token: token
            });
        });
    })
});

このコードを破棄した理由は「 client_secret を配布物に含めたり GitHub に載せるのは良くないのでは?」と思ったからです。配布物にOAuth2で利用するclient_secretを含むことのリスク なども見て、やっぱり違うよなと。

Qiita API が他のフローも用意してくれているのであれば、それを使ったのですが、OAuth を使ったフローは client_secret を使うフローしか無いようでした。

他にアクセストークンを発行する方法は、ユーザが自分で Qiita の管理画面から発行する方法しか無いようでした。

アクセストークンは、後述するOAuthを利用した認可フローか、ユーザの管理画面から発行できます。

そこで、ユーザが自分でアクセストークンを発行し、それをアドオンの設定画面で追加できるようにすることで、お茶を濁すことにしました。

user.organization

GET /api/v2/items/:item_id/likes で LGTM を取得したところ、 user.organization (ユーザの属する組織名)に 空文字null が混在していました。9

以下のように ドキュメント にも null が返ってくる可能性があることは示されているのですが、空文字しか想定していなかったので、危うくバグになるところでした。

organization
所属している組織
Example: "Increments Inc"
Type: null, string

感想

実際に作ってみると、アドオンも、中身は普通の HTML / CSS / JavaScript なんだなと思いました。正直、WebExtensions API で躓いたところよりも、CSS や JavaScript で躓いたところのほうが多かった気がします(あまりにも低レベル過ぎて、あえて記事には書きませんでしたが)。

火狐教徒なので Firefox を優先しましたが、いずれは Chrome にも対応したいですね。同じ WebExtensions API なので、そのまま動くのかと思いきや、どうやら Polyfill を使わないといけないらしく... リリースを優先して、そちらは一旦先送りにしました。10 何かありましたら、GitHub のほうへ Issue を立てていただければ幸いです。

転職活動とは全く関係なく11、純粋に個人的に欲しかったので作りはじめたのですが、addons.mozilla.org の審査もパスしたし、これをポートフォリオにしようかなと思いました。PHP の経験を PR しているのに、ポートフォリオが JavaScript ってどうなん?とは自分でも思いますが(苦笑)


  1. きっかけは、はてなブックマークの 公式アドオン を重宝していて、その Qiita 版のようなものがあったらいいのになーと思ったことです。半年ぐらい前からアイディアだけあったのですが、ある日曜日(8/2)の夜に、酒を飲みながら MDN を眺めていたら、意外と簡単そうで、これなら1週間ぐらいで作れそうな気がした。なので作ってみました。 

  2. 「Qiine」の語源は「Qiita」+「いいね」です。思いつきで名付けました。 

  3. Qiita API の ページネーション の仕様上、最大 10,000 件まで表示することができます。(1 ページ最大 100 件 × 最大 100 ページ = 10,000 件) 

  4. Qiita API の 利用制限 を参照。 

  5. GitHub の以下の文章の和訳。 "This is a command line tool to help build, run, and test WebExtensions." 

  6. create-react-app では、 npm start を実行しているあいだにコードを変更すると、自動で再コンパイルが行われますが、それに近いものを感じました。 

  7. デフォルトではテキスト形式で出力されますが、横に長くてコピペしにくかったので JSON 形式で出力しました。使用したコマンドは web-ext lint -o json --pretty です。 

  8. そういえば、Qiita API のドキュメントには、利用制限に到達した際に、どのくらいの時間が経過したら利用制限が解除されるかが明記されていないように見えます。 

  9. ちなみに、一度も組織名を登録したことがない私の user.organization は空文字でした。一度組織名を登録して削除した場合とかに null になるのでしょうか?(試していないですが...) 

  10. こだわりはじめると、芋づる式にこだわって永遠に完成しないという悪癖があるので... 「1週間ぐらいで」と期限を決めたのもそのためです。 

  11. 現在、無職3ヶ月目。そろそろどこかに転職しなければと思いつつ、なかなか良いところが見つからず。 

9
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
8