digital 千里眼 @abp_jp

アナログな日常とデジタルの接点

AutoPagerize のコードを読む 1

  • 自分用備忘録:そのうちエラー処理の getCacheErrorCallback について書き加えるつもりです 2009-05-22
  • AutoPagerize バージョンアップに従い、内容をアップデート 2009-05-09
    • version 0.0.37 に対応
目標
  • 処理の詳細を理解し、どこで何をやっているのかを理解する
資料
全体の流れ
今回説明する範囲
  • 正常系を追います。エラー系は...自分で追ってってください
  1. 定数定義(設定値など)
  2. 初期化(id:os0x さんが 動作の流れ で書いている『初期化』はページ継ぎ足し機能分類上の初期段階を指しています。私の言っている『初期化』はスクリプトの開始直後の変数の初期化等のプログラミング上の初期処理を指します。混同しないよう注意)
  3. launchAutoPager 関数を呼ぶところまで
解説の流れ
  1. 解説する対象コードを提示
  2. 解説
  3. 必要があればサンプルデータを提示

では、さっそく読み始めます...

定数定義

var URL = 'http://userscripts.org/scripts/show/8551'
var VERSION = '0.0.37'
var DEBUG = false
var AUTO_START = true
var CACHE_EXPIRE = 24 * 60 * 60 * 1000
var BASE_REMAIN_HEIGHT = 400
var FORCE_TARGET_WINDOW = true
var USE_COUNTER = true
var XHR_TIMEOUT = 30 * 1000
var SITEINFO_IMPORT_URLS = [
    'http://wedata.net/databases/AutoPagerize/items.json',
]
定数名 説明
DEBUG true に設定すると console.log 同様にコンソールに debug 出力することができるようになる(debug 関数はファイル後方の utility functions で定義されている)
AUTO_START false にすると AutoPagerize を無効にできる
CACHE_EXPIRE SITEINFO_IMPORT_URLS からダウンロードして読み込んだ SITEINFO (サイト毎の継ぎ足し設定をまとめた JSON データ)の有効期限をミリ秒単位で指定する。デフォルトは24時間。失効(expire)した場合、次回 AutoPagerize が実行された際に新たにダウンロードされる
BASE_REMAIN_HEIGHT ページ継ぎ足し開始位置をここで指定した数値(単位はピクセル)分だけ上にずらす。数値を大きくすると、その分だけ早めにページ継ぎ足しするようになる。フッターが異常に長いページで継ぎ足し開始が遅れる場合、この数値を上げて修正できる
FORCE_TARGET_WINDOW 継ぎ足されたページのリンクを新しいウィンドウで開くようにする。そうしない(false)と「戻る」ボタンで悲惨なことに...
USE_COUNTER どのくらい AutoPagerize したか、その回数の統計情報を記録するか否かの設定。false にすると、Greasemonkey のメニュー -> [ユーザースクリプトコマンド]が表示されなくなる
XHR_TIMEOUT SITEINFO_IMPORT_URLS で指定された SITEINFO(サイト毎の継ぎ足し設定をまとめた JSON データ) をダウンロードする際のタイムアウト時間をミリ秒単位で指定。SITEINFO のダウンロードはバックグラウンドで行われるため、ダイアログ等は出ない
SITEINFO_IMPORT_URLS SITEINFO(サイト毎の継ぎ足し設定をまとめた JSON データ)を保存しているURLを配列で指定。複数指定可能で wedata.net が落ちているときはここを書き換えたり書き加えたりする
var COLOR = {
    on: '#0f0',
    off: '#ccc',
    loading: '#0ff',
    terminated: '#00f',
    error: '#f0f'
}
定数名 説明
COLOR 画面右上に表示されるのステータスを示す色を定義
var SITEINFO = [
    /* sample
    {
        url:          'http://(.*).google.+/(search).+',
        nextLink:     'id("navbar")//td[last()]/a',
        pageElement:  '//div[@id="res"]/div',
        exampleUrl:   'http://www.google.com/search?q=nsIObserver',
    },
    */
    /* template
    {
        url:          '',
        nextLink:     '',
        pageElement:  '',
        exampleUrl:   '',
    },
     */
]
定数名 説明
SITEINFO ページ継ぎ足しの SITEINFO 設定を手動で指定する場所。個人用や wedata.net 登録前のテスト目的で使うことができる。コメントアウト部分がお手本
var MICROFORMAT = {
    url:          '.*',
    nextLink:     '//a[@rel="next"] | //link[@rel="next"]',
    insertBefore: '//*[contains(@class, "autopagerize_insert_before")]',
    pageElement:  '//*[contains(@class, "autopagerize_page_element")]',
}
定数名 説明
MICROFORMAT データフォーマットの違い以外は SITEINFO と同じ。こちらは一般ユーザーが触る必要はない。設定されている内容は AutoPagerize がウェブサイト制作者に希望する推奨値。この設定通りウェブページを作れば最初から AutoPagerize 対応できる

