sxpathがとてもわかりにくい

sxpathを使ってみようとしたら、とてもわかりにくかったのでメモ。
Gaucheのマニュアルを見ると

SXPathは、XML Information set (Infoset)のインスタンスのS式フォームである SXMLのためのクエリ言語です。

とあるけど、ここではクエリ言語に限らずに、sxml構造から特定のノードを取り出すための機能全般を表すことにする。

補足: HTMLをSXMLに変換する。

とりあえず何か具体的なXMLデータがあった方がよいので、HTMLファイルをSXMLに変換することにした。でもたいていのHTMLファイルはXMLの形式になっていないので、sxml.ssaxモジュールにあるXMLをSXMLに変換するパーザでは変換できないことが多い。変換できないおもな理由は、

  • タグを閉じていない、属性の値がないなど、HTMLではよくてXMLでは認められない文法がある。
  • 文字のエスケープのやり方がXMLと違う。例えばXMLでは引用符中に現れた「&」は「&」と書くことになっているので、HTMLでよくあらわれるURLのクエリ文字列の並びに出てくる「&」XMLではエスケープが必要になる。
  • そもそも文法的に誤っているHTML文書が多い。タグが入れ子になっていないとかエスケープせずに「<」や「>」をそのまま書いているなど文法的に間違っていても、ブラウザが適当にうまく扱ってくれるので、間違いがそのままになる。

あたりだと思う。またたとえパーズできても、sxml.serializerモジュールのsrl:sxml->htmlを使ってHTMLに戻したときに、ブラウザが解釈できないHTMLが生成される場合もある。たとえば

<script ……></script>

(script (@ ……))

に変換されて、これをHTMLに戻すと、

<script/>

になり、ブラウザがうまく解釈してくれない(かもしれない)。
さいわい多少おかしなHTMLでもSXMLに変換してくれるパーザを書いている方がいるので、それを使わせてもらう。

SXMLに変換する関数が(html->shtml input)で、HTMLに戻す関数が(shtml->html shtml)。inputは入力ポートか文字列。
それからHTMLのパーズが失敗する原因として文字コードがある。入力ポートのエンコーディング指定で自動判別"*jp"を指定しても判別を誤る場合がある(たぶんヘッダ部分が長くて日本語文字がなかなか出てこないのが原因だと思う)。その場合は正しいコードを明示的に指定する必要がある。
あとrfc.httpモジュールのhttp-get関数は文字列に変換するときの文字コードの指定ができないっぽいので、外部からHTMLファイルを取ってくるときはces-convertで変換する。

閑話休題。sxpathの話に戻る。

sxmlツリーを探索する

HTML文書をSXMLに変換すると、だいたい次のようなツリー構造が得られる。

(*TOP*
 (*PI* xml ……)
 (*DECL* DOCTYPE ……)
 (html
  (head
   ……)
  (body
   ……)))

このようなツリーから適切なノードを取り出したい。
そうような作業は、コンバータを繰り返し適用していくことで実現できる。

コンバータ

コンバータは、ノードの集まり(ノードセット)または単独のノードを受け取って、それぞれのノードについて探索をして見つけたノードの集まりを返す。典型的には、子ノードを調べて適合した子ノードだけを返すという仕事をする。適合した子ノードを返すコンバータを繰り返し適用していけば、簡単な探索は実現できる。

たとえば「一番外側にあるfoo要素の直下にある要素の属性が知りたい」とする。次のXMLだったら、bar要素の属性「attr2="b"」と、baz要素の属性「attr3="d" attr4="e"」となる。

<foo attr1="a">x<bar attr2="b">y</bar></foo>
<bar>z</bar>
<baz><foo atrr1="c">p</foo></baz>
<foo><baz attr3="d" attr4="e">q</baz><bar>r</bar></foo>

この仕事はコンバータを繰り返し適用して、次のようにおこなわれる。

