以前の記事でやりたいこととして挙げていた、

1.やっぱりContent ScriptとMessageを使いこなしたい。

を、ようやく実装できました。

Amazon to Rakuten

Chrome拡張「Amazon to Rakuten」は当初下記の流れで作っていました。

1. chrome.tabs.getSelectedで今開いてるamazonのページのURLを取得
2. XMLHttpRequestで取得したURLのHTMLを取ってきて、本のタイトルを正規表現で取得
3. 楽天ブックス総合検索APIに取得したタイトルを投げる
4. 検索結果をPopupに表示。表示されたやつをクリックすると無事楽天ブックスの商品詳細ページにジャンプ

が、この1.と2.の部分が無駄なのとbackgroundPageを使ってみたかったのとで、次のような流れにしました。

1. Content ScriptでDOM解析し、今開いているamazonの詳細ページから商品タイトルを取得
2. 取得したタイトルをMessageでbackground.htmlに送信
3. background.html上で楽天ブックス総合検索APIに、Content Scriptから受信した商品タイトルを投げて返ってきた結果を処理し、popup.htmlに表示したい内容を変数に格納
4. popup.htmlには3.で格納したbackground.html内の変数の中身を表示するスクリプトを書いておく

こうすることで、amazonの商品ページを表示した瞬間に裏側でDOM解析&API検索を行い、アイコンをクリックしたらその処理結果を表示するだけになるので、アイコンクリック後の待ち時間がほぼなくなります。今回も色んなところで詰まりまくった。

SPONSERD LINK

1. Content Scriptで商品タイトルを引っ張ってくる

 1 /* manifest.json */
 2 {
 3   ・・・
 4   "background_page": "background.html",
 5   "content_scripts": [
 6     {
 7       "matches": [
 8         "http://www.amazon.co.jp/*",
 9         "http://www.amazon.com/*"
10       ],
11       "js": ["script.js"],
12       "run_at": "document_end"
13     }
14   ],
15   "browser_action": {
16     "default_icon": "icon.png",
17     "popup": "popup.html"
18   },
19   ・・・
20 }

まずはここから。Content Scriptを使うには、manifest.jsonに上記のように記述する必要があります。
"matches"はContent Scriptを動かすサイトの条件を設定する項目。
"js"は動かしたいJavaScriptファイルのファイル名。
"run_at"はどのタイミングでスクリプトを実行するかを決める項目。今回はDOM構築完了時にスクリプトを動かす"document_end"にしてます。
他にも何個かオプションがあります。詳細はこちら

