Common Lisp 中的网络爬虫工具集相当完整而且使用起来也很不错。在本章的教程中,将介绍如何发出 http 请求、解析 html、提取内容以及进行异步请求。

我们的小任务就是提取 CL Cookbook主页上的链接,并检查这些链接是否都有效。

将会用到以下的库:

  • Dexador - HTTP 客户端(其目的是取代 Drakma)
  • Plump - 标记文本解析库,可以解析不规则的 HTML
  • Lquery - Dom 操作库,从 Plump 结果中提取内容
  • lparallel - 并行库(更多参考第20章:进程

开始之前,先用 Quicklisp 将这些库安装好:

  1. (ql:quickload '(:dexador :plump :lquery :lparallel))

HTTP 请求

很简单的一步。安装 Dexador,然后使用 get 函数:

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

上面代码会返回一个值列表:整个网页的内容、响应代码(200)、响应头、uri 以及对应的流。

  1. "<!DOCTYPE html>
  2. <html lang=\"en\">
  3. <head>
  4. <title>Home &ndash; the Common Lisp Cookbook</title>
  5. […]
  6. "
  7. 200
  8. #<HASH-TABLE :TEST EQUAL :COUNT 19 {1008BF3043}>
  9. #<QURI.URI.HTTP:URI-HTTPS https://lispcookbook.github.io/cl-cookbook/>
  10. #<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>))

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

lquery 会在内部调用 plump.

现在,将通过 CSS 选择器来提取链接。

: 为了找到需要提取的元素的 CSS 选择器,可以在浏览器中右键,然后选择“检查元素”。这样浏览器就会打开检查器,之后就可以研究页面的结构了。

页面中需要提取的链接内容是在 id 的属性中,并且是在常规的列表元素中(li)。

现在来试试:

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

哇哦,成功了。我们得到了包含 plump 元素的向量。

要想简单的检查一些这些元素,就要查看整个 html,这里可以直接在 lquery 那行的末尾加上 (serialize)

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

为了查看元素的文本内容(用户在 html 页面看到的文本),可以使用 (text)

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

好了,现在已经得到了想要的东西了。现在,要获取这些元素的 href,快速的浏览以下 lquery 文档,然后就知道该使用 (attr "some-name") 了。

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

: 在 attr 后再使用 (serialize) 会报错。

很好,现在已经得到了网页上链接的列表(好吧,是向量)。接下来将会写个异步的程序来检查这些链接是否有效。

拓展资源:

异步请求

在本章的例子中,将使用上面得到的 url 列表,然后检查这些 url 能否访问。我们是希望使用异步进行这个操作,但要了解异步的好处,首先需要先用同步来执行一遍。

首先,需要进行一些过滤,用来去掉邮件地址(也许可以在 CSS 选择器中进行?)

将 url 的向量保存到一个变量中:

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

然后移除那些以 “maitlto” 开头的元素:(快速浏览下第3章:字符串会有帮助的)

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

实际上,在写 remove-if(对任何序列都适用,包括向量)之前,可以使用 (map 'vector ...) 来检查得到的结果到底是 nil 还是 t

顺便说一下,在Quicklisp中有个 cl-strings 库,这个库中有个很方便的函数 starts-with。所以就可以这样:

  1. (map 'vector (lambda (it) (cl-strings:starts-with it "mailto:")) *urls*)

starts-with 也有选项来设置是否忽略大小写。

在这个过程中,我们只需要考虑以”http”开头的链接,以免编写太多与网络爬虫无关的内容:

  1. (remove-if-not (lambda (it) (string= it "http" :start1 0 :end1 (length "http"))) *) ;; note the remove-if-NOT

好了,现在将上面得到的结果保存到另一个变量中去:

  1. (defvar *filtered-urls* *)

然后回来现实世界中来。对每个 url,我们需要对它发起请求,然后检查响应码是否是 200。其中必须要忽略某些错误。实际上,请求可以超时、重定向(不需要)或是返回错误代码。

考虑到实际情况,将在列表中添加个超时的链接:

  1. (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 在遇到异常时会输出警告,可以不返回其元素。这样就实现了我们的目的了

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

将会得到:

  1. #(NIL 200 200 200 200 200 200 200 200 200 200 NIL 200 200 200 200 200 200 200
  2. 200 200 200 200)

成功了,但这花了很长时间。怎么精确这个时间呢?可以使用 (time ...)

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

整整 21 秒! 显然同步的方式并不高效。在等待超时是需要等待 10s。时候后编写并衡量异步版本了。

安装完 lparallel 并阅读了其文档后,我们发现并行映射 pmap好像就是我们需要的。而且只需要修改一个单词。现在来试试吧:

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

对咯。这次的时间仍然超过了 10s, 这是因为一个请求有 10s 的超时等待。但在其他的情况下,会并行处理所有的 http 请求,因此速度要快很多。

是否该提取无法访问的 url,从列表中删除它们,并在同步和异步情况下测量执行时间?

我们的做法是:不是只返回返回代码,而是检查它是否有效的,然后返回 url:

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

这次会得到一个 url 向量,其中有几个是 nil:实际上,我觉得只有一个无法访问的 url,但我发现还有一个。但愿在你阅读本教程之前,我已经推出了一个修复程序。

但这是什么呢?我们看到了状态代码,但没有看到 url :S 现在又一个向量,其中包含所有 url,另一个向量包含有效 url。可以简单地把这两个向量当作集合计算差集。只有一点不好的是,必须将向量转换成列表。

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

可以了!

顺便一提,当使用同步来检查 url 时花了 8.280s,而异步只花了 2.857s。

祝你在 CL 中用网络爬虫玩的开心!

更多有用的库:

  • we could use VCR, a store and
    replay utility to set up repeatable tests or to speed up a bit our
    experiments in the REPL.
  • cl-async,
    carrier and others
    network, parallelism and concurrency libraries to see on the
    awesome-cl list,
    Cliki or
    Quickdocs.