(*TOP*
 (foo (@ (attr1 "a")) x (bar (@ (attr2 "b")) y)) 
 (bar "z") 
 (baz (foo (@ (atrr1 "c")) "p")) 
 (foo (baz (@ (attr3 "d") (attr4 "e")) "q") (bar "r")) 
)

↓ 要素名がfooである子ノードを取り出すコンバータを適用。2つのノードが見つかる。

(foo (@ (attr1 "a")) x (bar (@ (attr2 "b")) y))
(foo (baz (@ (attr3 "d") (attr4 "e")) "q") (bar "r"))

↓ 任意の子要素を取り出すコンバータを適用。3つのノードが見つかる。

(bar (@ (attr2 "b")) y)
(baz (@ (attr3 "d") (attr4 "e")) "q")
(bar "r")

↓ 属性を取り出すコンバータを適用。2つのノードが見つかる。

(@ (attr2 "b"))
(@ (attr3 "d") (attr4 "e"))

このように、コンバータの適用を繰り返すことで、目的のノードを得ることができた。もちろんコンバータを合成して新しいコンバータを作れば、一度の適用で目的のノードを得られる。
またコンバータは「××であるノードはあるか?」という述語として使うこともできる。空リストが返ってきたら偽でそうでなかったら真。
Gaucheマニュアルの「sxml.sxpath」の「11.30.1 SXPathの基本的なコンバータとアプリケータ 」に、いろいろなコンバータを作る手続きが出ている。
ただし実際にコンバータを使うよりも「11.30.2 SXPathクエリ言語」で説明されているsxpath関数を使う。sxpath関数にクエリを表すリストを渡すと、クエリに基づいてコンバータを作って返してくれるので、そのコンバータをノードに適用して探索をおこなう。ただし、基本的には子ノードを調べていくという操作しか指定できないので、それから外れる仕事をさせようとするのは大変。
それと、マニュアルに書いてある書き換えルールを読み解くと任意のコンバータを埋め込めるように読める(書き換えルールの「(sxpath1 procedure) -> procedure」の部分)。だけど実際にはそのままでは埋め込めない(これについては後述する)。

sxpath関数に与えるクエリ

上の例での「一番外側にあるfoo要素の直下にある要素の属性を得る」という探索は、sxpathを使って次のように書く。SXMLデータをxsとする。

