Common Lisp 中的网络爬虫工具集相当完整而且使用起来也很不错。在本章的教程中,将介绍如何发出 http 请求、解析 html、提取内容以及进行异步请求。
我们的小任务就是提取 CL Cookbook主页上的链接,并检查这些链接是否都有效。
将会用到以下的库:
- Dexador - HTTP 客户端(其目的是取代 Drakma)
- Plump - 标记文本解析库,可以解析不规则的 HTML
- Lquery - Dom 操作库,从 Plump 结果中提取内容
- lparallel - 并行库(更多参考第20章:进程)
开始之前,先用 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 可以通过在对象上右击来检查对象。
使用 CSS 选择器解析和提取内容
下面接回用到 lquery
来解析网页和提取内容。
首先需要将 html 解析为内部的数据结构,使用(lquery:$ (initialize <html>))
:
(defvar *parsed-content* (lquery:$ (initialize *request*)))
;; => #<PLUMP-DOM:ROOT {1009EE5FE3}>
lquery 会在内部调用 plump.
现在,将通过 CSS 选择器来提取链接。
注: 为了找到需要提取的元素的 CSS 选择器,可以在浏览器中右键,然后选择“检查元素”。这样浏览器就会打开检查器,之后就可以研究页面的结构了。
页面中需要提取的链接内容是在 id
的属性中,并且是在常规的列表元素中(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 文档,然后就知道该使用 (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/")
注: 在 attr
后再使用 (serialize)
会报错。
很好,现在已经得到了网页上链接的列表(好吧,是向量)。接下来将会写个异步的程序来检查这些链接是否有效。
拓展资源:
异步请求
在本章的例子中,将使用上面得到的 url 列表,然后检查这些 url 能否访问。我们是希望使用异步进行这个操作,但要了解异步的好处,首先需要先用同步来执行一遍。
首先,需要进行一些过滤,用来去掉邮件地址(也许可以在 CSS 选择器中进行?)
将 url 的向量保存到一个变量中:
(defvar *urls* (lquery:$ *parsed-content* "#content li a" (attr :href)))
然后移除那些以 “maitlto” 开头的元素:(快速浏览下第3章:字符串会有帮助的)
(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
(对任何序列都适用,包括向量)之前,可以使用 (map 'vector ...)
来检查得到的结果到底是 nil
还是 t
。
顺便说一下,在Quicklisp中有个 cl-strings 库,这个库中有个很方便的函数 starts-with
。所以就可以这样:
(map 'vector (lambda (it) (cl-strings:starts-with it "mailto:")) *urls*)
starts-with
也有选项来设置是否忽略大小写。
在这个过程中,我们只需要考虑以”http”开头的链接,以免编写太多与网络爬虫无关的内容:
(remove-if-not (lambda (it) (string= it "http" :start1 0 :end1 (length "http"))) *) ;; note the remove-if-NOT
好了,现在将上面得到的结果保存到另一个变量中去:
(defvar *filtered-urls* *)
然后回来现实世界中来。对每个 url,我们需要对它发起请求,然后检查响应码是否是 200。其中必须要忽略某些错误。实际上,请求可以超时、重定向(不需要)或是返回错误代码。
考虑到实际情况,将在列表中添加个超时的链接:
(setf (aref *filtered-urls* 0) "http://lisp.org") ;; too bad indeed
在这种情况下将会使用简单的方法来忽略错误并返回 nil
。如果一切顺利的话,将会得到 200 的返回码。
就像在开始时看到的那样,dex:get
会返回很多值,包括响应码。我们只需要使用 nth-value
来得到这个值(而不是使用 multiple-value-bind
得到所有的返回值),同时,我们也将使用 ignore-errors
,该函数在遇到异常是返回 nil。也可以使用 handler-case
来捕获相应的异常(参看 dexador 文档中相应的例子)或是(更好的办法?)使用 handler-bind
来捕获所有的 异常(condition)
(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 秒! 显然同步的方式并不高效。在等待超时是需要等待 10s。时候后编写并衡量异步版本了。
安装完 lparallel
并阅读了其文档后,我们发现并行映射 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)
对咯。这次的时间仍然超过了 10s, 这是因为一个请求有 10s 的超时等待。但在其他的情况下,会并行处理所有的 http 请求,因此速度要快很多。
是否该提取无法访问的 url,从列表中删除它们,并在同步和异步情况下测量执行时间?
我们的做法是:不是只返回返回代码,而是检查它是否有效的,然后返回 url:
... (if (and status (= 200 status)) it) ...
(defvar *valid-urls* *)
这次会得到一个 url 向量,其中有几个是 nil
:实际上,我觉得只有一个无法访问的 url,但我发现还有一个。但愿在你阅读本教程之前,我已经推出了一个修复程序。
但这是什么呢?我们看到了状态代码,但没有看到 url :S 现在又一个向量,其中包含所有 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.280s,而异步只花了 2.857s。
祝你在 CL 中用网络爬虫玩的开心!
更多有用的库: