21
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Emacs:パターンマッチでスクレイピング

Posted at

はじめに

このエントリではスクレイピングに絡めて pcase を紹介します。

Emacs で大規模なクローリング & スクレイピングという話ではなく、調べものでちょっとした資料をまとめたり、マニュアル lookup 等のカスタムモードを作る時に pcase でスクレイピングすると楽しいなーという学びがあったのでおすそわけ。

pcase いいですよ。

必要なもの

  • libxml2 をリンクした Emacs 24.x

pcase 入門

リストを走査するとき、「字句どおりにマッチできたら楽なのに〜」って思いますよね。それ pcase で出来ます。

(pcase EXP &rest CASES)

EXP にマッチするパターンを CASES に書きます。CASES(UPATTERN CODE...) のリストで、UPATTERN にマッチすると CODE... が評価されます。

まずは字句どおりにマッチするパターンを見てみます。

(pcase '(keep "it" (simple) stupid)
  (`(keep "it" (simple) stupid)
   t))
;; ===> t

わーお!あまりにも自然すぎて便利さが伝わらないかも知れませんが、バッククォートで始まるパターンでは、アトム、文字列、コンスセルがそのままマッチします。

「マッチと同時に値を取り出せないの?」もちろん、できますとも。

(pcase '(The Whole Earth Catalog)
  (`(The Whole Earth ,what)
   what))
;; ===> Catalog

バッククォートパターンの中で、カンマに続けてシンボルを置くと、その位置で捕捉された値(この場合は Catalog)がシンボルに束縛されます。

もう少し複雑なリストでもやってみます。

