※この記事では、Google Chrome エクステンションで、JSONP で提供されている API を呼び出して、その結果を background.js
に送る方法を説明しています。これがなかなか素直に実装することが出来ず、色々と工夫が必要なのですが、背景だの問題点だのはいいから結論だけ欲しい方は解決編を直接ご覧下さい。
背景
3 年近く前の更新からずっと何もしていなかったのに、突然思い立って最近フルスクラッチで書き直した「Feedly はてブ」という Google Chrome エクステンションがあります。この記事はその Ver2 を書いている際、はてなブックマーク API の呼び出しで色々と詰まった点ならびにその解決策を知見としてまとめています。
このエクステンションは、Feedly で読み込んでいる RSS / ATOM の各記事に、はてなブックマーク数を表示するというエクステンションなのですが、以前のバージョンではブックマーク数を表示するためにそれを表す画像を埋め込むという方式を採用していました。例えば https://b.hatena.ne.jp/entry/image/https://www.google.com
は Google のホームページについたブックマーク数を表す画像を返すので、こういった URL を生成して画像として Feedly のページに埋め込むという事です。
この方式はどんな場所にも気軽に埋め込めるという反面、ちゃんとした API のレスポンスよりもデータの更新が遅い、デザインの自由度が全く無い、そもそも公式ドキュメントで触れられておらずいつ無くなってもおかしくない、などの問題がありました。ですのでまず、Ver2 を書くにあたってはきちんと公式のサポートがある API を利用する事にしました。
また、Chrome エクステンションでは各ページのロードごとに読み込まれる content.js
やエクステンション起動時からずっとバックグラウンドで走り続ける background.js
などが利用できるのですが、API の結果は background.js
側でキャッシュして、ページをリロードしても過度に API を呼び出さないようにすることにしました。それによってサーバ側の負荷低減とクライアント側のレスポンスの高速化を狙っています。
以上を踏まえて、どのような問題点があったのか、そしてそれをどのように解決したのかを以下で解説します。
問題点
CORS policy のためにエクステンションのスクリプトから API を fetch 出来ない
API としては、はてなブックマークエントリー情報取得APIで解説されている「高速なレスポンスの API」を使用します。これはとてもシンプルで、対象となる URL を含むアクセスポイントに GET
でアクセスすると、ブックマーク情報が入った JSON が取得出来るというものです。例えばお手元のターミナルで curl "https://b.hatena.ne.jp/entry/jsonlite/?url=https%3A%2F%2Fwww.google.com"
とでもすれば、簡単に望む結果が得られることでしょう。
そこで最初は無邪気に background.js
内で const result = await fetch(apiUrl)
してみたのですが、今どき当たり前というか "blocked by CORS policy" というエラーで失敗しました。これは API が HTTP レスポンスの中に Access-Control-Allow-Origin: https://b.hatena.ne.jp
というヘッダを含めているからで、簡単に言えば、ブラウザ上から API を呼び出す際は https://b.hatena.ne.jp
上で走っている JavaScript からの呼び出ししか認めないという事です。
これはもう本来なら適当なサーバを立てて API をプロキシするとかしない限りどうにもならないのですが、はてなブックマークの API は幸いにして JSONP バージョンも提供されていたので、content.js
から <script>
タグを動的に挿入して JSONP で API のレスポンスを受けることで回避することが出来ます。
エクステンションの JavaScript から生の window オブジェクトにアクセス出来ない
JSONP はある種のハックとして編み出されたテクニックで、JSONP のエンドポイントにコールバック関数名を渡すことで、API のレスポンスがその関数の引数として実行されるようになるものです。https://api.com/?callback=myFunc
を <script>
で読み込むと、myFunc({/* API レスポンス */});
が実行される感じですね。
従ってコールバック関数は必ずグローバル (window オブジェクト) にあらかじめ定義しておかないといけないのですが、JSONP のための <script>
の挿入を行う content.js
上で例えば window.myFunc = () => {}
のように定義しても上手く動きません。
これは、ロードしたページ上で走っているとされる content.js
であっても、セキュリティ上の理由で生の window オブジェクトを操作することは許されておらず、ここで定義した関数は別のスコープにあるように扱われるためです。これに関しては、myFunc = () => {}
を子のテキストノードとして持つ <script>
を挿入することで回避出来ます。
ページ上にテキストとして挿入したスクリプトから sendMessage 出来ない
前述のように API のレスポンスは background.js
側でキャッシュしたいので、何らかの手段で JSONP で得られた結果を background.js
に送る必要があります。通常 content.js
と background.js
間の通信はメッセージのやり取りで行わるので、本来であれば content.js
から chrome.runtime.sendMessage
で送ったメッセージを、background.js
で受けることが出来るはずです。
ところが今回定義しているコールバック関数は、content.js
のスコープ上には無く、あくまで生のページ上に存在しているので、エクステンションのスクリプトのみに実行が許された chrome.runtime.sendMessage
を呼び出すことが出来ません。
これを回避するためには生のページ上のスクリプトから content.js
に何らかの手段で情報を伝える必要があります。そこで採れる方法が、生のスクリプトはもちろん、content.js
上からも操作が許された DOM を使う方法です。つまり、あらかじめ空の HTML 要素を用意しておき、コールバック関数は API のレスポンスを受け取ったらその内容で DOM を通じて HTML 要素を更新する。content.js
はその空の HTML 要素を監視していて、HTML 要素が更新されたらその内容を chrome.runtime.sendMessage
で background.js
に送る、という二段構えの方法です。
ここまで来れば、あとの background.js
上での処理は煮るなり焼くなり好きに出来るでしょう。
解決編
背景で説明した内容を実現するために立ちはだかった問題点を全て解決する方法をまとめると、下記のようなコードになります。順を追って説明します。
const jsonpCallbackName = 'yourExtensionName_jsonpCallback';
const jsonpResultId = 'yourExtensionName_jsonpResult';
必須というわけではないですが、JSONP のコールバック関数で使う名前と、JSONP の結果を格納する HTML 要素に付ける ID を変数に格納しておきます。いずれもグローバル名前空間に置かれることになるので衝突する可能性のない名前にするのがマナーでしょう。
const insertJsonpResultScript = () => {
const jsonpResultScript = document.createElement('script');
jsonpResultScript.setAttribute('type', 'application/json');
jsonpResultScript.setAttribute('id', jsonpResultId);
document.head.appendChild(jsonpResultScript);
};
insertJsonpResultScript();
JSONP の API のレスポンスを生のページ上のスクリプトから content.js
に橋渡しするために、空の <script>
要素を追加しておきます。どんな要素を使っても動作はするはずですが、type
属性で JSON を保持することを指定した <script>
を使用するのがもっとも理にかなっているかと思います。
const insertCallbackJsonpScript = () => {
const jsonpScript = document.createElement('script');
jsonpScript.innerHTML = `
const ${jsonpCallbackName} = (result) => {
const resultElement = document.getElementById('${jsonpResultId}');
resultElement.innerHTML = JSON.stringify(result);
};
`.split(/\s+/).join(' ').replaceAll(/\s*([{}();=])\s*/g, '$1').trim();
document.head.appendChild(jsonpScript);
};
insertCallbackJsonpScript();
JSONP のレスポンスを受け取るコールバック関数を定義し、グローバル名前空間に置いておく部分です。生の window オブジェクト上にコールバック関数を定義するために、innerHTML
にスクリプトを持った <script>
を挿入しているのがポイントです。またそのコールバック関数でやっている事は、前述の結果格納用の HTML 要素にテキストとして API の結果をセットするだけ、というのもまたポイントですね。
ちなみに、split
から trim
までの部分は、ウルトラ雑なミニファイです。この程度の用途なら十分と思って書きました。外部ライブラリを import
する必要もなく、真面目にパースしてミニファイするよりずっと高速に動作しますが、あなたが "このコードが何をしているか 100% 理解でき、そのメリット、デメリット、上手く動かないケースを淀みなく説明できる場合" 以外は使わないで下さい。
const getHatebu = async (url) => {
const encodedUrl = encodeURIComponent(url);
const jsonpUrl = `https://b.hatena.ne.jp/entry/jsonlite/?url=${encodedUrl}&callback=${jsonpCallbackName}`;
const jsonpCallerScript = document.createElement('script');
jsonpCallerScript.setAttribute('src', jsonpUrl);
jsonpCallerScript.setAttribute('async', 'async');
const jsonpResultScript = document.getElementById(jsonpResultId);
const resultHandler = () => {
const result = JSON.parse(jsonpResultScript.innerHTML);
chrome.runtime.sendMessage(chrome.runtime.id, result);
jsonpResultScript.removeEventListener('DOMSubtreeModified', resultHandler);
jsonpResultScript.innerHTML = '';
document.head.removeChild(jsonpCallerScript);
};
jsonpResultScript.addEventListener('DOMSubtreeModified', resultHandler);
document.head.appendChild(jsonpCallerScript);
};
さあ、ここが本体。はてなブックマークの API を呼んで、その結果を background.js
側に送る部分です。まず JSONP のエンドポイントの URL を作りますが、この際のコールバック関数に先ほどグローバル名前空間に定義した関数を指定しておきます。そしてその URL を新たな <script>
要素の src
属性にセットします。
次に、API のレスポンスを一時的に格納する、現時点では空の <script>
の DOMSubtreeModified
イベントを監視します。これは要素の内容に変化があった場合に呼ばれる関数になります。この関数は content.js
のスコープで実行されるため、chrome.runtime.sendMessage
で結果を background.js
に送り込むことが出来ます。またこの <script>
を再利用可能にするために、結果を background.js
に送った後に中身を空っぽにする必要があるのですが、そこで無駄な関数呼び出しが行われたり、あるいは無限ループにハマる可能性を無くすために、その都度イベントリスナーを削除しているのも重要です。最後に JSONP を呼び出した <script>
も削除しておくのがマナーでしょう。
ここまで準備が出来たら、おもむろに JSONP 呼び出し用の <script>
を挿入します。すると、1) jsonpUrl
が呼ばれる 2) JSONP のレスポンスが返ってくる 3) jsonpCallbackName
に結果が渡される 4) jsonpResultId
の内容が更新される 5) resultHandler
が呼ばれる 6) API のレスポンスが sendMessage
で送られる、という順に動作します。
chrome.runtime.onMessage.addListener((message) => {
// 何かする
});
無事に全てのプロセスが想定通りに動作すれば、最終的に background.js
側で望む結果が得られます。
なお、この方法では複数の API コールが並行で走った時に上手く動かないので、冒頭で説明した実際の Feedly はてブでは、もう少し複雑な手順になっています。この記事ではシンプルな状況に絞ってコンセプトの説明を行いました。
あとがき
Chrome エクステンションで JSONP を呼び出す事自体、今どきレアな状況のような気がしますが、はてなの API で何かやりたい人というのはそこそこの人数がいるんじゃないかなと思うので、この記事がいつか誰かの役に立つことを祈っています。