Chrome Extensionを作ってみよう

この章では、どのようにChrome Extensionの開発を進めていくか、実際に開発したExtensionを例に、ハンズオン形式で具体的に解説していきます。 作りたいものは既に無限に存在していると仮定しますが、一応見つけるヒントも書いておきます。

作りたいものの探し方

Chrome Extensionは、ブラウザの様々な動作に影響を及ぼすことができます。 普段ブラウザを使っている時に、何かがめんどくさいとか、何かを自動化したいとか、特定のWebサイトが見づらいとか、ショートカットキーが欲しいとか、ブラウザの機能が足りないとか、痒いところに微妙に手が届かないとか、いろいろ不満を抱えているのではないでしょうか? これらの不満の多くはChrome Extensionを作ることにより解決できるものがほとんどだと思われます。
おそらくみなさん起きている時間の90%くらいをChromeの上で過ごしているはずですが、 そのような人生の非常に大きなウェイトを占めるChromeの不満を、そのままにしておいては絶対にいけません。 確実に人間の精神を蝕んでいきます。 ブラウザを使っていて、少しでも「めんどくさい」とか「使いにくい」と感じ、 それが2回目もあるようならば、Chrome Extensionを作る価値があります。 自分専用でもいいのです。

作るもの

例として、ダウンロードしたファイルの名前を変えるExtensionを作ります。 iwara.tvというMMD専用の動画サービスでは、動画ファイルのダウンロードが可能なのですが、ユーザがアップロードしたファイル名のままダウンロードされてしまいます。
これでは見たい動画を探すのに不便なのは明らかで、以前は手作業で iwara/ユーザ名/動画タイトル.mp4 というファイル名にリネームして保存していました。 一度にたくさんのタブを開き同時ダウンロードするので、ダウンロードした動画をひとつひとつ再生し、サイト上でも再生し、一致するタブを探しリネームするというかなり手間のかかる作業です。 この作業のめんどくささのため、動画を使うまでのハードルが非常に高くて困っていました。 あるときいい加減めんどくさい!!と思い、Extension化を決断しました。

実現可能性を探る

まず重要なことに、サイトのDOMからユーザ名動画タイトルを拾わなければならないので、Content Scriptは必須だというのが判明しました。 動画をダウンロードさせる方法は、この段階ではまだ決めていませんが、候補はいくつかに絞られます。
  • サイトのダウンロードボタンを再利用する
  • Page Actionのボタンを使う
サイトに新たにダウンロードボタンを追加することもできますが、 それよりもPage Actionを使ったほうがスジが良さそうです。 サイトのダウンロードボタンを再利用できるのが最良です。
ダウンロード手段も、
  • ブラウザのダウンロードを使い
    • 始まる前にリネームする
    • 終わった後にリネームする
  • それ以外のなにかしらの方法でダウンロードする
