The Common Lisp Cookbook – Web Scraping

Table of Contents

The Common Lisp Cookbook – Web Scraping

📢 🎓 ⭐ Learn Common Lisp efficiently in videos, by the Cookbook's main contributor. Learn more.

🖊️ Discover a new Common Lisp and Coalton editor for beginners: mine and a new VSCode extension for Common Lisp: OLIVE.

Common Lisp で web scraping を行うための道具立てはかなり揃っており、使っていて快適です。この短い tutorial では、http request を行い、html を parse し、content を抽出し、非同期 request を行う方法を見ます。

ここでの単純な課題は、CL Cookbook の index page にある link の一覧を抽出し、それらに到達できるか確認することです。

次の library を使います。

始める前に、Quicklisp でこれらの library を install しましょう。

(ql:quickload '("dexador" "plump" "lquery" "lparallel"))

HTTP Requests

まずは簡単なことからです。Dexador を install します。そして get function を使います。

(defvar *url* "https://lispcookbook.github.io/cl-cookbook/")
(defvar *request* (dex:get *url*))

これは複数の値を返します。page content 全体、return code (200)、response header、uri、stream です。

"<!DOCTYPE html>
 <html lang=\"en\">
  <head>
    <title>Home &ndash; the Common Lisp Cookbook</title>
    […]
    "
200
#<HASH-TABLE :TEST EQUAL :COUNT 19 {1008BF3043}>
#<QURI.URI.HTTP:URI-HTTPS https://lispcookbook.github.io/cl-cookbook/>
#<CL+SSL::SSL-STREAM for #<FD-STREAM for "socket 192.168.0.23:34897, peer: 151.101.120.133:443" {100781C133}>>

思い出してください。Slime では object を右クリックして inspect できます。

CSS selector で parse し、content を抽出する

html を parse して content を抽出するために lquery を使います。

まず html を内部 data structure へ parse する必要があります。 (lquery:$ (initialize <html>)) を使います。

(defvar *parsed-content* (lquery:$ (initialize *request*)))
;; => #<PLUMP-DOM:ROOT {1009EE5FE3}>

lquery は内部で Plump を使います。

次に CSS selector で link を抽出します。

Note: 関心のある element の CSS selector を知るには、browser でその element を右クリックし、”Inspect element” を選びます。すると browser の web dev tool の inspector が開き、page structure を調べられます。

抽出したい link は、id が “content” の page 内にあり、通常の list element (li) に含まれています。

試してみましょう。

(lquery:$ *parsed-content* "#content li")
;; => #(#<PLUMP-DOM:ELEMENT li {100B3263A3}> #<PLUMP-DOM:ELEMENT li {100B3263E3}>
;;  #<PLUMP-DOM:ELEMENT li {100B326423}> #<PLUMP-DOM:ELEMENT li {100B326463}>
;;  #<PLUMP-DOM:ELEMENT li {100B3264A3}> #<PLUMP-DOM:ELEMENT li {100B3264E3}>
;;  #<PLUMP-DOM:ELEMENT li {100B326523}> #<PLUMP-DOM:ELEMENT li {100B326563}>
;;  #<PLUMP-DOM:ELEMENT li {100B3265A3}> #<PLUMP-DOM:ELEMENT li {100B3265E3}>
;;  #<PLUMP-DOM:ELEMENT li {100B326623}> #<PLUMP-DOM:ELEMENT li {100B326663}>
;;  […]

うまく動きました。ここでは plump element の vector が得られます。

これらの element が何であるかを簡単に確認したいところです。html 全体を見るには、lquery の行を (serialize) で終えます。

(lquery:$  *parsed-content* "#content li" (serialize))
#("<li><a href=\"license.html\">License</a></li>"
  "<li><a href=\"getting-started.html\">Getting started</a></li>"
  "<li><a href=\"editor-support.html\">Editor support</a></li>"
  […]

また、その textual content (html 内で user に見える text) を見るには、代わりに (text) を使えます。

(lquery:$  *parsed-content* "#content" (text))
#("License" "Editor support" "Strings" "Dates and Times" "Hash Tables"
  "Pattern Matching / Regular Expressions" "Functions" "Loop" "Input/Output"
  "Files and Directories" "Packages" "Macros and Backquote"
  "CLOS (the Common Lisp Object System)" "Sockets" "Interfacing with your OS"
  "Foreign Function Interfaces" "Threads" "Defining Systems"
  […]
  "Pascal Costanza’s Highly Opinionated Guide to Lisp"
  "Loving Lisp - the Savy Programmer’s Secret Weapon by Mark Watson"
  "FranzInc, a company selling Common Lisp and Graph Database solutions.")

よさそうです。必要なものを操作できていることがわかります。次に href を取得するには、lquery の doc を少し見れば (attr "some-name") を使えばよいことがわかります。

(lquery:$  *parsed-content* "#content li a" (attr :href))
;; => #("license.html" "editor-support.html" "strings.html" "dates_and_times.html"
;;  "hashes.html" "pattern_matching.html" "functions.html" "loop.html" "io.html"
;;  "files.html" "packages.html" "macros.html"
;;  "/cl-cookbook/clos-tutorial/index.html" "os.html" "ffi.html"
;;  "process.html" "systems.html" "win32.html" "testing.html" "misc.html"
;;  […]
;;  "http://www.nicklevine.org/declarative/lectures/"
;;  "http://www.p-cos.net/lisp/guide.html" "https://leanpub.com/lovinglisp/"
;;  "https://franz.com/")

Note: attr の後に (serialize) を使うと error になります。

