先取り! Google Chrome Extensions

第3回 Chrome Extensionsの作り方#2

この記事を読むのに必要な時間:およそ 8.5 分

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定義に処理を書くことにします。

著者プロフィール

太田昌吾(おおたしょうご,ハンドルネーム:os0x)

1983年生まれ。JavaScriptをメインに,HTML/CSSにFlashなどのクライアントサイドを得意とするウェブエンジニア。2009年12月より、Google Chrome ExtensionsのAPI Expertとして活動を開始。

URLhttp://d.hatena.ne.jp/os0x/