くらいしかパターンはないでしょう。
次は、API一覧を見て、使えそうなAPIがないか調べてみます。 chrome.downloadsというAPIが見つかったので、もしかしたらこれが使えるのではないかと期待します。 APIを見てみると download というメソッドと、 `onCreatedonDeterminingFilename などのイベントがあることがわかります。 なんとかなりそうな気がしたので、このAPIを試してみることにします。
iwara-downloader のディレクトリを作り、yo で雛形を生成します。

このイベントはなんじゃ?

とりあえず、Chrome Extensionではイベントリスナを登録する手法のほうが自然なので、まずイベントの方に注目します。 onCreated より onDeterminingFilename、 直訳すると「名前を決定するとき」というイベントが気になります。
ドキュメントを読むと
ファイル名を決定するとき、Extensionは DownloadItem.filename を上書きする機会が与えられます
まさにドンピシャの機能です。 これを試してみましょう。 background.js にこんなコードを書いてみます。
chrome.downloads.onDeterminingFilename.addListener((download, suggest) => {
console.log(download);
suggest({filename: 'hello/world.mp4'});
});
このExtensionを登録し、適当なサイトでファイルをダウンロードしてみると、~/Downloads/hello/world.mp4 というファイル名で保存されます。 大成功ですね。 勝ったな! ガハハ!!という気分ですが、間違いなくこれは敗北フラグです。
さてこれを
  • iwara.tvで
  • HTMLを見て適切な名前を付ける
を満たそうとするととたんに難しくなります。
まずBackground Pageはブラウザ全体に影響するので、iwara.tvだけに限定するならダウンロードURLなどで判断するしかありません。 そして何より問題なのは、どのタブでダウンロードされたかがわからないので、Content Scriptに問い合わせることが難しいのです。
ひとつだけ手はあるかもしれません。 chrome.tabs APIの onActivated を使い、常にアクティブなタブを監視します。 今見ているタブがiwara.tvの動画ページかどうか、そうであればユーザ名/動画タイトルは何か、という情報を取得しておけば、ほぼ上手くいきます。 ほぼというのは、ダウンロードボタンを押し、onDeterminingFilename が呼ばれる前にタブを移動してしまった場合、上手く行かない可能性があるということで、これは実際の作業ではおそらく頻繁に起こるでしょう。 人間の操作スピードと気の短さは、ダウンロードの準備ができるすなわちWebページがレスポンスを返す時間よりも圧倒的に短いからです。
ここまで試してみた結果、イベントを使うのは難しそうだと考え方針を転換しました。

download メソッドを使ってみる

chrome.downloads APIには、もうひとつ download メソッドという気になるものがあります。 こちらも試してみましょう。 ダウンロードするという機能を考えると、Content Scriptでやるのが一番相応しそうに見えますが、やはりBackground Pageでしか動きません。
Background Pageにコードを書いて試す場合、何かしらのイベントの中でやらないと検証しにくいということが多々あります。 そのため、いったんアイコンを押すとダウンロードが発動するようなコードを書いてみます。
chrome.browserAction.onClicked.addListener(tab => {
chrome.downloads.download({url: 'https://google.com/', filename: 'hello/world.txt'});
});
Extensionのアイコンをクリックすると、https://google.com/ のHTMLが ~/Downloads/hello/world.txt に保存されるでしょう。 これで任意のURLを任意のファイル名でダウンロードできることが判明しました。 download メソッドを使うという方針が確定します。

本物のイベントを探る

仮に置いた chrome.browserAction.onClicked のことはいったん忘れて、次はContent Script側を試します。 というのも、Content Scriptの中で上手くMessageを送るタイミングを見つけられたのなら、おそらくこのコードは chrome.runtime.onMessage に置き換えられるからです。
まず、Content Scriptは対象URLを限定できるので、 http://*.iwara.tv/videos/* にマッチするURLだけで読み込まれるようにします。
Content Scriptは普通にサイトにJavaScriptを追加するように書けるので、自分がiwara.tvの管理者になった気分でコードを書いていきます。 インスペクタでHTMLを見ながら、本物のダウンロードボタンの仕様を確認しましょう。
<div class="node-buttons">
<a href="/download/12345" target="_blank" class="btn btn-primary">
<span class="glyphicon glyphicon-arrow-down"></span>
ダウンロード
</a>
</div>
となっています。 単純な <a> タグであることがわかります。 Content Scriptでクリックイベントを追加してみましょう。
$('.node-buttons > a').on('click', e => {
e.stopPropagation();
e.preventDefault();
});
この状態のExtensionを読み込み、おもむろにダウンロードボタンを押してみます。 何も起こりません。 なんとデフォルトのイベント伝搬はExtensionからでも止められることが判明しました。
この段階でやっと、今度こそ本気で勝ったなガハハ!!の確信が持てました。 chrome.runtime.sendMessage でBackground Pageにメッセージを送りましょう。 メッセージの中身は、href に指定されたファイルのURLと、DOMからタイトルとユーザ名を探し、保存したいファイル名を指定します。
const username = sanitize($('.node-info .username').text()).trim();
const title = sanitize($('.node-info .title').text()).trim();
$('.node-buttons > a').on('click', e => {
e.stopPropagation();
e.preventDefault();
chrome.runtime.sendMessage({
url: url.resolve(location.toString(), e.target.href),
filename: path.join('iwara', username, `{filename}.mp4`}
});
});
もちろん、Background Page側も chrome.runtime.onMessage に差し替えです。
こうしてできあがったのが iwara-downloaderです。

ユニットテストをしよう

開発するからにはテストが書きたくてウズウズしちゃうんじゃないでしょうか? でも残念、テストはほとんどできません。 諦めましょう。
なぜならChrome JavaScript APIの動作が予測不可能だからです。 例えば、 chrome.downloads.download が本当にファイルをダウンロードしてくれるのか分かりません。 download メソッドに、想定する通りの引数が渡るかどうかSpyを設定することはできるでしょう。 しかし、Content Scriptからメッセージを飛ばすところを再現するのは非常に困難です。 そしておそらく、Chrome以外で実際にAPIを叩ける環境は無いでしょう。
結局、テストする方法は今のところなさそうなので、あっさりと諦めたほうが心は安泰です。

ふりかえる

まず最初に、どんなAPIを使うか考えます。 ブラウザを構成する要素にはほとんど対応するAPIが用意されています。 作りたいものを手動でやった場合に使う要素を考えれば、おそらく必要なAPIが見つかるでしょう。 今回はダウンロードしたファイルをどうにかする話なので、download とか、もしかしたら file とかそんな名前かもと当たりをつけて探します。 偶然 chrome.downloads が見つかったのでそれを試してみました。
おそらくそのAPIを使える場所は、Background Pageになるはずです。 次は、Background PageでそのAPIが想定通りに使えるかどうかを試します。 試すときはBrowser Actionの onClicked を使うと楽でしょう。 今回は、最終的に chrome.downloads.download メソッドを使えば上手く行くことが判明しました。
次に、実際にどんなイベントでその機能が発動するか考えます。 Webページ内から操作したければContent Scriptとメッセージをやり取りするし、 例えばタブが切り替わった時に発動したいのなら、同じようにAPI一覧からtabっぽいAPIを探し、イベントを選びます。 すぐに chrome.tabs.onActivated が見つかるでしょう。
この段階で、おそらく欲しい機能を満たすことはできているでしょう。 いくつかの機能が必要なら、同じことを繰り返すだけです。 あとは使いやすさを向上させるために、Content Script側をがんばれば、いいものができあがるのではないでしょうか