1 /* script.js */
2 title = document.getElementById('btAsinTitle').firstChild.nodeValue;
3 title.match(/([^\(]*)/);
4 title = RegExp.$1;

こうすると、amazonの詳細ページでDOM構築が終わった瞬間script.jsが実行されて、titleの中に商品タイトルが入るって寸法です。
これをそのままAPIに投げたいところですが、Content ScriptではXMLHttpRequestが使えないそうなので、background.htmlに商品タイトルを送ってやる必要があります。

2. Content Scriptからbackground.htmlにメッセージを送信

 1 /* script.js */
 2 title = document.getElementById('btAsinTitle').firstChild.nodeValue;
 3 title.match(/([^\(]*)/);
 4 title = RegExp.$1;
 5 
 6 var port = chrome.extension.connect({name: "AtoR"});
 7 port.postMessage({title: title,status:"start"});
 8 port.onMessage.addListener(function(msg) {
 9   if (msg.status == "loading"){
10     port.postMessage({status: "loading"});
11   }
12 });
 1 /* background.html */
 2 chrome.extension.onConnect.addListener(function(port) {
 3   console.assert(port.name == "AtoR");
 4   port.onMessage.addListener(function(msg) {
 5     if(msg.status == "start"){
 6       //楽天APIから商品を検索
 7       query = "http://api.rakuten.co.jp/rws/3.0/json?" +
 8           "developerId=" + devId +
 9           "&affiliateId=" + afiId +
10           "&operation=" + opr +
11           "&version=" + ver+
12           "&amp;keyword=" + encodeURI(msg.title);</p>
13       api.open("GET",query,true);
14       api.onreadystatechange = sourceGet(port);
15       api.send(null);
16     }
17     else{
18       sourceGet(port);
19     }
20   });
21 });
22 
23 function sourceGet(port){
24   console.log("sourceGet");
25   if (api.readyState == 4 && api.status == 200){
26     //APIからのレスポンスがきちんと返ってきた時の処理
27     ・・・
28   }
29   else{
30     if (loading === false){
31       loading = true;
32       content = '<img src="loading.gif" />';
33     }
34     port.postMessage({status: "loading"});
35   }
36 }

例によって詳細はChromeAPIのメッセージのページを見てください。
今回はXMLHttpRequestを使うせいか単発通信ではうまくいかなかったので、永続通信で下記の流れでやり取りさせてます。

まずContentScriptでタイトルとstatus:startを送信
        ↓
background.htmlがstartを受け取ったら楽天APIにタイトルを投げてsourceGetを実行。通信中の場合はstatus:loadingをContentScriptに返す
        ↓
ContentScriptにstatus:loadingが返ってきたら再びstatus:loadingを投げ返し、background.htmlにお伺いを立てる
        ↓
background.htmlで再びsourceGetを実行。APIからの返事がまだならもっかいstatus:loadingを投げ返す、返事が来てたら処理終了

ってな具合です。

3. popup.htmlに表示したい内容を変数に格納

 1 /* background.html */
 2 function sourceGet(port){
 3   console.log("sourceGet");
 4   if (api.readyState == 4 && api.status == 200){
 5     response = eval('[' + api.responseText + ']')[0];</p>
 6 
 7     if(response['Header']['Status'] == 'Success'){
 8       items = response['Body']['BooksTotalSearch']['Items']['Item'];</p>
 9 
10       //商品データを1つずつhtmlに出力
11       content = '<table width="300">';
12       for(i=0;i<items.length;i++){
13         content += '<tr><td><a href="'+items[i]['affiliateUrl']+'"><img src="'+items[i]['mediumImageUrl']+'" /></a></td>' +
14           '<td style="width:200px;vertical-align:top;">タイトル:' +
15           '<a href="'+items[i]['affiliateUrl']+'" target="_blank">' + items[i]['title'] + '</a><br />' +
16           '著者:' + items[i]['author'] + '<br />' +
17           '価格(税込):' + setComma(items[i]['itemPrice']) + '円<br />' +
18           'ポイント:'+Math.floor(items[i]['itemPrice']/1.05/100)+'ポイント</td></tr>';
19       }
20       content = content+'</table>';
21     }
22     else if(response['Header']['Status'] == 'NotFound'){
23       content = '見つかりませんでした。';
24     }
25     else{
26       content = 'エラーが発生しました。';
27     }
28   }
29   else if (api.readyState == 4){
30     content = "接続に失敗しました。";
31   }
32   else{
33     if (loading === false){
34       //ロード中画像を表示
35       loading = true;
36       content = '<img src="loading.gif" />';
37     }
38     port.postMessage({status: "loading"});
39   }
40 }

実際のsourceGetはこんな感じです。汚い。
ポップアップに表示したいものは全部変数contentに格納して、後から引っ張ってきます。

4. background.htmlのcontentに格納した内容をpopup.htmlに表示

 1 /* popup.html */
 2 <html>
 3   <head>
 4     <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
 5   </head>
 6   <body>
 7     <div id="content"></div>
 8     <script>
 9     bg = chrome.extension.getBackgroundPage();
10     document.getElementById('content').innerHTML = bg.content;
11     </script>
12   </body>
13 </html>

なんと、たったの2行で、backgound.html上の変数をpopup.htmlでも使うことができてしまいます。
逆だとちょっとめんどいです。詳細はこちら
ちなみに、script.jsは外から呼べなかったです。なぜ。

思わぬ落とし穴

これでようやく思ったとおりの挙動になって万々歳!と思った矢先、思わぬ落とし穴が。
このbackground.htmlはタブを何個開いてても共通で使われるので、例えば次のような場合。

amazonで商品1のページを見ている状態で、商品2のページを別タブで開く
        ↓
商品2が読み込まれた瞬間background.htmlに商品2のデータが格納される
        ↓
商品1のタブに戻って拡張のアイコンをクリック
        ↓
background.htmlには商品2のデータが格納されたままなので、そっちが表示されてしまう

というわけで、タブを切り替える度にタイトルを取得してAPIを叩き直さんとあかんことに気づく。
ですが予想通りタブ切り替えイベントは用意されていたので、ささっと対応しました。

 1 /* background.html */
 2 ・・・
 3 
 4 //タブが変更された時の処理
 5 chrome.tabs.onSelectionChanged.addListener(function(tabid){
 6   chrome.tabs.getSelected(null, function(tab) {
 7     chrome.tabs.sendRequest(tab.id, {status: "changed"}, function(response) {});
 8   });
 9 });
10 
11 ・・・
 1 /* script.js */
 2 AtoR();
 3 
 4 chrome.extension.onRequest.addListener(
 5   function(request, sender, sendResponse) {
 6     AtoR();
 7   }
 8 );
 9 
10 function AtoR(){
11   title = document.getElementById('btAsinTitle').firstChild.nodeValue;
12   title.match(/([^\(]*)/);
13   title = RegExp.$1;
14 
15   var port = chrome.extension.connect({name: "AtoR"});
16   port.postMessage({title: title,status:"start"});
17   port.onMessage.addListener(function(msg) {
18     if (msg.status == "loading"){
19       port.postMessage({status: "loading"});
20     }
21   });
22 }

chrome.tabs.onSelectionChangedがscript.js側で使えればよかったんですが、ContentScript上では使えませんって怒られたのでbackground.htmlに記述して例によってメッセージで送信。
chrome.extension.onRequest.addListenerの中がメッセージ受信時、つまりタブが切り替わった時に実行されるアクションです。

そんなこんなで

あれやこれやと試行錯誤を繰り返し、なんとかやりたいことを1つ実装できました。今後もちょくちょくアップデートしていく予定です。
Chrome拡張はインストールしたらソースコード全部見られるので、興味ある方はぜひ。

Amazon to Rakuten