((sxpath '(foo * @)) xs)

「*」は任意の要素(属性リストやテキストノードは含まれない)を表し、「@」は属性リストを表す。
どの子ノードを取り出すのかを順番に指定する。(foo * @)で「要素名がfooの子要素を取り出せ」→「任意の子要素を取り出せ」→「属性リストを取り出せ」を表していることになる。
HTML文書のタイトルノードが欲しい場合は、

((if-car-sxpath '(html head title)) xs)

と書けば

(title (@ ……) "なにか題名")

のようなノードのが得られる(title要素が属性を何も持っていなければ「(@ ……)」の部分はない)。if-car-sxpathは、見つけた最初のノードだけを返す(ひとつも見つからなければ#fを返す)。
title要素ではなく、title要素の中身が欲しいなら

((if-car-sxpath '(html head title *text*)) xs)

と書く。「*text*」は、テキストノードを表す。
また「*any*」で任意のノードを表すので、

((sxpath '(html head title *any*)) xs)

と書けば、title要素の全ての子ノードが得られる。title要素の属性ノードも結果に含まれる。
あと重要なのが「//」で、これは「そのノード自身、全ての子ノード、孫ノード、以下全ての子孫ノード」を返すクエリを表す。なので、

((sxpath '(//)) xs)

と書けば、xs自身も含めた全ての部分ノードのリストが得られる。
「//」を使えば、ツリーの深い位置にある要素を取り出すことができる。

((sxpath '(// title)) xs)

で、全てのtitle要素を取り出せる。ただし、全ての部分ノードには属性リストも含まれていることに注意がいる。例えばa要素がtitle属性を持っていて

(a (@ (href "http://……") (title "……")) "……")

みたいになっていると、上のクエリはこのtitleにも適合する。
「*」も同様で、「*」は属性リスト「(@ (href …) (title …))」には適合しないけれど、内部の「(href …)」や「(title …)」には適合する。
title属性は除いてtitle要素だけが欲しいなら、

((sxpath '(// * title)) xs)

として、逆にtitle要素ではなくtitle属性だけが見たいなら

((sxpath '(// @ title)) xs)

とする。
他にもシンボルの代わりに(or@ a b …)や(not@ a b …)を置くことができる。それぞれ「a、b、…のどれか」「a、b、…のどれでもない」を表す。
例: a要素またはimg要素の属性から、href属性またはsrc属性の属性値を取り出す。

((sxpath '(// (or@ a img) @ (or@ href src) *text*)) xs)
条件で子ノードを限定する

ある条件を満たす子ノードだけを選択することもできる。まず「equal?」というクエリがある。おもに属性の値を調べる場合に使うのだと思う。たとえば

((sxpath '(// @ id (equal? "main"))) xs)

と書いたとする。「// @ id」までの部分で、id属性ノードが全て取り出されている。「(equal? "main")」は、取り出されたノードの子ノード(この場合は、id属性の値)と"main"をequal?で比較する。そして比較して真だったノードだけが取り出される。したがって結果として「"main"」を0個以上含んだリストが得られる。このクエリは欲しいノードを見つけるというより、特定の属性値を持っているかどうかの判定をしている。他にも色々な判定条件が書けるとうれしいけど、equal?以外にはeq?しか書けない。
他には、一旦見つけたノードからさらに条件をつけて絞りこむ、という操作を書くことができる。シンボルを書く代わりに、「(シンボル 条件)」と書く。
(あるいは「(クエリ 条件)」でもよい。クエリは今までに出てきた(// a)とか(html head title)みたいなリストのこと。シンボル以外のもの、たとえば(or@ …)を置きたい場合にはこちらを使う必要がある。「(((or@ …)) 条件)」のように括弧が多くてわかりにくくなるけど)。
条件部分にはクエリ(を表すリスト)か整数を置く。まず条件を使わないで、単に全てのリンク先のURLが欲しければ

((sxpath '(// a @ href *text*)) xs)

と書くことができる。ここでさらに、直下にimg要素を持っているa要素だけを選びたいとする。その場合、

((sxpath '(// (a (img)) @ href *text*)) xs)

と書けばよい。a要素ノードを取り出すのは同じだけど、得られたa要素ノードに対して、クエリ「(img)」を適用する。「(img)」は「要素名がimgの子ノードを取り出す」を表していた。このクエリを適用した結果が空リストでなかった(つまりクエリに適合したものがあった)ノードだけが残される。そして残ったノードに対して、続きの操作「@ href *text*」が適用される。
条件は複数書いてよくて、全てを満たすノードだけが残る。次の例は、直下にimg要素を持ち、title属性を持ったa要素ノードを取り出す。

((sxpath '(// (a (img) (@ title)))) xs)

他の例: ある属性の値が"1"であるような要素を取り出す。

((sxpath '(// (* (@ * (equal? "1"))))) xs)

クエリの最初の「*」で、任意の要素ノードが取り出され、それぞれに対してクエリ「(@ * (equal? "1")」=「属性リストを選び、そこに含まれる任意の要素(属性と値からなるノード)を選び、その属性値が"1"と等しいかを調べる」を適用して、ノードが返ってきたものだけを残す。
あと、条件として整数が書ける。数字を書いた場合、見つかったノードの何番目だけを残すかを指定できる(最初の要素を1番目と数える)。まず、条件指定をせずに

((sxpath '(// table tr)) xs)

と書くと、table要素の直下のtr要素が全て取り出される。一方

((sxpath '(// table (tr 2))) xs)

と書けば、table要素の直下にあるtr要素のうち2番目のtr要素だけが取り出される。もしもtable要素ノードが複数あった場合は、それぞれのtable要素から2番目のtr要素が取り出される。それぞれのtable要素ノードについて「要素名trの子ノードを全て取り出し、そのなかの2番目だけを残す」という動作をする。

((sxpath `(// table (tr ,j) (td ,k))) xs)

と書けば、テーブルのj行目k列目の要素を取り出せる。
後ろから数える場合は負数を使う。-1で一番最後の要素、-2で後ろから2番目の要素、等。
整数による条件指定と他の条件指定を組み合わせる場合は、条件の順番が重要になる。例えば、

((sxpath '(// div (a 4 (img)))) xs)

と書いた場合、各div要素について、div要素の直下にあるa要素のうち4番目のものがまず取り出されたあと、それらのノードのうち直下にimg要素を持つものだけが残る。一方

((sxpath '(// div (a (img) 4))) xs)

の場合、各div要素について、div要素の直下にあるa要素のうち、直下にimg要素を持つものが残されて、その残されたa要素ノードたちの4番目のものが残る。

コンバータの埋め込み

クエリに登場するのは、シンボルかリスト(あと整数)だった。他にはXPathを表す文字列を置けるみたいだけど、XPathを知らないので省略。
まだ手続きオブジェクトは使われていない。なので、クエリにコンバータを埋め込めたらうれしいだろう。たとえば次のように。

((sxpath `(//  ,converter )) xs)

でも、これはうまくいかない。sxml.sxpath「11.30.2 SXPathクエリ言語」の書き換えルールを読むと出来そうだけど、実際にやってみると、引数が3つ渡されているというエラーがでる(コンバータは1引数関数)。
どうも最初のバージョンから変更されているみたい。元バージョンはhttp://okmij.org/ftp/Scheme/lib/SXPath.scmで、Gaucheで使われているものはhttp://ssax.cvs.sourceforge.net/viewvc/ssax/sxml-tools/sxpath.scm?view=logのRevision 1.1(さらに、Revision 1.2以降では、3引数関数ではなく2引数関数を使うように変更になっている)。
コンバータは、ノードセット(ノードのリスト)かノードを受け取る。一方クエリに埋め込める手続きは、ノードセットの他にトップノードとあと何かを受け取る。トップノードは、大元のノード。今までの例でいうxsのこと(Rivision 1.2以降では、トップノードを表す引数はなくなっている。。第3引数は、基本的には空リスト。第3引数は束縛関係を表すらしいのだけど、どう使うのかわからなかった。これらの二つの値は、sxpathのオプション引数で変更できる。

((sxpath '(……) top vars)) xs)

と指定する。
結局、コンバータの代わりに、三つ引数を受け取ってノードセットを返す手続きを埋め込めばよい、ということになる。コンバータconverterを埋め込みたければ、

((sxapath `( ,(lambda (nodeset top vars) (converter nodeset)) )) xs)

と書くことになる。あるいはアダプター関数を作って「 ,(adapter converter)」みたいに書くとか。
例えば、sxml.sxpathモジュールにsxml:filter?という関数がある。この関数は、ノードについての述語を受け取ってコンバータを作る。((sxml:filter? pred?) nodeset)は、nodesetに含まれるノードのうち、pred?を満たすものだけを残す。これを使ってみる。
ついでにコンバータをクエリに埋め込める関数に変換するアダプタ関数を作っておく。

(define (q-converter conv)
        (lambda (nodeset . args)
          (conv nodeset)))

これらの関数を使うと、正規表現rxにマッチするノードだけを残すコンバータ(をクエリ用に変換したもの)は

(define (q-filter pred?)
  (q-converter (sxml:filter pred?)))

と書ける。このq-filterを使えば「href属性の値が「http」から始まるようなa要素を取り出す」は

((sxpath `(// (a (@ href *text* ,(q-filter #/^http/))))) xs)

と書くことができる。
コンバータを直接埋め込めたほうが便利な気がする。arityで受け取る引数の数を調べて、余分な引数は渡さないようにするとかなら互換性的にも問題なさそうだけど。

2010-04-13 追記

補足として「sxpathのクエリに正規表現を書けるようにする」を書いた。