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>などのようにタグが上手くバランスしていない場合

これらも含めて、多分他にもいろいろ問題を含んでいると思うので、ある程度使って知見が蓄積されたら、全体を書き直そう。

*1:例えば、要素の入れ子関係を適切に扱うことは正規表現では基本的に無理だ