http://d.hatena.ne.jp/t_f_m/20110321/1301004931 のエントリに関して。
<div class="pagerModule"> <ul> <li> <a href="../../../affairs/photos/110320/dst11032019130076-p1.htm">< 前の写真</a> </li> <li> <a href="../../../affairs/news/110320/dst11032018460075-n1.htm">記事を読む</a> </li> <li> <a href="../../../affairs/photos/110320/dst11032018460075-p2.htm">次の写真 ></a> </li> </ul> </div>
この例だと、dst以降の数字を上手く比較できれば解決できるはず……と考えて、次のようなXPathを書いた。実際に比較に使っているのは/photos/、/news/以降。
nextLink: 'id("MainContent")/div[@class="pager"]/div/ul/li[substring-before(substring-after(self::li/a/@href,"/photos/"),"-p") = substring-before(substring-after(preceding-sibling::li/a/@href,"/news/"),"-n")]/a',
が、しかし、動かない……! 何故か記事を読むのリンクが選択され、真っ当な読み込みがなされない。なんとなく、絶対パスで指定して比較すれば成功するのでは、と思ってそれっぽいXPathを試してみても、やっぱりダメ。
という部分を読んで、自分はこの XPath の挙動が理解できなかったので、詰め XPath 気分で調べてみた。
実験のために以下の用な HTML と JavaScript を用意した。
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xml:lang="ja" lang="ja" xmlns="http://www.w3.org/1999/xhtml"> <head> <title>xpath test</title> <style type="text/css"> #xpath { width: 85%; } #error { color: red; } .selected-by-xpath { border: 3px solid rgba(0, 95, 249, 0.5); } </style> </head> <body> <h1>xpath test</h1> <ul id="target-ul"> <li><a href="0000p"><前の写真</a></li> <li><a href="0001n">記事を読む</a></li> <li><a href="0001p">>次の写真</a></li> </ul> <hr /> <form id="xpath-form" action=""> <p> <input type="text" id="xpath" name="xpath" value="" /> <input type="submit" value="show XPath result" /> </p> </form> <p id="error"></p> <script src="xpath_test.js" type="text/javascript"></script> </body> </html>
// -*- coding: utf-8 -*- "use strict"; (function() { var form = document.getElementById('xpath-form'); var reset = function() { var forEach = Array.prototype.forEach; var error = document.getElementById('error'); forEach.call(document.querySelectorAll('.selected-by-xpath'), function(elem) { elem.classList.remove('selected-by-xpath'); }); error.innerHTML = ''; }; var show_error_message = function(msg) { var error = document.getElementById('error'); var text = document.createTextNode('Error: ' + msg); error.appendChild(text); }; var show_xpath_result = function(event) { event.preventDefault(); reset(); var xpath = document.getElementById('xpath').value; var nodes; if (!xpath) { return; } try { nodes = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); } catch (e) { show_error_message('Invalid XPath!'); return; } var target; var i; var len = nodes.snapshotLength; if (!len) { show_error_message('No element found!'); return; } for (i = 0; i < len; ++i) { target = nodes.snapshotItem(i); target.classList.add('selected-by-xpath'); } }; form.addEventListener('submit', show_xpath_result, false); }());
こんな感じでフォームに入力した XPath によって選択されるノードを調べた。
まずは 上で引用したエントリで t_f_m さんが書いているのと同様の XPath を試した。
id("target-ul")/li[substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a => 「記事を読む」の a ノード
t_f_m さんの場合と同じく、「>次の写真」の a ノードではなく、「記事を読む」の a ノードが選択されることが確認できた。
この XPath で、述語のコンテキストノードと選択結果の関係を明らかにするために、以下の XPath を試した。
id("target-ul")/li[1][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a => なにも選択されない
id("target-ul")/li[2][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a => 「記事を読む」の a ノード
id("target-ul")/li[3][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a => なにも選択されない
id("target-ul")/li[2][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a という XPath で 「記事を読む」 の a ノードが選択されるのはなぜか
id("target-ul")/li[3][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a という XPath でなにも選択されないのはなぜか
これは、 li[2] の述語で空文字同士の比較が行われているため。 以下の2つの XPath とその結果を見比べればわかると思う。
id("target-ul")/li[2][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a => 「記事を読む」の a ノード
id("target-ul")/li[2][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n") and substring-before(self::li/a/@href, "p") = "" ]/a => 「記事を読む」の a ノード
述語の substring-before(self::li/a/@href, "p") の部分は、 self::li/a/@href のコンテキストノードが li[2] なので substring-before("0001n", "p") となり、 "0001n" に "p" は含まれないので結果的に空文字が返される。
同じく substring-before(preceding-sibling::li/a/@href, "n") の部分も、 preceding-sibling::li/a/@href のコンテキストノードが li[2] であり、その兄ノードは1番目の li ノードしかないので、 substring-before("0000p", "n") となり空文字が返される。
結果として id("target-ul")/li[2][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a は、 id("target-ul")/li[2]["" = ""]/a となるため、「記事を読む」 の a ノードが選択される。
id("target-ul")/li[3][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")] の述語を詳しく見てみる。
まず substring-before(self::li/a/@href, "p") は、述語のコンテキストノードが li[3] なので self::li/a/@href => "0001p" となり、結果 substring-before("0001p", "p") => "0001" となる。
次に substring-before(preceding-sibling::li/a/@href) だけれど、述語のコンテキストノードが li[3] なので preceding-sibling::li/a/@href で選択される属性ノードは
li[3] の1つ手前にある li ノード下の a ノードの href (つまり id("target-ul")/li[3]/preceding-sibling::li[1]/a/@href)
li[3] の2つ手前にある li ノード下の a ノードの href (つまり id("target-ul")/li[3]/preceding-sibling::li[2]/a/@href)
これまでは substring-before の中でノードセットが文字列に変換される際には1つのノードしかノードセットに含まれなかったのでノードセットの文字列化について特に触れなかったのだけれど、この場合のように複数のノードを含むノードセットはどのように文字列化されるのか。
これは http://www.w3.org/TR/xpath/#section-String-Functions に書いてある。
A node-set is converted to a string by returning the string-value of the node in the node-set that is first in document order.
文書順で一番最初のノードの文字列値がノードセットの文字列値になるとある。 この場合にあてはめると
li[3] の1つ手前にある li ノード下の a ノードの href (つまり id("target-ul")/li[3]/preceding-sibling::li[1]/a/@href)
li[3] の2つ手前にある li ノード下の a ノードの href (つまり id("target-ul")/li[3]/preceding-sibling::li[2]/a/@href)
の二つの属性ノードのうち文書順で一番最初のものは li[3] の2つ手前にある li ノード下の a ノードの href であるから、 preceding::li/a/@href を文字列に変換すると "0000p" になり、 substring-before("0000p", "n") は空文字になる。
したがって id("target-ul")/li[3][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href, "n")]/a は id("target-ul")/li[3]["0001" = ""]/a となり、この XPath で選択されるノードはないことになる。
たぶん一番問題だったのは、 XPath におけるノードセットから文字列への変換の部分だと思う。
なのでノードセットを文字列化するときには、そのノードセットに1つのノードのみが含まれているようにするのがよいのでは。
というわけで t_f_m さんが書こうとしていた XPath は、 id("MainContent")/div[@class="pager"]/div/ul/li[substring-before(substring-after(self::li/a/@href,"/photos/"),"-p") = substring-before(substring-after(preceding-sibling::li [1] /a/@href,"/news/"),"-n") and substring-before(substring-after(self::li/a/@href,"/photos/"),"-p") != "" ]/a みたいに書いたらいいのではないか、と思う。少し不格好かもしれないけれど。
あるいは、ページ構造によっては id("MainContent")/div[@class="pager"]/div/ul/li [last()] [substring-before(substring-after(self::li/a/@href,"/photos/"),"-p") = substring-before(substring-after(preceding-sibling::li [1] /a/@href,"/news/"),"-n")]/a と書けるかもしれない。