Common Lisp で HTML をリスト形式にパース(parse)して、いろいろいじって、最後にまた HTML に戻す(serialize)一連の流れをメモ。
HTML 関連のパッケージは様々ある(quickdocs.org/search?q=HTML)が、今回やりたいことには HTML を Parse するだけじゃなくて元の HTML の形に戻す必要があるので、両方のメソッドが揃って提供されている Closure HTML を使うことにした。
(ql:quickload 'closure-html)
HTML をリストにパースする
例えばこんな HTML があったとする。
sample.html
<html> <head> <title>Sample HTML</title> </head> <body class="foo"> <h1 class="foo">Sample HTML</h1> <p class="bar">Welcom To My Page</p> <div class="baz">div1 <div class="bar">div2</div> </div> <h2 class="foo">head 1</h2> <p class="bar">paragraph 1</p> <h2 class="foo">head 2</h2> <div class="baz"> <p class="bar">paragraph 2</p> </div> </body> </html>
これを Closure HTML で Paese してみる。
;; sample.html を文字列として読み込む (defparameter sample_html (with-open-file (in "./sample.html" :direction :input) (do ((line (read-line in nil nil) (read-line in nil nil)) (lines nil (push line lines))) ((null line) (format nil "~{~A~^~%~}" (nreverse lines)))))) ;; HTML を Parse する関数 ;; chtml = closure-html (defun parse-html (html) (chtml:parse html (chtml:make-lhtml-builder))) ;; Parse してみる。 (parse-html sample_html) ;; -> こうなる (:HTML NIL (:HEAD NIL (:TITLE NIL "Sample HTML")) (:BODY ((:CLASS "foo")) " " (:H1 ((:CLASS "foo")) "Sample HTML") " " (:P ((:CLASS "bar")) "Welcom To My Page") " " (:DIV ((:CLASS "baz")) "div1 " (:DIV ((:CLASS "bar")) "div2") " ") " " (:H2 ((:CLASS "foo")) "head 1") " " (:P ((:CLASS "bar")) "paragraph 1") " " (:H2 ((:CLASS "foo")) "head 2") " " (:DIV ((:CLASS "baz")) " " (:P ((:CLASS "bar")) "paragraph 2") " ") " ")) ;; 変数に格納しておこう (defparameter parsed-html (parse-html sample_html))
見ての通り、
;; 例えば、 <div id="foo" class="bar"> text1 <span style="font-color:red"> test2 </span> </div> ;; は、こういうことだ (:DIV ((:ID "foo") (:CLASS "bar")) "text1" (:SPAN ((:STYLE "font-color:red")) "test2"))
リストの先頭は HTMLタグ、2個めの要素は属性のリスト(属性がなければ NIL)、3個目に HTMLタグでくくられたリテラル、という構成になっている。
パースした HTML を料理する
Parse した HTML を編集する。
簡単な例から行こう。
リスト内にある個々のアトムを編集するなら、subst を使えば十分だ。
sample.html を Parse した parsed-html の中にある
(:CLASS "foo")
の"foo"
を全て"foobar"
に置換してみよう。(subst "foobar" "foo" parsed-html :test #'equal) ;; -> 結果(長くなるから改行・タブ文字は削除した) (:HTML NIL (:HEAD NIL (:TITLE NIL "Sample HTML")) (:BODY ((:CLASS "foobar")) (:H1 ((:CLASS "foobar")) "Sample HTML") (:P ((:CLASS "bar")) "Welcom To My Page") (:DIV ((:CLASS "baz")) "div1" (:DIV ((:CLASS "bar")) "div2")) (:H2 ((:CLASS "foobar")) "head 1") (:P ((:CLASS "bar")) "paragraph 1") (:H2 ((:CLASS "foobar")) "head 2") (:DIV ((:CLASS "baz")) (:P ((:CLASS "bar")) "paragraph 2"))))
アトム毎に確認していって置換する(しかもそのアトムが条件に該当したら全部置換)ならこれでいい。
でも、「h1 の属性に使われてる foo だけを foobar にして、他で使われてる foo はそのままにしたい」とか、「div と baz クラスの組み合わせの時はタグ自体を取り除きたい」とか、そういうちょっと複雑な要望に応えるにはどうすればいいだろう?
要素と属性を判断して云々してくれるようなユーティリティをネットで探してみたけど、これっていうのが見つからなかった(知ってる人、教えて欲しい)。
まぁでも冷静に考えてみたら、Tree になってるリストの中を歩きまわって編集していけばいいわけだ。
Tree を歩きまわって再構築する関数の例として Paul Graham の「On Lisp」 の中にちょうど良い例を見つけた。
;; from Paul Graham's On Lisp (defun prune (test tree) (labels ((rec (tree acc) (cond ((null tree) (nreverse acc)) ((consp (car tree)) (rec (cdr tree) (cons (rec (car tree) nil) acc))) (t (rec (cdr tree) (if (funcall test (car tree)) acc (cons (car tree) acc))))))) (rec tree nil)))
この関数 prune も Tree のアトムに対して変更を加える点は subst といっしょだ。
prune は test の結果が t なら、そのアトムをスキップする。nil なら、何事もなかったように元のまま cons していく。
今回やりたいことは、
- アトムだけでなく、Tree の中のリスト要素も引数で渡した関数でテストしたい
- テストが nil を返したら、それはそのまま nil として cons する
- テストの結果、スキップすべき要素だったらスキップ用のキーワードを返す、というルールを適用したい
この3つのやりたいことが実現できれば、Tree を好きなように編集できる。
で、作成してみた関数はこれ。
(defun tree-editor (fn tree &key (skip-key 'skip)) (labels ((walker (tr acc) (cond ((null tr) (nreverse acc)) ((consp (car tr)) (walker (cdr tr) (let ((lis (funcall fn (car tr)))) (if (eq lis skip-key) acc (cons (walker lis nil) acc))))) (t (walker (cdr tr) (let ((atm (funcall fn (car tr)))) (if (eq atm skip-key) acc (cons atm acc)))))))) (walker tree nil)))
アトムだけでなく、Tree の中のリスト要素も引数で渡した関数でテストしたい | ☞ | cond で (consp (car tr)) の時も fn を呼ぶ |
テストが nil を返したら、それはそのまま nil として cons する | ☞ | let で一旦テスト結果を捕捉 |
テストの結果、スキップすべき要素だったらスキップ用のキーワードを返す、というルールを適用したい | ☞ | &key でスキップするときのキーワードを設定しておいて、テスト結果がスキップ用のシンボルを返したらスキップ |
使ってみよう。
;; h1 の中の class="foo" だけ class="foobar" に (tree-editor #'(lambda (obj) (if (and (listp obj) (eq (car obj) ':H1)) (list (car obj) (subst "foobar" "foo" (cadr obj) :test #'equal) (caddr obj)) obj)) parsed-html) ;; -> "foobar" に置換されたのは H1 の属性だけ (:HTML NIL (:HEAD NIL (:TITLE NIL "Sample HTML")) (:BODY ((:CLASS "foo")) (:H1 ((:CLASS "foobar")) "Sample HTML") (:P ((:CLASS "bar")) "Welcom To My Page") (:DIV ((:CLASS "baz")) "div1" (:DIV ((:CLASS "bar")) "div2")) (:H2 ((:CLASS "foo")) "head 1") (:P ((:CLASS "bar")) "paragraph 1") (:H2 ((:CLASS "foo")) "head 2") (:DIV ((:CLASS "baz")) (:P ((:CLASS "bar")) "paragraph 2"))))
;; ((:CLASS "bar")) を nil に ;; つまり HTML 上は <p class="bar"> -> <p> (tree-editor #'(lambda (obj) (if (and (listp obj) (equal obj '((:CLASS "bar")))) nil obj)) parsed-html) ;; -> (:HTML NIL (:HEAD NIL (:TITLE NIL "Sample HTML")) (:BODY ((:CLASS "foo")) (:H1 ((:CLASS "foo")) "Sample HTML") (:P NIL "Welcom To My Page") (:DIV ((:CLASS "baz")) "div1" (:DIV NIL "div2")) (:H2 ((:CLASS "foo")) "head 1") (:P NIL "paragraph 1") (:H2 ((:CLASS "foo")) "head 2") (:DIV ((:CLASS "baz")) (:P NIL "paragraph 2"))))
;; div タグで囲まれた部分はなかったことにする(スキップ) (tree-editor #'(lambda (obj) (if (and (listp obj) (eq (car obj) ':DIV)) 'skip obj)) parsed-html) ;; -> (:HTML NIL (:HEAD NIL (:TITLE NIL "Sample HTML")) (:BODY ((:CLASS "foo")) (:H1 ((:CLASS "foo")) "Sample HTML") (:P ((:CLASS "bar")) "Welcom To My Page") (:H2 ((:CLASS "foo")) "head 1") (:P ((:CLASS "bar")) "paragraph 1") (:H2 ((:CLASS "foo")) "head 2")))
ここまで書いて気付いたんだけど、例えばこんな
<div class="baz">div1 <div class="bar">div2</div> </div>
ネストしている部分から HTML タグだけを削除して、コンテンツのリテラルは残すにはどうしたらいいだろう?
一晩考えてみたんだけど、ここは一旦無効なタグ(それは NIL だ!)に置き換えて後で文字列としてのHTML上で削除するのが得策なようだ(ネストした部分が全て一つの文字列アトムになるならタグを取っ払った後 flatten して concatenate ‘string すればいいけど、タグを取っ払ったのがネストの外側で、内側のリストを残したい場合は?とかいろいろ考えてたら面倒臭くなった)。
;; div と div の attribute を nil nil に置換 (tree-editor #'(lambda (obj) (if (and (listp obj) (eq (car obj) ':DIV)) (if (= (length (cddr obj)) 2) (list nil nil (caddr obj) (cadddr obj)) (list nil nil (caddr obj))) obj)) parsed-html) ;; -> (:HTML NIL (:HEAD NIL (:TITLE NIL "Sample HTML")) (:BODY ((:CLASS "foo")) (:H1 ((:CLASS "foo")) "Sample HTML") (:P ((:CLASS "bar")) "Welcom To My Page") (NIL NIL "div1" (NIL NIL "div2")) (:H2 ((:CLASS "foo")) "head 1") (:P ((:CLASS "bar")) "paragraph 1") (:H2 ((:CLASS "foo")) "head 2") (NIL NIL (:P ((:CLASS "bar")) "paragraph 2")))) ;; 次のセクションで使うから変数に保存しておこう (defparameter edited-tree *) ;; Common Lisp は直前に評価したフォームの結果が ;; * というシンボルに格納されてる
パースされた HTML を文字列としての HTML に戻す(serialize)
じゃあ先ほど保存しておいた edited-tree を HTML 文書に戻してみよう。
(chtml:serialize-lhtml edited-tree (chtml:make-string-sink)) ;; -> "<HTML> <HEAD><TITLE>Sample HTML</TITLE></HEAD> <BODY CLASS=\"foo\"> <H1 CLASS=\"foo\">Sample HTML</H1> <P CLASS=\"bar\">Welcom To My Page</P> <NIL>div1<NIL>div2</NIL></NIL> <H2 CLASS=\"foo\">head 1</H2> <P CLASS=\"bar\">paragraph 1</P> <H2 CLASS=\"foo\">head 2</H2> <NIL><P CLASS=\"bar\">paragraph 2</P></NIL> </BODY> </HTML>"
で、ここから <NIL> と </NIL> を取り去ろう。
(個人的に使ってる正規表現の簡単な書き方を使ってる。詳しくは過去記事を参照: [Common Lisp] CL-PPCRE をコンパクトに纏める )
(s "</?NIL[^>]*?>" "" 'ig *) ;; -> "<HTML> <HEAD><TITLE>Sample HTML</TITLE></HEAD> <BODY CLASS=\"foo\"> <H1 CLASS=\"foo\">Sample HTML</H1> <P CLASS=\"bar\">Welcom To My Page</P> div1div2 <H2 CLASS=\"foo\">head 1</H2> <P CLASS=\"bar\">paragraph 1</P> <H2 CLASS=\"foo\">head 2</H2> <P CLASS=\"bar\">paragraph 2</P> </BODY> </HTML>"
できましたね。
HTML をパースする方法ぐらいは Web で検索すればいろいろ出てきたんだけど、パースした後の Tree をいじくってる記事がパッと出てこなかったので、自分なりに試行錯誤してみたところをまとめてみた。
今回は実務に関わる部分で HTML を題材にやってみたんだけど、お陰で Tree 状のリストを操作するときの勘みたいなものも身につけられた気がする。
僕と同じように駆け出しの LISPER の方がこれを読んで少しでも理解のきっかけになれば嬉しいです。
EC業界的な余談
元々 Perl を使ってたというのもあるけど、HTML の置換には正規表現を使いすぎていたきらいがある。
でもよくよく考えてみたら、正規表現は対象を平べったい文字の羅列として扱うのは非常に得意だけれども、HTML のように構造化された文字列を扱おうと思うと、いきなりパターンが複雑になる傾向にある。
今回この問題に取り組んでみて一番の収穫だったのは、Lisp のリストのように構造を扱うのが得意なメソッドでやるべきことと、正規表現のようなフラットな文字列を扱うのが得意なそれでやることをはっきりすみ分けると、関数同士の干渉が最小限になって(コードが読みやすいかは別問題として)役割分担が明確になるし、何より精度が向上するというのが理解できたことだろう。
さて、読者の中には「なんで HTML をいじくりまわすのにこんなに手のこんだことをしてるんだ?」と疑問に思う方もいらっしゃるかもしれない。
僕が単に趣味でホームページ用の HTML を書いてるぐらいの人だったらこの記事の内容はほとんど意味がない。
HTML を編集したかったらエディターで開いて編集したほうが早いだろう。
これらのメソッドが必要になるのは(これは正に僕の日々の仕事なのだが)以下の様なことをしなきゃいけない人である。
- 他人が書いた HTML を編集しなきゃいけない
- 使用可能な HTML の規制が緩いプラットフォームで書かれた HTML から、規制が厳しいプラットフォーム向けに変換しなきゃいけない
- それもいっぺんに何百から何十万件という HTML を変換しなきゃいけない
EC業界では「常識」としてまかり通っているようだが、プラットフォームによってはスタイルシート参照が許されていなかったり、ヘッドラインが h3 以下でないと使えなかったりなど、Web 全般で言えば「非常識」極まりないルールを適用しているところが意外とたくさんある。
ホームページを自分で作ったことがある人なら解ってもらえるだろう、これが如何に不自由なことか!
このためにかなり泥臭い HTML の書き換えを要求されるわけである(ちょっと愚痴っぽくなって申し訳ない)。
だが僕はプログラマだ。
そこにややこしい問題があればそれを如何に楽しんで解くかを仕事にしているのだ。
実際、今回のパース問題を解くのはすごく楽しかったし勉強になった。
ありがとう、う◯こなプラットフォームのお役人たちよ!