これで page の link の list (正確には vector) が得られました。次に、それらに到達できるかを確認して検証する async program を書きます。

外部 resource:

Async requests

この例では、上で得た url の list を取り、それらに到達できるか確認します。これは非同期で行いたいのですが、利点を見るために、まず同期的に実行します。

まず email address を除外するために少し filter する必要があります (CSS selector でできたかもしれません)。

url の vector を variable に入れます。

(defvar *urls* (lquery:$  *parsed-content* "#content li a" (attr :href)))

“mailto:” で始まる element を削除します (strings page を少し見ると助けになります)。

(remove-if (lambda (it)
              (string= it "mailto:" :start1 0
                                    :end1 (length "mailto:")))
           *urls*)
;; => #("license.html" "editor-support.html" "strings.html" "dates_and_times.html"
;;  […]
;;  "process.html" "systems.html" "win32.html" "testing.html" "misc.html"
;;  "license.html" "http://lisp-lang.org/"
;;  "https://github.com/CodyReichert/awesome-cl"
;;  "http://www.lispworks.com/documentation/HyperSpec/Front/index.htm"
;;  […]
;;  "https://franz.com/")

実際には、任意の sequence (vector を含む) に対して動く remove-if を書く前に、結果が実際に nil または t になることを確認するため、(map 'vector …) で試しました。

余談ですが、Quicklisp で利用できる “str” library には便利な starts-with-p function があります。したがって次のようにも書けます。

(map 'vector (lambda (it)
                (str:starts-with-p "mailto:" it))
             *urls*)

ついでに、web scraping に関係ない内容をあまり書きすぎないよう、”http” で始まる link だけを対象にします。

(remove-if-not (lambda (it)
                 (string= it "http" :start1 0 :end1 (length "http")))
               *)

よし、この結果を別の variable に入れます。

(defvar *filtered-urls* *)

ここから本題です。各 url について request し、return code が 200 であることを確認します。特定の error は無視しなければなりません。実際、request は timeout することも、redirect されることも (これは望みません)、error code を返すこともあります。

実際に近い条件にするため、timeout する link を list に追加します。

(setf (aref *filtered-urls* 0) "http://lisp.org")  ;; :/

error を無視し、その場合は nil を返す単純な方法を取ります。すべてうまくいけば、200 であるはずの return code を返します。

冒頭で見たように、dex:get は return code を含む多くの値を返します。ここでは multiple-value-bind で全てを受け取る代わりに、nth-value でこの値だけに access します。また、error の場合に nil を返す ignore-errors を使います。handler-case を使って specific error type を扱うこともできます (dexador の documentation の例を参照してください)。

(ignore-errors には、error が起きたとき、それがどの element から来たのかを返せないという注意点があります。それでもここでの目的は達成できます。)

(map 'vector (lambda (it)
  (ignore-errors
    (nth-value 1 (dex:get it))))
  *filtered-urls*)

次が得られます。

#(NIL 200 200 200 200 200 200 200 200 200 200 NIL 200 200 200 200 200 200 200
  200 200 200 200)

動きますが、非常に長い時間がかかりました。正確にはどれくらいでしょうか。(time …) で測ります。

Evaluation took:
  21.554 seconds of real time
  0.188000 seconds of total run time (0.172000 user, 0.016000 system)
  0.87% CPU
  55,912,081,589 processor cycles
  9,279,664 bytes consed

21 秒です。明らかにこの同期 method は効率的ではありません。timeout する link に 10 秒待っています。async version を書いて測定する時です。

lparallel を install し、その documentation を見たところ、parallel map の pmap が欲しいものに見えます。しかも変更は一語だけです。試してみましょう。

(time (lparallel:pmap 'vector
  (lambda (it)
    (ignore-errors
      (let ((status (nth-value 1 (dex:get it)))) status)))
  *filtered-urls*)
;;  Evaluation took:
;;  11.584 seconds of real time
;;  0.156000 seconds of total run time (0.136000 user, 0.020000 system)
;;  1.35% CPU
;;  30,050,475,879 processor cycles
;;  7,241,616 bytes consed
;;
;;#(NIL 200 200 200 200 200 200 200 200 200 200 NIL 200 200 200 200 200 200 200
;;  200 200 200 200)

やりました。timeout する request を 1 つ 10 秒待つため、まだ 10 秒以上かかっています。しかしそれ以外では http request がすべて parallel に進むため、ずっと高速です。

到達できない url を取得し、それらを list から削除し、sync と async の場合の実行時間を測ってみましょうか。

行うことは、return code だけを返す代わりに、それが valid かを確認して url を返すことです。

... (if (and status (= 200 status)) it) ...
(defvar *valid-urls* *)

いくつかの nil を含む url の vector が得られます。実のところ、到達できない url は 1 つだけだと思っていたのですが、もう 1 つ発見しました。あなたがこの tutorial を試す前に、修正を push できていることを願います。

では、それらは何でしょうか。status code は見ましたが url は見ていません。すべての url の vector と、valid なものの vector があります。単純にそれらを set として扱い、差分を計算します。これで悪いものがわかります。そのためには vector を list に変換する必要があります。

(set-difference (coerce *filtered-urls* 'list)
                (coerce *valid-urls* 'list))
;; => ("http://lisp-lang.org/" "http://www.psg.com/~dlamkins/sl/cover.html")

見つかりました。

ちなみに、valid url の list を同期的に確認するには、私の環境では real time で 8.280 秒かかり、async では 2.857 秒でした。

CL での web scraping を楽しんでください。

さらに役立つ library:

Page source: ja/web-scraping.md

T
O
C