先取り! Google Chrome Extensions

第3回Chrome Extensionsの作り方#2

この記事で取り上げているAPIは現在と使い方が異なっていたり、使用できなくなったものを含んでいます。

特にToolstrips APIは最新のChromeでは使用できなくなっています。詳しくは続・先取り! Google Chrome Extensionsをご覧ください。

前回はExtensionsの作り方からドキュメント、開発ツールについて紹介しました。今回は前回作成したSBMカウンタを改良しながら各APIの使い方を見ていきます。

前回の復習とバグフィックス

前回作成したSBMカウンタは、見ているページのURLのソーシャルブックマークサービスでのブックマーク数を表示するExtensionでした。ToolstripsというAPIを使用しましたが、このToolstripの使い方に2つのバグがあったので、まずはこちらの修正を行います。

一つ目のバグは、ウィンドウを2つ以上開いた場合に発生します。Toolstripはウィンドウに対して設置されるので、ウィンドウを複数開けばToolstripも複数開かれることになります。

このとき、Tabs APIのonSelectionChangedイベントは自分のウィンドウ以外でのイベントも受け取ってしまうので、バックグラウンドウィンドウのToolstripも一緒に更新されてしまいます。これを防ぐため、ウィンドウの一致を確認することにします。

onSelectionChangedのバグ修正(windowIdの一致を確認)
chrome.tabs.onSelectionChanged.addListener(function(tabid){
  chrome.tabs.get(tabid, function(tab){
    if (tab.windowId === current_window.id) {
      run_on_tab(tab);
    }
  });
});

続いて、もうひとつのバグはページを読み込んだ際に発生します。Tabs API の、onUpdatedイベントは読み込みの開始(loading)と終了(complete)の2回発生するので、前回のコードでは2回書き換えを行ってしまっていました。よって、読み込み状態がloadingの場合だけ処理を行うように修正します。

onUpdatedのバグ修正(loadingの場合だけ処理)
chrome.tabs.onUpdated.addListener(function(tabid, inf){
  if (inf.status === 'loading') {
    chrome.tabs.getSelected(current_window.id, function(tab){
      if (tab.id === tabid) {
        run_on_tab(tab, true);
      } else {
        run_on_tab(tab, false, true);
      }
    });
  }
});

なお、この2つの修正は前回の記事が公開された時点でパッケージとソースには反映済みとなっています。

Page Actions と Background Pages

続いて、Page Actions と Background Pagesの使い方を見ていきます。Page Actionsはアドレスバー(OmniBox)の中にボタンを表示するAPIで、まずはこのPage Actionsを使用するためにmanifest.jsonにその定義を書きます。対象とするサービスは前回同様で、はてなブックマークとdeliciousです。

Page Actionsの定義
  "page_actions": [
    {
      "id": "hatena",
      "name": "add hatena bookmark",
      "icons": [
        "hatena.favicon.gif"
      ]
    },
    {
      "id": "delicious",
      "name": "add delicious",
      "icons": [
        "delicious.small.gif"
      ]
    }
  ],

