Re:Re:XPathの動作にまつわる試行錯誤

以前書いた記事(id:t_f_m:20110321:1301004931 XPathの動作にまつわる試行錯誤 - 近江在住)の内容を検証・仕様確認してくださった方がいらっしゃったので、この記事はそれへの返答となります。
tumblr:xkansanさんありがとうございます。

検証の要点

問題となったXPath、サイト構造とほぼ同様のテスト環境によるテスト。

この結果を見て疑問に思ったことは、

  1. id(“target-ul”)/li[2][substring-before(self::li/a/@href, “p”) = substring-before(preceding-sibling::li/a/@href, “n”)]/a という XPath で 「記事を読む」 の a ノードが選択されるのはなぜか
  2. id(“target-ul”)/li[3][substring-before(self::li/a/@href, “p”) = substring-before(preceding-sibling::li/a/@href, “n”)]/a という XPath でなにも選択されないのはなぜか

という2点。

Re: XPathの動作にまつわる試行錯誤 - xK.memo

問題の切り分け。それぞれの解題は以下。

空文字問題

1. について

これは、 li[2] の述語で空文字同士の比較が行われているため。 以下の2つの XPath とその結果を見比べればわかると思う。

分かってしまえば、こちらは至極単純。

substring-before(self::li/a/@href, "p")

を実行した結果も

substring-before(preceding-sibling::li/a/@href, "n")

を実行した結果も、共に空文字となり、空文字同士の比較は真。当然である。

仮にこれを[1][2]のような文字付加に依らず回避するならば、

id("target-ul")/li[substring-before(self::li/a/@href, "p")][substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href,"n")]/a

のように空文字そのものの真偽を判定してやれば良いだろうか。かなり冗長だが。

ノードと文字列

2. について

(中略)

これまでは 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.

文書順で一番最初のノードの文字列値がノードセットの文字列値になるとある。 この場合にあてはめると

つまり、仕様に従ってノードから文字列(正しくはノードセット、ノード集合から文字列)に変換しているだけなので、別にバグというわけではない。何となく、文字列セットのようなものに変換されて、全て比較に回されるのではないかと思っていたのだが、世の中そんなに甘くなかった。

これを回避するための方策は

なのでノードセットを文字列化するときには、そのノードセットに1つのノードのみが含まれているようにするのがよいのでは。

とあるし、実際そうするしかない。

id("target-ul")/li[substring-before(self::li/a/@href, "p") = substring-before(preceding-sibling::li/a/@href,"n")]/a

の中の

preceding-sibling::li/a/@href

の部分が唯一のノードを返せばよい。少し冗長になるが、

preceding-sibling::li[contains(a/@href,"n")]/a/@href

として、一意に存在するノードを拾い上げれば良い。

対策

というわけで 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 と書けるかもしれない。

『出来るだけマルチバイト文字をXPathとして使いたくない』という前提があるので、こういう書き方をせざるを得ないのだろうな、と思う。文字列も集合として取り扱えるのなら、ここまでトリッキーな書き方をしなくて済むのだろうけれど。まあ、この実装の利点も何となく分かるので、不満というほどでもない。

で、一意にノード同士を比較することが出来るなら元の問題もそこまで大きく騒がなくて済んだので、意味を失わない程度に書き換えるなら、自分はこうするかなあ、と。

id("MainContent")/div[@class="pager"]/div/ul/li[contains(a/@href,"photos")][substring-before(substring-after(a/@href,"/photos/"),"-p") = substring-before(substring-after(preceding-sibling::li[contains(a/@href,"news")]/a/@href,"/news/"),"-n")]/a

xkansanさんの書いてくれた例と大差はないが、自分なりのこだわりというヤツ。

締めに

元記事執筆以降も、何件か似たような失敗と、条件次第ではすべての要素が比較対象となるわけではないらしい、という事には薄々気付いていたものの、具体的な仕様についてはどこにまとまっているのかよく分からなかったので触れずにいた。今回、こうして仕様レベルの動作も含めた検証を見ることで、横着してばかりではいけないな、と痛感した。

実際のところ、動くSITEINFO自体は既に出来ているので、検証の確認と今後に役立てるべき反省材料という以上のものは無いのだが、XPathの仕様に触れることで推奨される手法、あるいは推奨されない手法、バッドノウハウなどを整理できる。他の仕様も、時間を見て確認していく予定。