Common Lisp でウェブスクレイピングを行うための道具立てはかなり揃っており、使っていて快適です。この短いチュートリアルでは、HTTP リクエストを行い、HTML を解析し、内容を抽出し、非同期リクエストを行う方法を見ます。
ここでの単純な課題は、CL Cookbook のインデックスページにあるリンクの一覧を抽出し、それらに到達できるか確認することです。
次のライブラリを使います。
- Dexador - HTTP クライアント (由緒ある Drakma を置き換えることを目指しています)。
- Plump - 壊れた HTML にも対応するマークアップパーサー。
- Lquery - Plump の結果から内容を抽出するための DOM 操作 ライブラリ。
- lparallel - 並列プログラミング用ライブラリ (詳しくはプロセスの節を読んでください)。
始める前に、Quicklisp でこれらのライブラリをインストールしましょう。
(ql:quickload '("dexador" "plump" "lquery" "lparallel"))
HTTP リクエスト
まずは簡単なことからです。Dexador をインストールします。そして get 関数を使います。
(defvar *url* "https://lispcookbook.github.io/cl-cookbook/")
(defvar *request* (dex:get *url*))
これは複数の値を返します。ページの内容全体、戻りコード (200)、レスポンスヘッダ、URI、ストリームです。
"<!DOCTYPE html>
<html lang=\"en\">
<head>
<title>Home – 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 ではオブジェクトを右クリックして inspect できます。
CSS セレクタで解析し、内容を抽出する
HTML を解析して内容を抽出するために lquery を使います。
まず HTML を内部データ構造へ解析する必要があります。
(lquery:$ (initialize <html>)) を使います。
(defvar *parsed-content* (lquery:$ (initialize *request*)))
;; => #<PLUMP-DOM:ROOT {1009EE5FE3}>
lquery は内部で Plump を使います。
次に CSS セレクタでリンクを抽出します。
Note: 関心のある要素の CSS セレクタを知るには、ブラウザでその要素を右クリックし、”Inspect element” を選びます。するとブラウザの開発者ツールのインスペクタが開き、ページの構造を調べられます。
抽出したいリンクは、id が “content” のページ内にあり、通常のリスト要素 (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 の要素のベクトルが得られます。
これらの要素が何であるかを簡単に確認したいところです。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>"
[…]
また、そのテキストとしての内容(HTML 内で利用者に見えるテキスト)を見るには、代わりに (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) を使うとエラーになります。
これでページのリンクの一覧(正確にはベクトル)が得られました。次に、それらに到達できるかを確認して検証する非同期プログラムを書きます。
外部リソース:
非同期リクエスト
この例では、上で得た URL の一覧を取り、それらに到達できるか確認します。これは非同期で行いたいのですが、利点を見るために、まず同期的に実行します。
まずメールアドレスを除外するために少しフィルタリングする必要があります(CSS セレクタでできたかもしれません)。
URL のベクトルを変数に入れます。
(defvar *urls* (lquery:$ *parsed-content* "#content li a" (attr :href)))
“mailto:” で始まる要素を削除します(文字列のページを少し見ると助けになります)。
(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/")
実際には、任意のシーケンス(ベクトルを含む)に対して動く remove-if を書く前に、結果が実際に nil または t になることを確認するため、(map 'vector …) で試しました。
余談ですが、Quicklisp で利用できる “str” ライブラリには便利な starts-with-p 関数があります。したがって次のようにも書けます。
(map 'vector (lambda (it)
(str:starts-with-p "mailto:" it))
*urls*)
ついでに、ウェブスクレイピングに関係ない内容をあまり書きすぎないよう、”http” で始まるリンクだけを対象にします。
(remove-if-not (lambda (it)
(string= it "http" :start1 0 :end1 (length "http")))
*)
よし、この結果を別の変数に入れます。
(defvar *filtered-urls* *)
ここから本題です。各 URL についてリクエストし、戻りコードが 200 であることを確認します。特定のエラーは無視しなければなりません。実際、リクエストはタイムアウトすることも、リダイレクトされることも(これは望みません)、エラーコードを返すこともあります。
実際に近い条件にするため、タイムアウトするリンクを一覧に追加します。
(setf (aref *filtered-urls* 0) "http://lisp.org") ;; :/
エラーを無視し、その場合は nil を返す単純な方法を取ります。すべてうまくいけば、200 であるはずの戻りコードを返します。
冒頭で見たように、dex:get は戻りコードを含む多くの値を返します。ここでは multiple-value-bind で全てを受け取る代わりに、nth-value でこの値だけにアクセスします。また、エラーの場合に nil を返す ignore-errors を使います。handler-case を使って特定のエラー型を扱うこともできます(dexador のドキュメントの例を参照してください)。
(ignore-errors には、エラーが起きたとき、それがどの要素から来たのかを返せないという注意点があります。それでもここでの目的は達成できます。)
(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 秒です。明らかにこの同期的なやり方は効率的ではありません。タイムアウトするリンクに 10 秒待っています。非同期版を書いて測定する時です。
lparallel をインストールし、そのドキュメントを見たところ、並列版の 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)
やりました。タイムアウトするリクエストを 1 つ 10 秒待つため、まだ 10 秒以上かかっています。しかしそれ以外では HTTP リクエストがすべて並列に進むため、ずっと高速です。
到達できない URL を取得し、それらを一覧から削除し、同期と非同期の場合の実行時間を測ってみましょうか。
行うことは、戻りコードだけを返す代わりに、それが有効かを確認して URL を返すことです。
... (if (and status (= 200 status)) it) ...
(defvar *valid-urls* *)
いくつかの nil を含む URL のベクトルが得られます。実のところ、到達できない URL は 1 つだけだと思っていたのですが、もう 1 つ発見しました。あなたがこのチュートリアルを試す前に、修正を push できていることを願います。
では、それらは何でしょうか。ステータスコードは見ましたが URL は見ていません。すべての URL のベクトルと、有効なもののベクトルがあります。単純にそれらを集合として扱い、差分を計算します。これで問題のあるものがわかります。そのためにはベクトルをリストに変換する必要があります。
(set-difference (coerce *filtered-urls* 'list)
(coerce *valid-urls* 'list))
;; => ("http://lisp-lang.org/" "http://www.psg.com/~dlamkins/sl/cover.html")
見つかりました。
ちなみに、有効な URL の一覧を同期的に確認するには、私の環境では実時間で 8.280 秒かかり、非同期では 2.857 秒でした。
CL でのウェブスクレイピングを楽しんでください。
さらに役立つライブラリ:
- VCR を使うと、繰り返し実行できるテストを設定したり、REPL での実験を少し高速化したりするための、記録・再生ユーティリティとして使えます。
- cl-async、 carrier、およびその他の ネットワーク、並列処理、並行処理のライブラリは awesome-cl の一覧、 Cliki、または Quickdocs で探せます。
Page source: ja/web-scraping.md