HTMLパーサ
HTMLパーサは、たまに使いたいと思うのだが、なかなか良さそうなのが見つからないので、自分で作ることにした。
ソース: html2list(0.0.2)
行数は250行くらいで、依存packageはcommon-utils。
とりあえず、行儀良く(?)書かれているHTMLは、だいたいちゃんとパース出来てるような気がする...がまだ開発段階なので自信なし。
機能
外部から使える関数は、html2list:parseのみで、これはstreamかstring(HTML)を引数として取る。
結果としては、パース結果のリスト、HTMLの先頭に付随する宣言のリスト(DOCTYPEなど)、パースに使われなかったトークンのリスト(開発用)の三値が返される。
例:
> (html2list:parse "<html><head><title>タイトル</title></head><body>ボディ</body></html>") --> (:HTML (:HEAD (:TITLE "タイトル")) (:BODY "ボディ")) NIL NIL ;; 宣言付き > (html2list:parse "<?XML ... ?><!DOCTYPE ...><html><head><title>タイトル</title></head><body>ボディ</body></html>") --> (:HTML (:HEAD (:TITLE "タイトル")) (:BODY "ボディ")) ((:! :DOCTYPE "...") (:! :XML "... ?")) ;; パースが途中で終了(先頭に<html>がないため) > (html2list:parse "<head><title>タイトル</title></head><body>ボディ</body></html>") --> (:HEAD (:TITLE "タイトル")) NIL ((:BODY :OPEN) "ボディ" (:BODY :CLOSE) (:HTML :CLOSE))
パース結果のリストは、基本的にcl-who互換だが、cl-whoでコメントなどをどのように扱えば良いのか分からなかったので、その部分は独自のフォーマット(:! :comment ...)を採用している。
なので、コメントや宣言さえ取り除けば、cl-whoに渡すことが可能。↓。
;;; utility (defun tree-remove-if (fn tree) (unless (funcall fn tree) (if (atom tree) tree (cons (tree-remove-if fn (car tree)) (tree-remove-if fn (cdr tree)))))) ;;; HTMLをパース (defparameter *html* (html2list:parse html-string)) ;;; *html*から、コメントを除去 (defparameter *comment-removed-html* (tree-remove-if (lambda (x) (and (consp x) (eq :! (car x)))) *html*)) ;;; cl-whoに渡す: HTMLが整形されて出力される (progn (cl-who:with-html-output (*standard-output* nil :indent t) *comment-removed-html*) 'done)
使用例
せっかくなので使用例を一つ。
参照: nlet-acc, tiny-http
;;;; 例: HTML内に記述されたリンク(a href)や画像(img src)をリストとして返す ;;; utility (defmacro a.when (expr &body body) `(let ((it ,expr)) (when ,it ,@body))) ;; 指定された要素の属性値を集める (defun collect-attribute-value (element attribute tree) (nlet-acc self ((tr tree)) (when (consp tr) (a.when (and (eq (car tr) element-name) (member attribute (cdr tr))) (accumulate (second it))) (self (car tr)) (self (cdr tr))))) ;;; 実行 ;; html取得: Common Lisp HyperSpec(TM)を使わせてもらう > (defvar *html-string* (tiny-http:get "www.lispworks.com" "/documentation/HyperSpec/Front/index.htm")) ;; > (setf *print-length* 5) ;; パース > (defvar *html-list* (html2list:parse *html-string*)) --> (:HTML (:HEAD (:TITLE "Common Lisp HyperSpec (TM)") (:LINK :HREF "../Data/clhs.css" :REL "stylesheet" ...) (:META :HTTP-EQUIV "Keywords" :CONTENT "X3J13, Lisp, Programming Language, Programming Environment, Language Standard, ANSI Language, Object-Oriented, CLOS, Lambda Calculus") (:META :HTTP-EQUIV "Author" :CONTENT "Kent M. Pitman") ...) (:BODY (:H1 (:A :REV "MADE" :HREF "http://www.lispworks.com/" ...) (:A :REL "META" :HREF "../Front/Help.htm" ...)) (:HR) (:BLOCKQUOTE "Welcome to the" (:I "Common Lisp HyperSpec") "." (:BR) ...) (:HR) ...)) ((:! :COMMENT " Common Lisp HyperSpec (TM), version 7.0 ")) NIL ;; リンクを集める > (collect-attribute-value :a :href *html-list*) --> ("http://www.lispworks.com/" "../Front/Help.htm" "mailto:kmp@lispworks.com" "../Front/StartPts.htm" "../Front/StartPts.htm" ...) ;; 画像を集める > (collect-attribute-value :img :src *html-list*) ("../Graphics/LWLarge.gif" "../Graphics/CLHS_Lg.gif" "../Graphics/StartPts.gif" "../Graphics/Hilights.gif" "../Graphics/Contents.gif" ...)
この例のようなことは、正規表現を使っても出来るのだが、文字列ではなくリストとして扱った方が簡潔だったり、表現力が高かったりする*1ことは少なくないと思う。
パースに失敗する既知のケース
- <script>や<stylesheet>の内容が、コメントアウトされていなくて、かつ、#\<などを含む場合
- <p><div>..</div></p>などのように、block要素を含めないはずの要素がblock要素を含んでいる場合
- <p><b></p></p>や<p>...</p></p>などのようにタグが上手くバランスしていない場合
これらも含めて、多分他にもいろいろ問題を含んでいると思うので、ある程度使って知見が蓄積されたら、全体を書き直そう。