digital 千里眼 @abp_jp

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

AutoPagerize のコードを読む 2

AutoPagerize のコードを読む 1 の続きです

前回は launchAutoPager 呼び出しまでの流れを説明しました。ざっと流れを復習すると

    1. 定数定義(設定値など)
    2. 初期化(id:os0x さんが 動作の流れ で書いている『初期化』はページ継ぎ足し機能分類上の初期段階を指しています。私の言っている『初期化』はスクリプトの開始直後の変数の初期化等のプログラミング上での初期処理を指します。混同しないよう注意)
    3. launchAutoPager 関数を呼ぶところまで(シナリオ1〜3)
各シナリオの違い = launchAutoPager の引数の違い(呼び出す関数は同じ
シナリオ 引数
正常系1 スクリプトの冒頭で定数定義した配列 SITEINFO(デフォルトでコメントアウト
正常系2 wedata.net からダウンロードした JSON データの一部(必要な設定データを抜き出した配列)、もしくは過去に wedata.net からダウンロードして保存したローカルキャッシュ(デフォルトで24時間使い回す)
正常系3 スクリプトの冒頭で定数定義した連想配列 MICROFORMATを [ と ] で囲って配列化したもの

今回説明する範囲

  • 冒頭でシナリオ1に軽く触れますが、基本的にシナリオ2を追っていきます。他のシナリオに興味があれば同じように自分で追ってみてください
  • ページ分量の関係で scroll イベントの説明は次回
呼び出された launchAutoPager の定義を説明する前に、引数の例を確認
  • 配列の中に連想配列が入っている
  • 連想配列毎にページ継ぎ足しで使われる設定値が格納されてます
連想配列のキー 意味 備考
insertBefore どの要素の前に次のページを挿入するか XPath
pageElement 次ページとしてどの要素を継ぎ足すか XPath
url どの URL でこのルールを適用するのか 正規表現
nextLink 次ページへのリンク(アンカータグ)を指定 XPath
exampleUrl ルールが適用されるべき URL の例 URL
  • 引数 list の例
[
  {
    "insertBefore": "",
    "pageElement": "//div[@class=\"ybks-md17\" or @class=\"ybks-md117\"]",
    "url": "^http://books\\.yahoo\\.co\\.jp/new_release/",
    "nextLink": "//a[text()=\"次の20件\"]",
    "exampleUrl": "http://books.yahoo.co.jp/new_release/list?k=3&ty=0&nr=2"
  },
  /* 途中省略 */
  {
    "insertBefore": "",
    "pageElement": "id(\"pixflow\")",
    "url": "^http://whytheluckystiff\\.net/quiet/",
    "nextLink": "id(\"header\")/a[last()]",
    "exampleUrl": "http://whytheluckystiff.net/quiet/"
  }
]

launchAutoPager 関数で引数を調べ、現在ページに該当する list(SITEINFO) があれば引数にして AutoPager を new する(570行目付近)

var launchAutoPager = function(list) {
    if (list.length == 0) {
        return
    }
    for (var i = 0; i < list.length; i++) {
        try {
            if (ap) {
                return
            }
            else if (!location.href.match(list[i].url)) {
            }
            else if (!getFirstElementByXPath(list[i].nextLink)) {
                // FIXME microformats case detection.
                // limiting greater than 12 to filter microformats like SITEINFOs.
                if (list[i].url.length > 12 ) {
                    debug("nextLink not found.", list[i].nextLink)
                }
            }
            else if (!getFirstElementByXPath(list[i].pageElement)) {
                if (list[i].url.length > 12 ) {
                    debug("pageElement not found.", list[i].pageElement)
                }
            }
            else {
                ap = new AutoPager(list[i])
                return
            }
        }
        catch(e) {
            log(e)
            continue
        }
    }
}
  • 正常系シナリオ1は、スクリプト冒頭で定数定義された SITEINFO 配列を引数に launchAutoPager を呼んでいました。その SITEINFO 配列はデフォルトでコメントアウトされているので長さゼロ。launchAutoPager 関数は最初に引数の配列の長さを調べており(list.length)、長さゼロなら return する。つまり、デフォルトでシナリオ1は何もしない。自分で SITEINFO を書き加えて初めてシナリオ1は機能する
  • for ループ:配列の要素数(list.length)と同じ回数ループする
    1. if(ap):ap は前回、初期化の最後で null クリアしていたので if の中には入らずスルー
    2. 最初の else if:引数の配列の各要素(=連想配列)について、そのキー url に対応する値を調べ、現在ブラウザで閲覧中の URL とマッチ(match)しなければループ先頭に戻って次の要素に進み(i++)ループ内を最初から繰り返す
    3. 2番目の else if:キー nextLink の値を getFirstElementByXPath(↓直後に解説あり↓) で取得している。閲覧中のページ内に一致するノードがなければ null が帰ってきて、(定数定義で DEBUG = true に設定していたなら、コンソールに"nextLink not found.<見つからなかったXPath>"を出力し)ループ先頭に戻って次の要素に進み(i++)ループ内を最初から繰り返す
    4. 3番目の else if:キー pageElement の値をgetFirstElementByXPath(↓直後に解説あり↓) で取得している。閲覧中のページ内に一致するノードがなければ null が帰ってきて、(定数定義で DEBUG = true なら、コンソールに"pageElement not found.<見つからなかったXPath>"を出力し)ループ先頭に戻って次の要素に進み(i++)ループ内を最初から繰り返す
    5. else:この条件に該当するなら有効な SITEINFO が見つかったと判定し、該当した list(SITEINFO) を引数に AutoPager オブジェクトを new して return する
  • 例外が発生した場合、例外オブジェクトをログ出力しループ処理を継続
  • 続きは70行目付近の AutoPager の定義(utility functions を解説した後に続く)
getFirstElementByXPath関数で XPath に該当する最初の要素を取得(utility functions)
function getFirstElementByXPath(xpath, node) {
    var result = getXPathResult(xpath, node,
        XPathResult.FIRST_ORDERED_NODE_TYPE)
    return result.singleNodeValue
}
  • getFirstElementByXPath の定義はスクリプト後方の utility functions にある。見に行くと...
  • 引数2つ!?...いやいや慌てることはない。足りない引数は undefined となるだけだ
  • 同じく utility functions の ↓ getXPathResult ↓ 関数を第3引数に XPathResult.FIRST_ORDERED_NODE_TYPE に設定して呼び出している。そうすると、一致したノード集合の最初の要素だけを取ってくる
  • singleNodeValue で中身を取り出して return
getXPathResult 関数で XPath で指定したノードを取得(utility functions)
function getXPathResult(xpath, node, resultType) {
    var node = node || document
    var doc = node.ownerDocument || node
    var resolver = doc.createNSResolver(node.documentElement || node)
    // Use |node.lookupNamespaceURI('')| for Opera 9.5
    var defaultNS = node.lookupNamespaceURI(null)
    if (defaultNS) {
        const defaultPrefix = '__default__'
        xpath = addDefaultPrefix(xpath, defaultPrefix)
        var defaultResolver = resolver
        resolver = function (prefix) {
            return (prefix == defaultPrefix)
                ? defaultNS : defaultResolver.lookupNamespaceURI(prefix)
        }
    }
    return doc.evaluate(xpath, node, resolver, resultType, null)
}
変数 解説
xpath 取ってくるノードを XPath で指定
node 対象となるノードを指定(子ノードも含まれる)
resultType http://developer.mozilla.org/En/XPathResult 参照
defaultNS 引数 null で lookupNamespaceURI 関数を呼ぶと、デフォルト名前空間を返す(ここら辺の名前空間について詳しく知りたければ HTML と XHTML で同じ XPath を使う を参照すると良いでしょう)
  • if(defaultNS):もし閲覧中のページでデフォルト名前空間があった場合...(えぇいめんどくさい)は自習とします(要望があれば書く)
  • 最後に evaluateXPath に対応するノードを return しています

AutoPager(70行目付近)により周辺機能の初期化及びページ継ぎ足しのトリガー・イベントの登録を行う

  • 引数 info の例
{
  "pageElement": "id(\"days\")/div",
  "url": "^https?://(?:d|[^.]+\\.g)\\.hatena\\.ne\\.jp/",
  "nextLink": "//a[@rel=\"prev\"]",
  "exampleUrl": "http://os0x.g.hatena.ne.jp/os0x/"
}
var AutoPager = function(info) {
    this.pageNum = 1
    this.info = info
    this.state = AUTO_START ? 'enable' : 'disable'
    var self = this
    var url = this.getNextURL(info.nextLink, document, location.href)

    if ( !url ) {
        debug("getNextURL returns null.", info.nextLink)
        return
    }
    if (info.insertBefore) {
        this.insertPoint = getFirstElementByXPath(info.insertBefore)
    }

    if (!this.insertPoint) {
        var lastPageElement = getElementsByXPath(info.pageElement).pop()
        if (lastPageElement) {
            this.insertPoint = lastPageElement.nextSibling ||
                lastPageElement.parentNode.appendChild(document.createTextNode(' '))
        }
    }

    if (!this.insertPoint) {
        debug("insertPoint not found.", lastPageElement, info.pageElement)
        return
    }

    this.requestURL = url
    this.loadedURLs = {}
    this.loadedURLs[location.href] = true
    var toggle = function() {self.stateToggle()}
    this.toggle = toggle
    GM_registerMenuCommand('AutoPagerize - on/off', toggle)
    this.scroll= function() { self.onScroll() }
    window.addEventListener("scroll", this.scroll, false)
    this.initIcon()
    this.initHelp()
    this.icon.addEventListener("mouseover",
        function(){self.viewHelp()}, true)
    var scrollHeight = getScrollHeight()
    var bottom = getElementPosition(this.insertPoint).top ||
        this.getPageElementsBottom() ||
        (Math.round(scrollHeight * 0.8))
    this.remainHeight = scrollHeight - bottom + BASE_REMAIN_HEIGHT
    this.onScroll()
}
  • 長いので、分割ブラウザを使うなり工夫しましょう...俺が...
  • 関数の頭で初期化がいくつかあり、↓ getNextURL ↓ 関数では次ページを指す URL を絶対パス取得している
  • url(次ページの URL)が取得できなかったら return して終了
  • info.insertBefore つまり SITEINFO で insertBefore が指定されていれば、該当する最初のノードを取得し insertPoint プロパティに代入
  • insertPoint プロパティがなければ(つまり SITEINFO の insertBefore が設定されていない、もしくは該当ノードがなければ) ↓ getElementsByXPath ↓ 呼び出し、結果のノード集合を配列をすぐに pop() して配列最後のノードを取り出し lastPageElement に代入
    • その lastPageElement があれば、nextSibling で直後の兄弟ノードを insertPoint プロパティに代入、兄弟ノードがなければ親ノード(parentNode)より appendChild してテキストノードを追加し insertPoint プロパティに代入
  • insertPoint プロパティがなければ return して終了
  • 次ページの URL を requestURL プロパティに設定
  • 初期化した連想配列 loadedURLs に 現在ページの URL をキーに true を設定
  • toggle メソッド呼び出しを ↓ stateToggle ↓ メソッドに転送するように設定
  • Greasemonkey のメニューに 'AutoPagerize - on/off' として toggle メソッド呼び出しを登録
  • scroll メソッドを onScroll メソッドに転送するように設定
  • window.addEventListener でスクロール時に呼び出される onScroll メソッドを登録
  • ↓ initIcon ↓ メソッド呼び出しで右上のアイコンを設定
  • ↓ initHelp ↓ メソッド呼び出しで右上のヘルプを設定(マウス・オーバーするまで見えませんが...)
  • icon つまり画面右上にある四角形の div に addEventListener で mouseover イベントを追加。↓ viewHelp ↓ メソッドが呼ばれるよう設定しています(3番目の引数 useCapture が true の理由は未確認。フォロー求む)
  • ↓ getScrollHeight ↓ でまだ見えていない部分も含めたドキュメント全体の高さを求め scrollHeight に代入
  • ↓ getElementPosition ↓ 関数で次ページを挿入する位置をドキュメント上部からの差異(offset) の総計(単位ピクセル)として求める、失敗したら getPageElementsBottom メソッドで SITEINFO の pageElement の最後尾の下端の位置を求める、それも失敗したら先ほど求めた scrollHeight の 80% を整数に丸めた値を bottom に代入
  • scrollHeight(見えない部分も含めたドキュメント全体の高さ)- bottom(ページ継ぎ足し開始位置までの距離)+ BASE_REMAIN_HEIGHT(定数定義で指定。ページ継ぎ足し位置よりどのくらい上でページ継ぎ足しを開始するか)を remainHeight (ページ継ぎ足しを開始する残りの高さ)プロパティに代入

  • onScroll メソッド呼び出し
getNextURL で「次のページ」のURLを取得
AutoPager.prototype.getNextURL = function(xpath, doc, url) {
    var nextLink = getFirstElementByXPath(xpath, doc)
    if (nextLink) {
        var nextValue = nextLink.href || nextLink.action || nextLink.value
        if (nextValue.match(/^http(s)?:/)) {
            return nextValue
        }
        else {
            var base = getFirstElementByXPath('//base[@href]', doc)
            return resolvePath(nextValue, (base ? base.href : url))
        }
    }
}
引数 渡ってくる引数の中身
xpath info.nextLink なので、次のページへのアンカータグ(<a href="...url文字列...">)を指すXPath
doc document
url location.href なので、閲覧中ページの URL
  • 前も使った utility functions の getFirstElementByXPath で次ページへのアンカー(=リンク)を取得
  • nextLink が無事取得できたら
    1. アンカーの属性値 href なければ action なければ value を nextValue に代入する。nextValue は次ページの URL
    2. もし nextValue が http: もしくは https: で始まる絶対パスなら nextValue をそのまま return
    3. それ以外(相対指定など)の場合、href 属性を持つ base エレメントを取得し、utility functions の ↓ resolvePath ↓ 関数の戻り値、つまり絶対パス化された URL を return
resolvePath 関数で相対パス絶対パスに変換(utility functions)
function resolvePath(path, base) {
    var XHTML_NS = "http://www.w3.org/1999/xhtml"
    var XML_NS   = "http://www.w3.org/XML/1998/namespace"
    var a = document.createElementNS(XHTML_NS, 'a')
    a.setAttributeNS(XML_NS, 'xml:base', base)
    a.href = path
    return a.href
}
引数 渡ってくる引数の中身
path 次ページへのアンカーに設定されている、絶対パス指定以外の URL
base base エレメントが取得できていれば、その href 属性値。取得できなければ閲覧中ページの URL
  1. createElementNS 関数で名前空間付きのアンカーを作成
  2. 作成したアンカーに setAttributeNS 関数で属性値 base を設定
  3. 作成したアンカーの href 属性に引数 path を代入
  4. アンカーの href を return
  • AutoPager の続きに ↑ 戻ります ↑
getElementsByXPath 関数で XPath で指定されたノード(集合)を取得(utility functions)
function getElementsByXPath(xpath, node) {
    var nodesSnapshot = getXPathResult(xpath, node, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE)
    var data = []
    for (var i = 0; i < nodesSnapshot.snapshotLength; i++) {
        data.push(nodesSnapshot.snapshotItem(i))
    }
    return data
}
引数 渡ってくる引数の中身
xpath SITEINFO の pageElement
node 呼び出し時に指定されていなかったので undefined
  • 以前も出てきた getXPathResult を呼び出しているが第3引数が異なる。XPathResult.ORDERED_NODE_SNAPSHOT_TYPE は「結果ノード集合の順番は文書内に現れる順番」となる
  • 得られたノード集合を snapshotItem で取り出して data 配列に格納(push)し return
stateToggle メソッドで画面右上の ON/OFF 表示を切り替える
AutoPager.prototype.stateToggle = function() {
    if (this.state == 'enable') {
        this.disable()
    }
    else {
        this.enable()
    }
}
  • state プロパティが 'enable' なら disable メソッド呼び出し
  • それ以外なら enable メソッド呼び出し
enable メソッドで右上のアイコン表示を 'on' へ切り替え
AutoPager.prototype.enable = function() {
    this.state = 'enable'
    this.icon.style.background = COLOR['on']
    this.icon.style.opacity = 1
}
  • 状態フラグを 'enable' に
  • 右上の四角いアイコンの色を定数定義で COLOR 連想配列のキー 'on' の値に設定
  • アイコンを不透明に設定
disable メソッドで右上のアイコン表示を 'off' へ切り替え
AutoPager.prototype.disable = function() {
    this.state = 'disable'
    this.icon.style.background = COLOR['off']
    this.icon.style.opacity = 0.5
}
  • 状態フラグを 'disable' に
  • 右上の四角いアイコンの色を定数定義で COLOR 連想配列のキー 'off' の値に設定
  • アイコンを透過度 50% を設定
initIcon で右上のアイコンを設定
AutoPager.prototype.initIcon = function() {
    var div = document.createElement("div")
    div.setAttribute('id', 'autopagerize_icon')
    with (div.style) {
        fontSize   = '12px'
        position   = 'fixed'
        top        = '3px'
        right      = '3px'
        background = COLOR['on']
        color      = '#fff'
        width = '10px'
        height = '10px'
        zIndex = '255'
        if (this.state != 'enable') {
            background = COLOR['off']
        }
    }
    document.body.appendChild(div)
    this.icon = div
}
  • div エレメントを作成し、id 属性に 'autopagerize_icon' と設定。スタイルを設定した後で body エレメントに appendChild。最後に div を this.icon に代入しとく
  • これが、右上の四角形の正体
initHelp で右上のアイコンをホバーさせたときに出てくるヘルプメニュー(?)を設定
AutoPager.prototype.initHelp = function() {
    var helpDiv = document.createElement('div')
    helpDiv.setAttribute('id', 'autopagerize_help')
    helpDiv.setAttribute('style', 'padding:5px;position:fixed;' +
                     'top:-200px;right:3px;font-size:10px;' +
                     'background:#fff;color:#000;border:1px solid #ccc;' +
                     'z-index:256;text-align:left;font-weight:normal;' +
                     'line-height:120%;font-family:verdana;')

    var toggleDiv = document.createElement('div')
    toggleDiv.setAttribute('style', 'margin:0 0 0 50px;')
    var a = document.createElement('a')
    a.setAttribute('class', 'autopagerize_link')
    a.innerHTML = 'on/off'
    a.href = 'javascript:void(0)'
    var self = this
    var toggle = function() {
        self.stateToggle()
        helpDiv.style.top = '-200px'
    }
    a.addEventListener('click', toggle, false)
    toggleDiv.appendChild(a)

    var s = '<div style="width:100px; float:left;">'
    for (var i in COLOR) {
        s += '<div style="float:left;width:1em;height:1em;' +
            'margin:0 3px;background-color:' + COLOR[i] + ';' +
            '"></div><div style="margin:0 3px">' + i + '</div>'
    }
    s += '</div>'
    var colorDiv = document.createElement('div')
    colorDiv.innerHTML = s
    helpDiv.appendChild(colorDiv)
    helpDiv.appendChild(toggleDiv)

    var versionDiv = document.createElement('div')
    versionDiv.setAttribute('style', 'clear:both;')
    versionDiv.innerHTML = '<a href="' + URL +
        '">AutoPagerize</a> ver ' + VERSION
    helpDiv.appendChild(versionDiv)
    document.body.appendChild(helpDiv)

    var proc = function(e) {
        var c_style = document.defaultView.getComputedStyle(helpDiv, '')
        var s = ['top', 'left', 'height', 'width'].map(function(i) {
            return parseInt(c_style.getPropertyValue(i)) })
        if (e.clientX < s[1] || e.clientX > (s[1] + s[3] + 11) ||
            e.clientY < s[0] || e.clientY > (s[0] + s[2] + 11)) {
                helpDiv.style.top = '-200px'
        }
    }
    helpDiv.addEventListener('mouseout', proc, false)
    this.helpLayer = helpDiv
    GM_addStyle('#autopagerize_help a { color: #0f0; text-decoration: underline;}')
}
  • div エレメント作成し helpDiv に代入。id 属性 'autopagerize_help' と style 属性を設定(top:-200px で見えなくしている。この div は右上の四角形でマウスをホバーすると表示されるやつ)
  • div エレメント作成し toggleDiv に代入し、style 属性で左にマージンを設定
  • アンカー(a エレメント)を作成し、class 属性の 'autopagerize_link' やテキストノード 'on/off'、href 属性を設定
  • toggle メソッドを定義。アイコンの変化に加え helpDiv の非表示(top:-200px)が追加されている
  • a.addEventListener でアンカーがクリックされたとき呼び出される toggle メソッドを登録
  • toggleDiv にアンカーを appendChild(toggleDiv の子供ノードにアンカー が追加された)
  • div エレメントを作成し colorDiv に代入。innerHTML で直前に 変数 s や for ループで作成した文字列を流し込んでいる。これは右上の四角形でマウスでホバーさせると表示される左上の部分
  • colorDiv、toggleDiv を helpDiv に appendChild
  • div エレメントを作成し versionDiv に代入。回り込み回避の clear:both スタイル、アンカー文字列を設定
  • versionDiv を helpDiv に appendChild
  • helpDiv を body に appendChild
  • helpDiv の内部構造

  • proc メソッドを定義(引数の e は イベント で直後の addEventListener で mouseout イベントだとわかる)
    1. document.defaultView.getComputedStyle で helpDiv に最終的に適用されているスタイルを取得し c_style に代入(document.dafaultView を使う理由
    2. c_style から top, left, height, width の設定値を getPropertyValue で取り出し、parseInt で整数化し、map を使って s 配列に格納
    3. if の条件を下表にまとめてみました。条件の1つでも一致すれば helpDiv は画面上に跳んでいき見えなくなります。条件を言葉にすると「マウスの位置が helpDiv 上から外れたら、helpDiv を描画領域外に跳ばして見えなくする」
説明
e.clientX イベント発生場所(マウスポインタ)の水平位置(始点は左)
e.clientY イベント発生場所(マウスポインタ)の垂直位置(始点は上)
s[0] helpDiv の上端の位置
s[1] helpDiv の左端の位置
s[2] helpDIv の高さ
s[3] helpDiv の幅
11 padding * 2 + border = 5 * 2 + 1
e.clientX < s[1] イベントの発生場所(マウスポインタの水平位置)が helpDiv の左端より左にある
e.clientX > (s[1] + s[3] + 11) イベントの発生場所(マウスポインタの水平位置)が helpDiv の右端より右にある
e.clientY < s[0] イベントの発生場所(マウスポインタの垂直位置)が helpDiv の上端より上にある
e.clientY > (s[0] + s[2] + 11) イベントの発生場所(マウスポインタの垂直位置)が helpDiv の下端より下にある