idはそのpage_actionsの中でそれぞれがユニークである必要があります。nameはアイコンにマウスオーバーした時などにツールチップとして表示されます(ただし、これを動的に変更することも可能です⁠⁠。iconsはアドレスバー(OmniBox)の中に表示される画像で、複数定義しておくことができ、どの画像を設置するか制御できます。画像が16×16pxよりも大きい場合は自動的にリサイズされます。

続いて、Page Actionを制御するためのBackground Pagesを定義します。Background Pagesは1つの拡張に対して1つだけ持てる、表には現れないページで、バックグラウンドでの処理を一手に引き受けるAPIです。ToolstripsでもPage Actionを制御することは可能ですが、前述した通り複数ウィンドウの制御を考慮しないといけないため、ToolstripsよりBackground PagesのほうがPage Actionsの制御に適しています。

Background Pagesの定義
  "background_page": "background.html",

では、Background PageにPage Actionsの制御用のJavaScriptを記述します。コードはサービスの定義、アクションの登録、アクション時の動作の2つのパートで構成します。

サービスの定義
var PageActionServices = [
   {
      "id":"hatena",
      "title":"add hatena bookmark",
      "url":"http://b.hatena.ne.jp/add?&url=#{encoded_url}&title=#{title}"
   },
   {
      "id":"delicious",
      "title":"add delicious",
      "url":"http://delicious.com/save?v=5&jump=close&url=#{encoded_url}&title=#{title}"
   }
];

このサービスの定義はToolstripsで使用している定義と共有する形にしてもよいのですが、ここでは個別に定義しています。Page Actionsはアドレスバー(OmniBox)の表示領域を狭くするので、実際に使用するかどうか慎重に決める必要があります。

続いて、Page Actionsを各タブに設置する処理を記述します。

enableForTabによるActionの登録
chrome.tabs.onUpdated.addListener(function(tabid, inf){
  if (inf.status !== 'loading') return;
  chrome.tabs.get(tabid, function(tab){
    if (!/^http/.test(tab.url)) return;
    PageActionServices.forEach(function(service){
      var opt = {
        tabId:  tab.id,
        url:    tab.url,
        title:  service.title,
        iconId: 0
      };
      chrome.pageActions.enableForTab(service.id, opt);
    });
  });
});

ここでもTabs APIのonUpdatedイベントでタブを取得し、PageActions APIのenableForTabを呼び出します。enableForTabの第1引数はmanifest.jsonで定義したid、第2引数はタブID、URL、タイトル、アイコンID(manifest.jsonで指定したもの)をもったオブジェクトになります。タイトルとアイコンIDは省略可能です。

これでアドレスバー(OmniBox)へのアイコンの登録ができました。続いて、アイコンをクリックした際のアクションを定義します。

enableForTabによるActionの登録
PageActionServices.forEach(function(service){
  chrome.pageActions[service.id].addListener(function(id, tabinf){
    if (!tabinf){//for Chrom 3
      tabinf = id.data;
      id = id.pageActionId;
    }
    chrome.tabs.get(tabinf.tabId,function(tab){
      var inf = {
        encoded_url:encodeURIComponent(tab.url),
        title:tab.title
      };
      var opt = {
        url: fill(service.url, inf),
        selected: true
      };
      chrome.tabs.update(tab.id,opt);
    });
  });
});
function fill(str, opt){
  function replacer(_, _$){
    return opt[_$] || '';
  }
  return str.replace(/#\{([^}]+)\}/g, replacer);
}

chrome.pageActionsの各IDに対してaddListenerでイベントを登録しています。Chrome 3では1つの引数にデータが入っていましたが、Chrome 4ではリスナー関数は2つの引数をもつように変更されています。そこで、Chrome 3をChrome 4の実装に合わせるための互換コードを入れています。

あとはタブIDからTabオブジェクトを取得し、そのタイトルとURLでブックマークの追加画面へ遷移させています。Page Actions APIの基本的な使い方は以上となります。

Content Scripts API

続いて、Content Scripts APIの使い方を見ていきます。Content Scriptsは対象ページを表示する際に、そのページのコンテキスト上でJavaScriptを実行するAPIです。前回も書きましたが、Greasemonkeyとよく似た機能を持っています。このContent Scriptsを利用して、ページ内のリンクにブックマーク数を表示する機能を実装してみます(これははてなブックマークFirefox拡張に実装されている機能を参考にさせて頂いています⁠⁠。

まず、manifest.jsonでの記述を見てみます。

Content Scriptsの定義
  "content_scripts": [
    {
      "js": [
        "x.js",
        "bookmark.js"
      ],
      "css": [
        "bookmark.css"
      ],
      "matches": [
        "http://*/*"
      ]
    }
  ],

必須のプロパティはmatchesのみです。どれも文字列の配列で、jsとcssは適用したいファイル名を、matchesはURLとのマッチパターンを記述します。今回は使用していませんが、"run_at": "document_start" という指定を加えるとページの読み込みを開始したタイミングでjs、cssが適用されます。

matches の http://*/* という記述は、httpスキーム(httpsは含みません)のすべてのドメインのすべてのパスにマッチします。パターンの詳細はMatch Patternsを参照してください。特筆すべき注意点としては、スキームパートとドメインパートは省略できない、スキームパートには*が使えない、ドメインパートで*が使えるのは先頭のみなどがあります。

続いて、Content Scriptsの中身となるファイルを書いていきます。Content ScriptsはページのDOMを操作することが多くなるので、jQueryなどのライブラリを利用するのもよい手段でしょう。今回はXPathを使用するため、XPath用のライブラリ(というより、コード・スニペットといったほうがよいかもしれません)を使用します。

XPathはCSSセレクタ以上に柔軟に指定の要素を選択できるので、ページ側のHTMLを編集できないケースでは大変に重宝します。ただし、JavaScriptからXPathを利用するには少々手間がかかるので、cho45氏の$X関数HTML と XHTML で同じ XPath を使う: Days on the Moonを組み合わせた$X関数を使用します。こちらはx.jsとして先に読み込んでいます。

では、Content Scriptsの本体の作成に入りますが、その前にContent Scriptsの仕様を再確認しておきます。Content ScriptsはクロスドメインリクエストやTabs APIの操作、Bookmark APIへのアクセスはできません。Content ScriptはBackground PagesやToolstripsとメッセージをやり取りして間接的にAPIを操作します。これは(悪意ある)ページ側に影響を受けやすいContent Scriptsと高機能なAPIを分離して安全性を高めるための仕様です。

それでは、Background Pageと連携するためにchrome.extension APIでconnectionを作ります。

connectの作成
var connection = chrome.extension.connect();
connection.onMessage.addListener(function(info, con){
  console.log(info, con);
});
connection.postMessage({url:location.href});

chrome.extension.connectを呼び出してconnectionを作成し、そのconnectionのpostMessageメソッドでBackground Pageにメッセージを送ります。このとき、メッセージにはObjectを渡せますが、中身はJSON.stringifyで文字列化されてからさらにJSON.parseされます。つまりJSONとしてシリアライズできないデータ(関数やDOM要素など)は受け渡しできません。

また、onMessageを監視することで、Background Page側から送られてきたメッセージを受け取ることができます。このようにして、少々手間はかかりますが安全にContent Scriptsを活用できます。

Background Pageの応用

Background PageはChromeが起動時に読み込まれ、終了するまで開いたままになります。そのためメモリーリークのリスクもありますが、それ以上に初期化処理のコストを抑えることができるという大きなメリットがあります。

では、Background Page側の処理を書いていきます。まず、ページ内のパラグラフとそのリンクをXPathで記述した情報を取得します。これはGreasemonkey用ScriptのLDRizeの定義データをお借りします。元データはwedataというサービスに存在しますが、今回はそのミラーを使用します。

定義データ(JSON)の取得とパース
var Siteinfo = [
  'http://b.st-hatena.com/file/HatenaBookmarkUsersCount.items.json',
  'http://ss-o.net/json/wedataLDRize.json'
];
var bookinfo = [];
(function getSiteinfo(url){
  var xhr = new XMLHttpRequest;
  xhr.open('GET',url,true);
  xhr.onload = function(){
    try {
      var i = JSON.parse(xhr.responseText);
      i.forEach(function(s){
        bookinfo.push(s.data);
      });
    } catch (e) {
      console.error(e);
    }
    if (Siteinfo.length) {
      getSiteinfo(Siteinfo.shift());
    }
  };
  xhr.onerror = function(){
    if (Siteinfo.length) {
      getSiteinfo(Siteinfo.shift());
    }
  };
  xhr.send(null);
})(Siteinfo.shift());

取得先のURLを配列で定義し、shiftで先頭を取り出して一つずつ処理しています。一度に実行しないのは定義の順番を整えるためですJSDeferredを使えばよりシンプルに記述できます⁠⁠。

続いて、先ほどContent Scriptで送信(postMessage)したデータを受け取る処理を記述します。

Content Scriptからのメッセージの受信
chrome.self.onConnect.addListener(function(port,name) {
  port.onMessage.addListener(function(info,con){
    SeachSiteinfo(info, con);
  });
});

まずchrome.self.onConnectでコネクションが作成されるのを監視し、作成されたポートにメッセージが届くのを監視します。SeachSiteinfo関数では受信したURLと先ほどの定義データマッチングをします。

URLと定義データのマッチング
function SeachSiteinfo(info, con){
  var _info = [], url = info.url;
  for (var i = 0,len = bookinfo.length;i < len;i++){
    var inf = bookinfo[i], matcher = null;
    if (inf.domain) {
      try {
        matcher = new RegExp(inf.domain);
      } catch(e){}
    }
    if (matcher && matcher.test(url)){
      if (inf.disable){
        return;
      }
      if (inf.paragraph && inf.link) {
        _info.push(inf);
      }
    } else if (matcher === null) {
    }
  }
  if (_info.length) {
    con.postMessage({"matched":_info});
  }
}

定義データのdomainを正規表現オブジェクトに変換してURLとの一致をチェックしています。URLとマッチした時にdisableがtrueである場合は処理を終了します。そうなければ、マッチした定義データの配列をpostMessageでContent Scripts側に返します。

定義データを受け取ったContent Script側では、$X関数でパラグラフを取得し、そのパラグラフを基点にリンクを抽出します。

定義データによるリンクの抽出
var siteinfo = [];
var CountItem = {};
connection.onMessage.addListener(function(info){
  siteinfo = info.matched;
  SeachInlineLinks(document);
});
function SeachInlineLinks(doc){
  var links = [];
  siteinfo.forEach(function(info){
    $X(info.paragraph, doc).forEach(function(paragraph){
      var link = $X(info.link, paragraph)[0];
      if (!link) return;
      if (!CountItem[link.href]) {
        links.push(link.href);
        CountItem[link.href] = [link];
      } else {
        CountItem[link.href].push(link);
      }
    });
  });
  connection.postMessage({links:links});
}

CountItemは同じURLが複数あった場合に1つにまとめる役割を担っています。そうして抽出されたURLを再度Background Pageに渡します。

Content Scriptからのメッセージの受信#2
chrome.self.onConnect.addListener(function(port,name) {
  port.onMessage.addListener(function(info,con){
    if (info.url) {
      SeachSiteinfo(info, con);
    } else if (info.links) {
      GetLinkCount(info, con);
    }
  });
});
function GetLinkCount(info, con) {
  Services.forEach(function(service){
    service.link_count_request(info, con, service);
  });
}

先ほどは受信したメッセージを無条件でSeachSiteinfo関数に渡していましたが、今度は受信したデータに応じて処理を振り分けています。複数のURLからブックマーク数を取得する処理はサービスごとにことなるので、前回のServices定義に処理を書くことにします。

複数のURLに対してブックマーク数を取得するには、はてなブックマークの場合ははてなブックマーク件数取得APIが、deliciousではFeeds APIのSummary information about a URLが使用できます。なお、deliciousのAPIを使うためにURLのmd5ハッシュを計算する必要があるため、JavaScriptでハッシュアルゴリズムのmd5.jsを使用します。md5.jsには、光成氏による、より最適化された実装もありますが、Chromeでは大きな差にならないとのことなので、今回はシンプルな実装を採用しています。

では、Background Page側でURLに対応するブックマーク数を取得して、再びContent Script側にデータを渡します。なお、はてなブックマークのXMLRPC APIを利用する部分については解説を省略させていただきます。ご了承ください。

Content Scriptへメッセージの送信#2 はてな API
var parser = new DOMParser(), text = xhr.responseText;
var doc = parser.parseFromString(text, 'text/xml');
var res = [];
$X('//member',doc).forEach(function(member,i){
  var vals = $X('*',member);
  res.push({
    url: vals[0].textContent,
    total_posts: vals[1].textContent
  });
});
con.postMessage({
  "count":{
    "id": "hatena",
    "items": res,
    "api_link": service.api_link
  }
});

はてなブックマーク件数取得APIで取得したXMLをパースし、XPathでデータを取り出してContent Script側に渡しています。

Content Scriptへメッセージの送信#2 delicious API
var api = 'http://feeds.delicious.com/v2/json/urlinfo';
var param = 'hash=' + info.links.map(function(url){
  return encodeURIComponent(md5.hex(url));
}).join('&hash=');
var xhr = new XMLHttpRequest();
xhr.open('post',api + '?' + param, true);
xhr.onload = function(){
  var res = JSON.parse(xhr.responseText);
  con.postMessage({
    "count":{
      "id": "delicious",
      "items": res,
      "api_link": service.api_link
    }
  });
};
xhr.send(null);

deliciousのAPIはURLをmd5に変換する必要があるものの、フォーマットをJSONで受け取れるため扱いやすくなっています。

Background Pageからのメッセージの受信と件数の表示
connection.onMessage.addListener(function(info){
  if (info.matched) {
    siteinfo = info.matched;
    SeachInlineLinks(document);
  } else if (info.count) {
    Counter(info.count);
  }
});
function Counter(data){
  var id = data.id, api_link = data.api_link;
  var class_name = 'sso_'+id+'_bookmark_counter_element';
  data.items.forEach(function(item){
    if (item.total_posts < 1) return;
    var links = CountItem[item.url];
    links.forEach(function(link){
      if (link.nextSibling &&
        class_name === link.nextSibling.className){
        return;
      }
      var parent = link.parentNode;
      var a = document.createElement('a');
      a.href = api_link.replace(/#\{([^}]+)\}/g,
          function(_, _$){return item[_$] || '';});
      a.className = class_name;
      a.textContent = item.total_posts + ' user' +
          (item.total_posts === '1' ? '' : 's');
      parent.insertBefore(a, link.nextSibling);
      CountedItems.push(link);
    });
  });
}

メッセージを受け取るContent Script側は、やはりメッセージの内容によって処理を分岐するようにします。ブックマーク数のデータを受け取ったら、URLに対応するリンク要素に対して、ブックマーク数をそのリンクの後ろに追加します。

Node.parentNode.insertBefore(NewNode, Node.nextSibling)

これは特定のノードの後ろに新しいノードを追加するときに使用するイディオムとして覚えておいて損はないでしょう(なお、Node.nextSiblingはnullである可能性がありますが、insertBeforeは第2引数がnullである場合は、新しい要素を末尾に追加するので問題ありません⁠⁠。

最後になりましたが、最初にmanifest.jsonで定義したbookmark.cssで、追加したブックマーク数リンクのスタイルを定義します。ページ側のスタイルとぶつからないように長めのクラス名を付けてありますので、それを元にCSSを書きます。

Content ScriptのCSS(bookmark.css)
a.sso_hatena_bookmark_counter_element,
a.sso_delicious_bookmark_counter_element{
  font-weight:bold !important;
  padding:0 2px !important;
  display:inline !important;
  font-size:10px !important;
}
a.sso_hatena_bookmark_counter_element{
  background:#ffffff !important;
}
a.sso_delicious_bookmark_counter_element{
  background:#0066cc !important;
}
a.sso_hatena_bookmark_counter_element:link,
a.sso_hatena_bookmark_counter_element:visited{
  color:#ff8888 !important;
  text-decoration:none !important;
}
a.sso_delicious_bookmark_counter_element:link,
a.sso_delicious_bookmark_counter_element:visited{
  color:white !important;
  text-decoration:none !important;
}
a.sso_hatena_bookmark_counter_element:hover{
  color:#ff6666 !important;
  text-decoration:none !important;
}
a.sso_delicious_bookmark_counter_element:hover{
  color:white !important;
  background:#0099cc !important;
  text-decoration:none !important;
}

ページ側のスタイルより優先されるように!importantを付けています(余談ですが、Content Scriptsのcssはhtml要素の直下に挿入されます。HTMLの仕様ではhtml要素の子になれるのはhead要素かbody要素と決まっているので、少々行儀の悪い挙動です……⁠⁠。

以上をパッケージしたファイルは下記にあります。前回とは別ファイルになっています。

SBM Counter2 for Chrome 4

まとめ

今回は前回に続いてExtension APIの使い方をContent ScriptとBackground Pageの連携部分を中心に実習しました。次回はChrome 4での新機能やExtensionsの今後について、最新事情を追っていきたいと思います。

おすすめ記事

記事・ニュース一覧