ソース上は定数定義の後、しばらく関数の定義とかが続きます

初期化等

AutoPager.documentFilters = []
AutoPager.requestFilters = []
AutoPager.responseFilters = []
AutoPager.filters = []
  • 配列として初期化
function Counter() {}
Counter.DATA_KEY = 'counter_data'
  • カウンター関係の定数の定義
if (USE_COUNTER) {
    GM_registerMenuCommand('AutoPagerize - count chart', Counter.view)
    AutoPager.documentFilters.push(function() {
        Counter.up()
    })
}
  • Greasemonkey の「ユーザースクリプトコマンド」にカウンター表示コマンド 'AutoPagerize - count chart' を登録し、コールバック関数 Counter.view が呼ばれるようにしている
  • 先に初期化した配列 AutoPager.documentFilters に Counter.up() を追加
AutoPager.documentFilters.push(linkFilter)
  • 直前で定義した linkFilter 関数を配列 AutoPager.documentFilters に 追加
if (typeof(window.AutoPagerize) == 'undefined') {
    window.AutoPagerize = {}
    window.AutoPagerize.addFilter = function(f) {
        AutoPager.filters.push(f)
    }
    window.AutoPagerize.addDocumentFilter = function(f) {
        AutoPager.documentFilters.push(f)
    }
    window.AutoPagerize.addResponseFilter = function(f) {
        AutoPager.responseFilters.push(f)
    }
    window.AutoPagerize.addRequestFilter = function(f) {
        AutoPager.requestFilters.push(f)
    }

    var ev = document.createEvent('Events')
    ev.initEvent('GM_AutoPagerizeLoaded', false, true)
    window.dispatchEvent(ev)
}
  • グローバルオブジェクト window に AutoPagerize プロパティが未定義なら
    1. 連想配列として初期化
    2. addFilter 関数を AutoPager.filters の push 関数呼び出しと定義(関数名のエイリアスみたいな感じ)
    3. addDocumentFilter 関数を AutoPager.documentFilters の push 関数呼び出しと定義(関数名のエイリアスみたいな感じ)
    4. addResponseFilter 関数を AutoPager.responseFilters の push 関数呼び出しと定義(関数名のエイリアスみたいな感じ)
    5. addRequestFilter 関数を AutoPager.requestFilters の push 関数呼び出しと定義(関数名のエイリアスみたいな感じ)
GM_registerMenuCommand('AutoPagerize - clear cache', clearCache)
  • Greasemonkey の「ユーザースクリプトコマンド」にローカルにキャッシュされた SITEINFO を wedata.net から強制的に再取得させるコマンド 'AutoPagerize - clear cache' を登録し、コールバック関数 clearCache が呼ばれるようにしている
