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

http://d.hatena.ne.jp/t_f_m/20110321/1301004931 のエントリに関して。

<div class="pagerModule">
  <ul>
    <li>
      <a href="../../../affairs/photos/110320/dst11032019130076-p1.htm">&lt; 前の写真</a>
    </li>
    <li>
      <a href="../../../affairs/news/110320/dst11032018460075-n1.htm">記事を読む</a>
    </li>
    <li>
      <a href="../../../affairs/photos/110320/dst11032018460075-p2.htm">次の写真 &gt;</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">&lt;前の写真</a></li>
      <li><a href="0001n">記事を読む</a></li>
      <li><a href="0001p">&gt;次の写真</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>

xpath_test.js はこのように。

// -*- 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 => なにも選択されない

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

  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点。

1. について

これは、 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 ノードが選択される。

2. について

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)

の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.

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

  • 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”)]/aid(“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 と書けるかもしれない。

Notes

  1. ohmizaiju reblogged this from xkansan and added:
    検証どうもありがとうございます。
  2. xkansan posted this