アドオン(WebExtensions)を初めて作ってみました。その過程で、いろいろハマったので、まとめたいと思います。
作ったもの
Qiine というアドオンを作りました。1 2 いまのところ、Firefox にのみ対応しています。
主な機能は以下のとおりです。
-
LGTM 表示
- Qiita の記事を開きながらツールバーのアイコンをクリックすると、ポップアップが開きます
- ポップアップでは、その記事の LGTM を最大 100 件まで表示します
- 100 件以上表示したい場合や別タブで表示したい場合は、一番下の More をクリックしてください3
-
アクセストークン追加
- Qiita で発行されたアクセストークンを設定画面に追加すると、API の利用制限を緩和することができます4
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
を開くと、 一時的な拡張機能 としてアドオンが読み込まれていることを確認できます。
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
の「調査」ボタンを押します。
ただし、ポップアップを表示する場合は、開発ツールの設定で「ポップアップを自動で隠さない」にチェックを入れておいたほうが良いでしょう( 参考 )。画面の他の場所をクリックしてもポップアップが開いたままになります(ポップアップを閉じたいときは Esc
キーを押します)。
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.Tab
の url
プロパティに 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 を使って、以下の情報を取得します。
-
GET /api/v2/items/:item_id
- 記事に関する情報(タイトルなど)
-
GET /api/v2/items/:item_id/likes
- 記事につけられた LGTM に関する情報(最大 100 件まで取得)
ポップアップ最下部の 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.
と表示されて、うまく動いてくれません。
検索すると、同じ事象に遭遇した方々を見つけました。
- WebExtensionsのtabs.sendMessageが動かない
- chrome extension の Unchecked lastError value: Error: Could not establish connection. Receiving end does not exist.の対処法
どうやら、メッセージ送信時に、送信先のタブの読み込みが完了していないことが原因のようです。タブの読み込み状況は tabs.Tab.status
で確認でき、読み込み中の場合は loading
を、読み込みが完了した場合は complete
を返します。実際に試してみると、たしかに loading
のままになっていました。
そこで、teratail の解決策を参考に tabs.onUpdated
( MDN )を使うことにしました。これはタブが何らか更新されたときに発火するイベントです。その中には status
が loading
から 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)だけでも問題なさそうですが、念の為、 properties
に status
を指定しています。こうすることで、 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 ってどうなん?とは自分でも思いますが(苦笑)
-
きっかけは、はてなブックマークの 公式アドオン を重宝していて、その Qiita 版のようなものがあったらいいのになーと思ったことです。半年ぐらい前からアイディアだけあったのですが、ある日曜日(8/2)の夜に、酒を飲みながら MDN を眺めていたら、意外と簡単そうで、これなら1週間ぐらいで作れそうな気がした。なので作ってみました。 ↩
-
「Qiine」の語源は「Qiita」+「いいね」です。思いつきで名付けました。 ↩
-
Qiita API の ページネーション の仕様上、最大 10,000 件まで表示することができます。(1 ページ最大 100 件 × 最大 100 ページ = 10,000 件) ↩
-
GitHub の以下の文章の和訳。 "This is a command line tool to help build, run, and test WebExtensions." ↩
-
create-react-app では、
npm start
を実行しているあいだにコードを変更すると、自動で再コンパイルが行われますが、それに近いものを感じました。 ↩ -
デフォルトではテキスト形式で出力されますが、横に長くてコピペしにくかったので JSON 形式で出力しました。使用したコマンドは
web-ext lint -o json --pretty
です。 ↩ -
そういえば、Qiita API のドキュメントには、利用制限に到達した際に、どのくらいの時間が経過したら利用制限が解除されるかが明記されていないように見えます。 ↩
-
ちなみに、一度も組織名を登録したことがない私の
user.organization
は空文字でした。一度組織名を登録して削除した場合とかにnull
になるのでしょうか?(試していないですが...) ↩ -
こだわりはじめると、芋づる式にこだわって永遠に完成しないという悪癖があるので... 「1週間ぐらいで」と期限を決めたのもそのためです。 ↩
-
現在、無職3ヶ月目。そろそろどこかに転職しなければと思いつつ、なかなか良いところが見つからず。 ↩