(pcase '(p nil
           "paragraph"
           (a ((href . "foo.html")
               (rel . "bookmark"))
              "link text"))
  (`(p nil
       "paragraph"
       (a ((href . ,url)
           (rel . "bookmark"))
          ,text))
   (cons text url)))
;; ===> ("link text" . "foo.html")

結構深い位置にある要素も、パターンマッチを使うと簡単に抜き出せることがわかります。便利ですねー。

パターンは記述した順に評価され、マッチしたパターンより後は評価されません。

(pcase '(a b c)
  (`(a b c)
   'first)
  (`(a b c)
   'second)
  (`(a b c)
   'third))
;; ===> first

どのパターンにもマッチしない場合には nil が返されます。

(pcase '(a b c)
  (`(x y z)
   t))
;; ===> nil

何にでもマッチするフォールバックパターンには ,_ が使えます。

(pcase '(a b c)
  (`(a ,_ ,_)
   t))
;; ===> t

リストにマッチするパターンがどういったものか、これでおおよそ理解できたかと思います。でも、これだけでは不十分ですね。条件分岐や、込み入った変数束縛もできないと。

他にはどんなパターンを書けるのか、pcase のドキュメントで確認してみます。

pcase is an autoloaded Lisp macro in `pcase.el'.

(pcase EXP &rest CASES)

Perform ML-style pattern matching on EXP.
CASES is a list of elements of the form (UPATTERN CODE...).

UPatterns can take the following forms:
  _             matches anything.
  SELFQUOTING   matches itself.  This includes keywords, numbers, and strings.
  SYMBOL        matches anything and binds it to SYMBOL.
  (or UPAT...)  matches if any of the patterns matches.
  (and UPAT...) matches if all the patterns match.
  `QPAT         matches if the QPattern QPAT matches.
  (pred PRED)   matches if PRED applied to the object returns non-nil.
  (guard BOOLEXP)       matches if BOOLEXP evaluates to non-nil.
  (let UPAT EXP)        matches if EXP matches UPAT.
If a SYMBOL is used twice in the same pattern (i.e. the pattern is
"non-linear"), then the second occurrence is turned into an `eq'uality test.

QPatterns can take the following forms:
  (QPAT1 . QPAT2)       matches if QPAT1 matches the car and QPAT2 the cdr.
  ,UPAT                 matches if the UPattern UPAT matches.
  STRING                matches if the object is `equal' to STRING.
  ATOM                  matches if the object is `eq' to ATOM.
QPatterns for vectors are not implemented yet.

PRED can take the form
  FUNCTION           in which case it gets called with one argument.
  (FUN ARG1 .. ARGN) in which case it gets called with an N+1'th argument
                        which is the value being matched.
A PRED of the form FUNCTION is equivalent to one of the form (FUNCTION).
PRED patterns can refer to variables bound earlier in the pattern.
E.g. you can match pairs where the cdr is larger than the car with a pattern
like `(,a . ,(pred (< a))) or, with more checks:
`(,(and a (pred numberp)) . ,(and (pred numberp) (pred (< a))))

どうやら、UPatternsQPatterns の二種類のパターンがあるようです。以下、少し詳しく見ていきますが、上記内容を噛み砕いているだけなので、不要なら読み飛ばしてください。

UPatterns

UPatterns には何にでもマッチする _、即値、シンボル(変数束縛)、組み込み関数、そしてバッククォートで始まる QPattern を記述できます。

フォールバックパターン

何にでもマッチするパターンには _ を使います。

(pcase '(a b c)
  (`(x y z) 'xyz)
  (`(1 2 3) '123)
  (_ 'fallback))
;; ===> fallback

即値

即値として許されるのは、数値、文字列、キーワードだけです。

(pcase 1225
  (0 nil)
  (1225 t))
;; ===> t

(pcase "happy"
  ("Christmas" nil)
  ("happy" t))
;; ===> t

(pcase :white
  (:Christmas nil)
  (:white t))
;; ===> t

変数束縛

裸のシンボルは、捕捉した値を束縛します。

(pcase 'qiita
  (var1 var1)
  (var2 var2))
;; ===> qiita

変数束縛は常に non-nil を返します。よって上記は var1 が評価されます。

組み込み関数

(pred FUNC) は叙述関数で、FUNC の引数として捕捉した値(ここでは 'atom)を適用して評価します。

(pcase 'atom
  ((pred numberp)
   'number)
  ((pred stringp)
   'string)
  ((pred symbolp)
   'symbol))
;; ===> symbol

(and UPAT...)UPAT が全て non-nil ならマッチとみなします。

(pcase 1224
  ((and (pred (< 1201))
        (pred (> 1231)))
   t))
;; ===> t

pred の引数を (FUNC ARG1 .. ARGN) のように括弧でくくると、複数引数の関数も呼び出せるようになります。捕捉した値(ここでは 1224)が最後の引数として適用されるのは同じです。

(or UPAT...)UPAT のいずれかが non-nil ならマッチとみなします。

(pcase "qiita"
  ((or (pred symbolp)
       (pred stringp))
   t))
;; ===> t

残りの (guard ...)(let ...) については、おいおい見ていくことにします。

QPatterns

QPatterns には字句どおりマッチするアトム、文字列、コンスセルの他、カンマに続けて UPattern を記述できます。ところで、UPatterns にも、バッククォートで始まる QPattern を書くことができました。

ということはつまり、両者は相互再帰的に記述可能ということです。

(pcase '(ul nil
            (li ((class . "menu"))
                (button ((class . "bookmark"))
                        "button text")))
  (`(ul ,_
        (li ((class . "menu"))
            ,(or `(a ,(pred (member '(rel . "bookmark")))
                     ,text)
                 `(button ((class . "bookmark"))
                          ,text))))
   text))
;; ===> "button text"

最初のほうで見た、バッククォート中のフォールバックパターン ,_ や変数束縛 ,url なども、結局のところ、QPattern から UPattern への一時的なスイッチにすぎなかったわけです。

補足:捕捉とマッチについて

何度か捕捉という言葉が出てきていますが、このエントリでは次の意味で使っています。

  • 捕捉:パターンに適用するオブジェクトを式から取り出すこと
  • マッチ:捕捉したオブジェクトをパターンに適用すること

例として、以下の式を順を追って見ていきます。

(pcase '(a (b c) "string")
  (`(a ,(pred listp) ,text)
   text))
;; ===> "string"
  1. EXP から '(a (b c) "string") を捕捉する(以降、object と呼ぶ)
  2. 最初のパターンを取り出す(以降、pattern と呼ぶ)
  3. object から a を捕捉し、patterna と比較 → マッチ成功
  4. object から (b c) を捕捉し、pattern,(pred listp) に適用 → マッチ成功
  5. object から "string" を捕捉し pattern,text に適用 → 束縛 & マッチ成功
  6. objectpattern 全体にマッチしたので、text を評価 → "string"

捕捉したからといって、常にマッチに成功するわけではありません。念の為。

スクレイピング用のテンプレート

続いて後半です。スクレイピングの実装に入ります。

まずは、最低限の処理だけ実装したテンプレートです。

(defun your-retrieve-function (url func buffer-name)
  (let ((callback
         `(lambda (status)
            (when status
              (error "%S" status))
            (let ((dom (mm-with-part (mm-dissect-buffer 'no-strict-mime)
                         (libxml-parse-html-region (point-min)
                                                   (point-max))))
                  (buffer (get-buffer-create ,buffer-name))
                  (standard-output (get-buffer ,buffer-name)))
              (with-current-buffer buffer
                (erase-buffer)
                (prin1 (,func dom))
                (message "`%s' done" ',func))))))
    (url-retrieve url callback)))

url-retrieve は非同期なのですぐに制御を戻します。コールバック関数 callback は、一時バッファに応答結果(HTTPヘッダー込み)が展開された状態で呼び出されます。

この内容を mm-dissect-buffer で解析し、mm-with-part で HTML を抜き出し、libxml-parse-html-region で DOM に変換します。func 引数はスクレイピング用の関数で、この戻り値を buffer-name バッファに挿入します。とりあえずのテンプレートなので、カスタマイズはお好きなように。

次は func に渡す DOM の中身を調べてみます。

DOM の構造

libxml-parse-html-region が返すリスト形式は (TAG ATTR-ALIST CHILDREN) です。例えば、以下のような HTML があった場合

<div class="article">
  <h1>title</h1>
  <p>paragraph
    <a href="foo.html" class="bar">link text</a>
    <!-- comment -->
    some text
    
    end of paragraph
    <br/>
  </p>
</div>

次のリストが返されます。

(html nil
      (body nil
            (div
             ((class . "article"))
             "\n  "
             (h1 nil "title")
             "\n  "
             (p nil "paragraph\n    "
                (a
                 ((href . "foo.html")
                  (class . "bar"))
                 "link text")
                "\n    "
                (comment nil " comment ")
                "\n    some text\n    \n    end of paragraph\n  ")
                (br nil))
             "\n")))

htmlbody など、最低限必要な要素は自動的に補われてます。また、ATTR-ALIST がない場合でも明示的に nil が置かれます。CHILDREN 部は長さ 0 以上のリストで、その要素はアトムかリストです。

HTML が普通にリスト化されてますね。

ところで、見るからに邪魔なのが "\n " などの文字列。パターンマッチさせることを考えると、あらかじめ除去しておきたいところ。

DOM のサニタイズ

さて、どうやってゴミを除去したものか。

ゴミと思しき文字列は空白や改行ですが、これはゴミ以外の文字列にも出現します。なので、ゴミにマッチしない正規表現を書いてみます。

(string-match-p "[[:graph:]]" "\n  ")
;; ===> nil
(string-match-p "[[:graph:]]" "\n    some text\n    \n    end of paragraph\n  ")
;; ===> 5

オーケー。これを反転させればゴミを識別できます。あとはリスト走査を付け加えれば、サニタイズ関数の出来上がり。

(defun your-sanitize-function (dom &optional result)
  (push (nreverse
         (cl-reduce (lambda (acc object)
                      (cond
                       ((and (stringp object)
                             (not (string-match-p "[[:graph:]]" object)))
                        acc)
                       ((or (atom object)
                            (consp (car object)))
                        (cons object acc))
                       (t
                        (your-sanitize-function object acc))))
                    dom :initial-value nil))
        result))

cl-reducedom を走査し、リスト要素を調べていきます。

ゴミを見つけた場合には、それまでの結果を蓄えたリストである acc を返します。アトムか連想リストなら、要素をそのまま cons します。残りはリストなので再帰します。

要素はリストの先頭に追加していくので、最後に nreverse で元の並びに戻します。

これを先ほどのテンプレートと組み合わせると次の通り。

(your-retrieve-function "http://your.url"
                        (lambda (dom)
                          (your-extract-function
                           (your-sanitize-function dom)))
                        "your buffer")

よーやく形が見えてきました。あとは your-extract-function を実装するだけ。

実践:パターンマッチ

長らくお待たせしました。いよいよパターンマッチで抜き出します。cl-reduce を使うとこんな感じ。

(defun your-extract-function (dom &optional result)
  (nreverse
   (cl-reduce (lambda (acc object)
                (pcase object
                  ;; マッチさせたいパターンを最初に書く
                  ;; 例)とにかくリンクを抜き出す場合
                  ;; (`(a ,_ ,_)
                  ;;  (cons object acc))
                  ((or (pred atom)
                       (guard (consp (car object))))
                   acc)
                  (_
                   (your-extract-function object acc))))
              dom :initial-value result)))

サニタイズ関数と似ていますが、パターンにマッチしたものだけを cons していくので、結果は平坦なリストになります。

((or ... の部分は、サニタイズ関数にもあったアトムと連想リストの判定です。このパターンにマッチしたということは即ち不要な要素(再帰も不要)なので、cons せずに acc を返します。

(guard BOOLEXP)BOOLEXP を評価します。(pred FUNC) とは異なり、捕捉した値は引数に適用されません。冗長ですが、汎用的とも言えます。

最後に、フォールバックパターンで再帰します。あとは好きなようにいじってテストしていけば、感じが掴めるはず。

例1:テーブルからリンクを抜き出す

テーブル形式の一覧からリンクを抜き出したいとします。抜き出したい部分はこんな感じです(ちなみに、ネタは Go のパッケージ一覧)。

<tr>
  <td class="pkg-name" style="padding-left: 20px;">
    <a href="archive/tar/">tar</a>
  </td>
  <td class="pkg-synopsis">
    Package tar implements access to tar archives.
  </td>
</tr>

ところでよく見ると、次のように "pkg-synopsis" が空っぽのものもチラホラあります。

<tr>
  <td class="pkg-name" style="padding-left: 0px;">
    <a href="archive/">archive</a>
  </td>
  <td class="pkg-synopsis">

  </td>
</tr>

これはディレクトリ用のリンクであり、パッケージそのものではないので、無視することにします。また、テーブルに含まれていないリンクもいくつか発見しましたが、これらも当然、対象外となります。

よって仕様としては『テーブルに含まれていて、かつ、"pkg-synopsis" が空っぽじゃないリンクを抜き出す』になります。言葉で書くと簡単ですが、いざ実装しようと思うといかにも面倒臭そうですね。。。

でも pcase を使えばご覧の通り。仕様と実装の隔たりを、ほとんどゼロと言えるくらいにまで縮めることができます。

(pcase object
  (`(tr ,_
        (td ,_
            (a ((href . ,href)) ,text))
        (td ((class . "pkg-synopsis"))
            ,(pred stringp)))
   (cons (cons text href) acc))
  ...

若干補足すると、サニタイズ済みなので ,(pred stringp) の部分は ,_ でも良いのですが、『文字列が置かれていること』という意図を強調するために pred を使っています。このへんは好みですね。

例2:可変パターン

固定のパターンにマッチさせるのは確かに簡単です。でも可変の場合はどうか?

基本的には、コンスセルとしてマッチさせることになります。

(pcase '(1 2 3)
  (`(,_ . ,rest)
   rest))
;; ===> (2 3)

同様に、CHILDREN 部だけ束縛したいケースは (a b c) == (a . (b . c)) なので、

(pcase '(p nil
           "paragraph"
           (br nil)
           (a ((href . "abc")) "text"))
  (`(p . (,_ . ,children))
   children))
;; ===> ("paragraph" (br nil) (a ((href . "abc")) "text"))

ですね。

類似した例で、属性リストが可変だったり、パターンの出現位置を事前に固定できない場合もあります。そんな時でも、

(pcase '(a ((href . "foo.html")
            (rel . "bookmark"))
           "link text")
  (`(a ,(pred (member '(rel . "bookmark")))
       ,text)
   text))
;; ===> "link text"

と堅牢に書くことができます。

例3:一時変数

もしパターンの中で込み入った変数束縛が必要なら let が使えます。let は常に non-nil と評価されます(nil を束縛した場合でも)。

(pcase '(a ((href . "foo.html")
            (rel . "bookmark"))
           "link text")
  (`(a ,(and attr
             (pred (member '(rel . "bookmark")))
             (let href (cdr (assq 'href attr))))
       ,text)
   (cons text href)))
;; ===> ("link text" . "foo.html")

この例では、(rel . "bookmark") が現れるリンクの href を抜き出しています。老婆心ながら、(and attr...attr は変数束縛です。その値は、この位置で捕捉した連想リストになります。

例4:否定

pcase のドキュメントには記述がありませんが、Emacs 24.5 に付属の pcase では組み込みの (not OBJECT) として扱われるようです。

(pcase 'abc
  ((not stringp)
   'not-string)
  ((not atom)
   'not-atom)
  (_
   'unknown))
;; ===> unknown

(pcase 'abc
  ((not (pred stringp))
   'not-string)
  ((not (pred atom))
   'not-atom)
  (_
   'unknown))
;; ===> not-string

番外編:init.el で使う

elisp初心者がelispを触るとdolistを使いたがる を見てたらどうにもムズムズしてしまったので、pcase を使って書いてみました。

(let ((mode-specific-keybind-list
       '(("text-mode" text-mode-hook text-mode-map
          (("C-M-i" dabbrev-expand)))
         ("tex-mode" latex-mode-hook latex-mode-map
          (("<M-return>" latex-insert-item)
           ("C-c p p" exopen-buffer-pdffile)
           ("C-c p d" exopen-buffer-dvifile)
           ("C-c C-c" comment-region)))
         ("lisp-mode" emacs-lisp-mode-hook lisp-mode-shared-map
          (("<M-return>" completion-at-point)))
         ("mediawiki" mediawiki-mode-hook mediawiki-mode-map
          (("C-x C-s" save-buffer))))))
  (dolist (config mode-specific-keybind-list)
    (pcase config
      (`(,lib ,hook ,map
              ,keybind-list)
       (let ((func-init-keybind
              (intern (format "my-init-%s-keybind" map))))
         (with-eval-after-load lib
           (fset func-init-keybind
                 `(lambda ()
                    (dolist (keybind ',keybind-list)
                      (pcase keybind
                        (`(,key ,(and (pred functionp)
                                      func))
                         (define-key ,map (kbd key) func))
                        (`(,_ ,func)
                         (message "Warning: in setting %s, function `%s' is not defined."
                                  ',map func))))))
           `(add-hook ',hook ',func-init-keybind)))))))

こんな書き方もあるんだなーということで参考にしてもらえれば。

おわり

長々とお付き合いいただき、ありがとうございました。

pcase だけに絞ったエントリのほうが良かったかもと思いつつ、つい欲張ってモリモリ書いてしまいました。わかりずらいところなどあれば加筆しますので、ご指摘下さい。スクレイピングに限らずいろんなところで活用できそうな pcase の楽しさ・便利さを少しでも共有できたらなと。

たぶん来年は「それ、pcase で書けるよ」という、かわいがりエントリが盛大に流行る予定なので、今から準備しておくときっといいことあります(嘘です)。

それでは皆様、良いお年を!

21
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?