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

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

# 作ったもの

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

[^1]: きっかけは、はてなブックマークの [公式アドオン](https://addons.mozilla.org/ja/firefox/addon/hatena-bookmark/) を重宝していて、その Qiita 版のようなものがあったらいいのになーと思ったことです。半年ぐらい前からアイディアだけあったのですが、ある日曜日（8/2）の夜に、酒を飲みながら [MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions) を眺めていたら、意外と簡単そうで、これなら1週間ぐらいで作れそうな気がした。なので作ってみました。

[^2]: 「Qiine」の語源は「Qiita」＋「いいね」です。思いつきで名付けました。

- [addons.mozilla.org](https://addons.mozilla.org/ja/firefox/addon/qiine/)
- [GitHub](https://github.com/Aminevsky/qiine)

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

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

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

[^3]: Qiita API の [ページネーション](https://qiita.com/api/v2/docs#%E3%83%9A%E3%83%BC%E3%82%B8%E3%83%8D%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3) の仕様上、最大 10,000 件まで表示することができます。（1 ページ最大 100 件 × 最大 100 ページ = 10,000 件）
[^4]: Qiita API の [利用制限](https://qiita.com/api/v2/docs#%E5%88%A9%E7%94%A8%E5%88%B6%E9%99%90) を参照。

<img width="338" alt="Qiineポップアップ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/be13f789-08f8-704e-db7b-2d97e2da152f.png">
<img width="1316" alt="Qiineタブ.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/1ffba3f4-c9f3-138e-446d-a67688067fff.png">
<img width="951" alt="Qiineオプション.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/892d540d-7cb3-83d6-8d52-f9861a8a86e5.png">

# MDN

今回、MDN の [ブラウザー拡張機能](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions) にはかなり助けられました。

[初めての拡張機能](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Your_first_WebExtension) はあまりにも簡単なので、ざっと眺める程度にして、 [2つめの拡張機能](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Walkthrough) から作り始めるのが良いように感じました。

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

# web-ext

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

[^5]: [GitHub](https://github.com/mozilla/web-ext) の以下の文章の和訳。 "This is a command line tool to help build, run, and test WebExtensions."

[2つめの拡張機能](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Walkthrough) では最後のほうに小さく書かれているだけだったり、入門記事が [英語](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/) しかなかったりと敷居が高いですが、使ってみるとかなり便利だったので、オススメです。


## インストール

`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` を開くと、 **一時的な拡張機能** としてアドオンが読み込まれていることを確認できます。

<img width="697" alt="about:debugging.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/76fde388-0fe1-4fee-078a-ba903f5541f6.png">


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

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

## 反映

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

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

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

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

## コードチェック

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

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

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

```json
{
    "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](https://addons.mozilla.org) へ登録する際は、zip ファイルで提出することになります。

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

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

# 開発ツール

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

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

<img width="697" alt="about:debugging_調査.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/345c9e6f-f042-2d14-99f3-70b137b967d1.png">


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

<img width="276" alt="開発ツール_ポップアップを自動で隠さない.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141299/3fb92456-41ce-dcdb-9986-8c521b13b063.png">


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

現在開いているタブの URL を取得する場合のように、アクティブなタブの情報を取得したいことがあると思います。そうした場合は `tabs.query()` （ [MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/tabs/query) ）を使います。

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

`tabs.query()` の戻り値は、 `tabs.Tab` 型（ [MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab) ）の配列です。 `tabs.Tab` の `url` プロパティに URL が格納されています。

なお、メソッドをコールせずに `tabs.Tab` 型を直接参照することはできません。また、メソッド名が紛らわしいですが、 `tabs.getCurrent()` （ [MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/tabs/getCurrent) ）も使えません。バックグラウンドスクリプトやポップアップで使うと `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 については [逆引きリファレンス](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Working_with_the_Tabs_API) に情報がまとめられているので、リファレンスを見る前に、そちらに目を通したほうが良いでしょう。

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

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

- [GET /api/v2/items/:item_id](https://qiita.com/api/v2/docs#get-apiv2itemsitem_id)
  - 記事に関する情報（タイトルなど）
- [GET /api/v2/items/:item_id/likes](https://qiita.com/api/v2/docs#get-apiv2itemsitem_idlikes)
  - 記事につけられた LGTM に関する情報（最大 100 件まで取得）

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

そこで、ポップアップからタブへデータを受け渡す方法を探しました。コンテンツスクリプトの解説に [バックグラウンドスクリプトとの通信](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/Content_scripts#Communicating_with_background_scripts) が載っています。どうやらバックグラウンドスクリプトとコンテンツスクリプトのあいだでメッセージを送り合うことで、データを受け渡すことができるようです。

ポップアップはそのどちらでもありませんが、 `tabs.sendMessage()` （ [MDN](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/Tabs/sendMessage) ）の概要を見ると、ポップアップでも使えるように読める記載がありました。

> 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 でシリアライズできるオブジェクトなら何でも指定できるようです。

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

次に、メッセージの送信先では `runtime.onMessage` イベント（ [MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage) ）を使って受信します。 `addListener()` の第1引数に、イベントが発生したときに実行される関数を指定します。送られてきたメッセージは、関数の第1引数（下記のコードでは `request` ）に格納されています。

```javascript
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.` と表示されて、うまく動いてくれません。

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

- [WebExtensionsのtabs.sendMessageが動かない](https://teratail.com/questions/69161)
- [chrome extension の Unchecked lastError value: Error: Could not establish connection. Receiving end does not exist.の対処法](https://www.itoukun.com/2019/11/09/chrome-extension-%E3%81%AE-unchecked-lasterror-value-error-could-not-establish-connection-receiving-end-does-not-exist-%E3%81%AE%E5%AF%BE%E5%87%A6%E6%B3%95/)

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

そこで、teratail の解決策を参考に `tabs.onUpdated` （ [MDN](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onUpdated) ）を使うことにしました。これはタブが何らか更新されたときに発火するイベントです。その中には `status` が `loading` から `complete` へ更新された場合も含まれています。このイベントにリスナー関数を追加すれば、「送信先の読み込みが完了しているときにのみメッセージを送信する」こともできそうです。

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

```javascript
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）だけでも問題なさそうですが、念の為、 `properties` に `status` を指定しています。こうすることで、 `status` （タブの読み込み状況）が更新されたときにのみ、リスナー関数が実行されるようにしています。

# Qiita API

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

## OAuth

Qiita API には、以下のような [利用制限](https://qiita.com/api/v2/docs#%E5%88%A9%E7%94%A8%E5%88%B6%E9%99%90) があります。

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

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

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

もともと、Qiita API には OAuth を使った認可の仕組みが用意されています（ [API ドキュメント](https://qiita.com/api/v2/docs#%E8%AA%8D%E8%A8%BC%E8%AA%8D%E5%8F%AF) ）。また、WebExtensions API でも [identity API](https://developer.mozilla.org/ja/docs/Mozilla/Add-ons/WebExtensions/API/identity) が用意されており、簡単に OAuth を利用することができます。

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

```javascript
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を含むことのリスク](https://qiita.com/nobuhito/items/6e2c511fdff27ff1eeda) なども見て、やっぱり違うよなと。

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

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

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

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

## user.organization

[GET /api/v2/items/:item_id/likes](https://qiita.com/api/v2/docs#get-apiv2itemsitem_idlikes) で LGTM を取得したところ、 `user.organization` （ユーザの属する組織名）に **空文字** と **null** が混在していました。[^9]

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

以下のように [ドキュメント](https://qiita.com/api/v2/docs#%E3%83%A6%E3%83%BC%E3%82%B6) にも `null` が返ってくる可能性があることは示されているのですが、空文字しか想定していなかったので、危うくバグになるところでした。

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

# 感想

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

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

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

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

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