var clearCache = function() {
    GM_setValue('cacheInfo', '')
}
  • clearCache 関数の定義は約100行前にあります。単純なのでここで見ておきましょう
  • GM_setValue 関数は GM_ が付いてるので Greasemonkey 組み込み関数でキー・データ型のデータストアを提供する。ブラウザに文字列・ブール値・整数のいずれかを保存でき、ブラウザ・OSの終了・起動で揮発しない。第一引数をキーに、第二引数をデータ、第三引数は任意でデフォルト値。GM_getValue とペアで使われる
  • Greasemonkey 内に保存された値はブラウザのURLに about:config として、フィルタを greasemonkey.scriptvals と指定することで確認できる。Autopagerize のみに絞る場合はフィルタに作者を示す名前空間名を加えて greasemonkey.scriptvals.http://swdyh.yu.to/ と指定すればいい
  • さて、話は戻ってここでやっていることは cacheInfo をキーに保存したデータの削除。それだけ
var ap = null
  • 後に AutoPager オブジェクトを代入するため、変数 ap を初期化

冒頭で定数定義した SITEINFO を引数に launchAutoPager 呼び出し(正常系シナリオ1

launchAutoPager(SITEINFO)
  • 定数定義で登録した SITEINFO 変数(デフォルトで内容はコメントアウト)を引数に launchAutoPager 関数を呼ぶ(以後、"正常系シナリオ1"と呼ぶ)
  • 自分で直接スクリプト内に SITEINFO を書き加えた場合はこのシナリオが使われる
  • 正常系シナリオ1は次回に続く

オンラインで wedata.net から、もしくはそのローカルキャッシュから SITEINFO 取得し launchAutoPager 呼び出し(正常系シナリオ2

var cacheInfo = getCache()
  • 約100行程前方に、この getCache 関数の定義がある
var getCache = function() {
    return eval(GM_getValue('cacheInfo')) || {}
}
  • GM_getValue は GM_ がつくことからも分かる通り Greasemonkey 組み込み関数で、GM_setValue された値を取得するために使われる。引数にはキーを指定する。キーが存在しなければ undefined を返す
  • cacheInfo の内容は wedata.net からダウンロードしたページ継ぎ足し設定(SITEINFO のかたまり)をローカルブラウザに保存したキャッシュデータ
  • そのキャッシュを eval した連想配列、なければ空の連想配列を返す
  • cacheInfo のイメージ
{
  "http://wedata.net/databases/AutoPagerize/items.json": {
    "url": "http://wedata.net/databases/AutoPagerize/items.json",
    "expire": {
      "Fri Dec 26 2008 20:44:13 GMT+0900"
    },
    "info": [
      {
        "pageElement": "id(\"center\")/div[@class=\"entry\"]",
        "url": "^http://blog\\.stco\\.info/",
        "nextLink": "id(\"center\")/div[@class=\"page\"]/a",
        "exampleUrl": "http://blog.stco.info/"
      },
      /* 途中省略 */
      {
        "insertBefore": "",
        "pageElement": "id(\"pixflow\")",
        "url": "^http://whytheluckystiff\\.net/quiet/",
        "nextLink": "id(\"header\")/a[last()]",
        "exampleUrl": "http://whytheluckystiff.net/quiet/"
      }
    ]
  }
}
var xhrStates = {}
ローカルキャッシュを調べ、失効していれば再取得
SITEINFO_IMPORT_URLS.forEach(function(i) {
    if (!cacheInfo[i] || cacheInfo[i].expire < new Date()) {
        var opt = {
            method: 'get',
            url: i,
            onload: function(res) {
                xhrStates[i] = 'loaded'
                getCacheCallback(res, i)
            },
            onerror: function(res){
                xhrStates[i] = 'error'
                getCacheErrorCallback(i)
            },
        }
        xhrStates[i] = 'start'
        GM_xmlhttpRequest(opt)
        setTimeout(function() {
            if (xhrStates[i] == 'start') {
                getCacheErrorCallback(i)
            }
        }, XHR_TIMEOUT)
    }
    else {
        launchAutoPager(cacheInfo[i].info)
    }
})
  • SITEINFO_IMPORT_URLS は最初に定数定義した配列なので、デフォルトだと i は具体的には "http://wedata.net/databases/AutoPagerize/items.json"
  • 連想配列 cacheInfo のキーが SITEINFO_IMPORT_URLS のいずれかにマッチしなかったり、マッチしていても cacheInfo[i].expire より今の時間(new Date())が新し(<)ければ次行以降を継続。それ以外なら launchAutoPager を引数 cacheInfo[i].info で起動(以後、"正常系シナリオ2"と呼ぶ)
    • GM_xmlhttpRequest するための引数 opt を連想配列として準備。optはリクエストの種類を method: で指定し、url: でリクエスト先URL を指定、onload: で読み込み完了時に設定される xhrStates[i] = 'loaded' とコールバック関数 getCacheCallback(res, i) を指定し、onerror: でエラー時に設定される xhrStates[i] = 'error' とコールバック関数 getCacheErrorCallback(i) を指定している
    • xhrStates[i] を 'start' にして opt を引数にリクエスト(GM_xmlhttpRequest)する。ちなみに GM_xmlhttpRequest は非同期通信。AJAX で似たような API 使いますよね
    • setTimeout はリクエストのタイムアウト処理。タイムアウトしたら xhrStates[i] == 'start' に設定し、コールバック関数 getCacheErrorCallback(i) を呼ぶように指定
  • それ以外(else)なら、cacheInfo[i].info つまりローカルキャッシュされた SITEINFO の集合を引数に launchAutoPager を呼ぶ
getCacheCallback 関数でダウンロードした SITEINFO の json データをソートして Greasemonkey のローカルキャッシュに格納する
var getCacheCallback = function(res, url) {
    if (res.status != 200) {
        return getCacheErrorCallback(url)
    }

    var info
    try {
        info = eval(res.responseText).map(function(i) { return i.data })
    }
    catch(e) {
        info = []
        var matched = false
        var hdoc = createHTMLDocumentByString(res.responseText)
        var textareas = getElementsByXPath('//*[@class="autopagerize_data"]', hdoc)
        textareas.forEach(function(textarea) {
            var d = parseInfo(textarea.value)
            if (d) {
                info.push(d)
                if (!matched && location.href.match(d.url)) {
                    matched = d
                }
            }
        })
    }
    if (info.length > 0) {
        info = info.filter(function(i) { return ('url' in i) })
        info.sort(function(a, b) { return (b.url.length - a.url.length) })
        cacheInfo[url] = {
            url: url,
            expire: new Date(new Date().getTime() + CACHE_EXPIRE),
            info: info
        }
        GM_setValue('cacheInfo', cacheInfo.toSource())
        launchAutoPager(info)
    }
    else {
        getCacheErrorCallback(url)
    }
}
  • GM_xmlhttpRequest(opt) のリクエストが帰ってきたとして、getCacheCallback 関数の続きを追うとしよう。定義は約100行前にある
  • リクエストが成功(200)したことを res.status で確認たなら次行に続く。エラーなら getCacheErrorCallback(url) を戻り値にして return する
  • info ローカル変数初期化
  • res.responseText (文字列のレスポンス本体:下のイメージ参照)を eval(引数の文字列を JavaScript として処理:文字列 ⇒ 配列化)し、その配列でキーが data の値を取り出した新たな info 配列を作成する。map 関数については ここを参照
  • catch の例外処理は宿題
  • info 配列に中身があれば(info.length > 0)、キーに 'url' がある要素だけ抽出(filter )し info 配列に再度代入し次行に継続。info に中身がなければ getCacheErrorCallback 関数を呼んで戻る(return)
    • info 配列の url キーの値の長さ基準に url が長い要素が先に来るよう sort する
    • r_keys で SITEINFO の核となるキー配列を定義し、そのキーを使って配列を再作成し info 配列に再代入
    • 渡された引数の url、失効時間(expire) を現在時間に冒頭で定義した CACHE_EXPIRE 定数を足して計算し、info を加えた cacheInfo[url] 連想配列を作成
    • cacheInfo を文字列化(toSource)して Greasemonkey に保存(GM_setValue)
    • info 配列を引数に launchAutoPager 関数を呼ぶ(以後、これを"正常系シナリオ2"と呼ぶ:同名のシナリオが2つあるように見えるが引数の中身は同じ)
  • 正常系シナリオ2は次回に続く
  • res.responseText のイメージ
[
  {
    "name": "うごメモはてな開発秘話 - プロデューサー編",
    "updated_at": "2008-12-20T17:28:18+09:00",
    "database_resource_url": "http://wedata.net/databases/AutoPagerize",
    "created_by": "Yuichirou",
    "resource_url": "http://wedata.net/items/26584",
    "created_at": "2008-12-20T17:28:18+09:00",
    "data": {
      "pageElement": "id(\"memo-navigation-top\")//div[@class=\"box-body\"]/ul[@class=\"chapter\"]/following-sibling::*",
      "insertBefore": "",
      "url": "^http://ugomemo\\.hatena\\.ne\\.jp/opening_producerinterview",
      "nextLink": "id(\"memo-navigation-top\")//div[@class=\"channel-navigation\"]//img[@alt=\"NEXT\"]/parent::a",
      "exampleUrl": "http://ugomemo.hatena.ne.jp/opening_producerinterview01_1"
    }
  },
  /* 途中省略 */
  {
    "name": "(.~) what a quiet stiff (~.)",
    "updated_at": "2008-04-25T07:34:25+09:00",
    "database_resource_url": "http://wedata.net/databases/AutoPagerize",
    "created_by": "swdyh",
    "resource_url": "http://wedata.net/items/399",
    "created_at": "2008-04-16T12:35:17+09:00",
    "data": {
      "pageElement": "id(\"pixflow\")",
      "insertBefore": "",
      "url": "^http://whytheluckystiff\\.net/quiet/",
      "nextLink": "id(\"header\")/a[last()]",
      "exampleUrl": "http://whytheluckystiff.net/quiet/"
    }
  }
]
  • info のイメージ
[
  {
    "pageElement": "id(\"memo-navigation-top\")//div[@class=\"box-body\"]/ul[@class=\"chapter\"]/following-sibling::*",
    "insertBefore": "",
    "url": "^http://ugomemo\\.hatena\\.ne\\.jp/opening_producerinterview",
    "nextLink": "id(\"memo-navigation-top\")//div[@class=\"channel-navigation\"]//img[@alt=\"NEXT\"]/parent::a",
    "exampleUrl": "http://ugomemo.hatena.ne.jp/opening_producerinterview01_1"
  },
  /* 途中省略 */
  {
    "pageElement": "id(\"pixflow\")",
    "insertBefore": "",
    "url": "^http://whytheluckystiff\\.net/quiet/",
    "nextLink": "id(\"header\")/a[last()]",
    "exampleUrl": "http://whytheluckystiff.net/quiet/"
  }
]

冒頭で定数定義した MICROFORMAT を引数に launchAutoPager 呼び出し(正常系シナリオ3

launchAutoPager([MICROFORMAT])
return
  • 元に戻って再開
  • 定数定義していた連想配列 MICROFORMAT カッコで囲って配列化し、これを引数に launchAutoPager 関数を呼ぶ(以後、"正常系シナリオ3"と呼ぶ)
  • return でこのスクリプトを終了
  • 正常系シナリオ3は次回に続く

各シナリオのまとめ(正常系のみ)

シナリオ コンテキスト
正常系1 スクリプト冒頭の(自分で定数定義を追加した)SITEINFO を試す場合のシナリオ
正常系2 wedata.net のページ継ぎ足し設定を使う一般的なシナリオ
正常系3 スクリプト冒頭の MICROFORMAT で定数定義された AutoPagerize デフォルトページ継ぎ足し設定のシナリオ

AutoPagerize のコードを読む 2 に続く