About

我想要在 Lisp 中实现类似于 Ruby 中 watir 类似的功能: 即控制一个游览器, 这样就可以作为一个简单的, 但是高级的爬虫来爬取网站了.

仔细阅读代码, 并往上找依赖, 可以发现原理是 WebDriver 这个协议. 那么问题就变成了该怎么实现这个协议了.

协议省流版

以我用的游览器 Safari (> 10.1) 为例, 其在后端会开一个 WebDriver 服务:

(defun run-webdriver-server (&optional (port *webdriver-default-port*))
  (uiop:launch-program `("safaridriver" "-p" ,(format nil "~D" port))))

根据 Protocol | Endpoints 中的说明, WebDriver 和 Client 之间通过 HTTP 进行通信, 然后根据不同的方法 (method) 执行动作, 并返回对应的结果. 这里会以 JSON 进行传值.

比如参考 New Session, 这里可以做一个非常简单的 Client 的创建:

(let ((json         (make-hash-table :test 'equal))
      (capabilities (make-hash-table :test 'equal)))
  ;; 这里构建的是一个 JSON Object:
  ;; {
  ;;   "capabilities": { "browserName": "safari" }
  ;; }
  ;; 使用 shasht 库进行 JSON 序列化
  (setf (gethash "browserName" capabilities) "safari")
  (setf (gethash "capabilities" json) capabilities)
  ;; 使用 dexador 库进行 HTTP 请求
  (dex:post (quri:make-uri-http :host *webdriver-default-address*
				:port *webdriver-default-port*
				:path "/session")
	    ;; 发送的 POST 内容为 JSON Object
	    :content (shasht:write-json json nil)))

其返回的结果是一个 JSON Object:

大概的形式类似于:

{
  "value": {
    "sessionId": "一个 session id",
    "capabilities": {
      ...
    }
  }
}

基本上所有的方法的返回值都是一个 JSON Object, 通过 value 项来得到具体的结果. 那么接下来具体有啥方法, 该如何调用, 请看 Endpoint Table.

程序化地调用 method

不难发现, 这些 method 的调用都几乎是一个套路:

  1. POST 方法构建调用所需要的 JSON, GET 和 DELETE 则不需要;
  2. 请求对应的方法, 然后解析返回值;

    注: 这里暂时略去对于错误的处理 (详见 Errors 一节), 后文中将会进行考虑.

  3. 处理返回的 JSON Object.

那么完全可以将这个步骤变成一个固定的程序:

POST 构建 JSON 输入

构建一个输入的 List 到 Hash Table 再到 JSON 的快速输入:

(defmacro make-hash (&body bindings)
  "Make a hash table from `bindings'.

Bindings shall be like:

   ((\"hash-key\" val-expr)
    (...))
"
  (let ((hash-table (gensym "HASH-TABLE")))
    `(let ((,hash-table (make-hash-table :test 'equal)))
       ,@(loop for (key val) in bindings
	       collect `(setf (gethash ,key ,hash-table) ,val))
       ,hash-table)))

解析 JSON Object 的 Hash Table

会发现, 其实大部分的时候就是取 key, 然后得到 value 的过程:

(defmacro gethash* (key+ hash-table)
  "Resursively get keys from `hash-table'.

The `key+' shall be a list of hash-table key. "
  (if (endp key+) hash-table
      `(gethash* ,(rest key+) (gethash ,(first key+) ,hash-table))))

(defmacro let-hash (hash-table bindings &body body)
  "Extract values from `hash-table' from `bindings'.

The `bindings' could be like:

    ((variable \"key-tag\") -> gethash
     (variable (\"key-tag\" \"sub-hash-key-tag\" ...)) -> gethash*
     variable) -> (variable \"variable in camel-case\") -> gethash
"
  `(let ,(loop for binding in bindings
	       collect (cond ((symbolp binding)
			      `(,binding
				(gethash ,(str:camel-case (symbol-name binding))
					 ,hash-table)))
			     ((listp binding)
			      (if (listp (second binding))
				  `(,(first binding)
				    (gethash* ,(second binding) ,hash-table))
				  `(,(first binding)
				    (gethash ,(second binding) ,hash-table))))
			     (t (error "Malform bindings. "))))
     ,@body))

组合在一起

(defun %webdriver-method (method path content)
  (let* ((url (quri:make-uri-http :host *webdriver-default-address*
				  :port *webdriver-default-port*
				  :path path))
	 (res (ecase method
		(:post   (dex:post url :content content))
		(:get    (dex:post url))
		(:delete (dex:delete url)))))
    (gethash "value" (shasht:read-json res))))


(defmacro with-webdriver-post (path json bindings &body body)
  "With WebDriver POST method at `path'.

Post content is `json' -> `make-hash';
Response result will parsed with `bindings' <- `let-hash'.
"
  (let ((object (gensym "OBJECT")))
    `(let ((,object (%webdriver-method
		     :post ,path
		     (shasht:write-json (make-hash ,@json) nil))))
       (let-hash ,object ,bindings ,@body))))

于是一个 POST 的请求就得到了:

(with-webdriver-post "/session"
    (("capabilities" (make-hash ("browserName" "safari"))))
    ((session-id "sessionId"))
  (format t "~A" session-id))

同理还可以实现其他 GET 和 DELETE 的方法.

一些写 Lisp 宏展开的小技巧

(不保真, 毕竟只是个人的体会)

  • 不要试图在一个宏展开中解决所有的问题

    因为用户输入肯定是千奇百怪的, 在一个宏展开里面做 parse 只会让代码变得丑陋不堪, 并且如果这样的展开是合理的, 那么这种展开方式应当可以被自然地拓展到其他的地方, 而只在一个宏展开实现就会导致其他的宏展开中会存在许多重复的代码.

  • 通过把复杂的模式匹配 fallback 到简单的模式匹配上

    比如根据输入的形式为符号, 列表, 亦或是其他, 上层做的只是对模式进行判断, 然后匹配到对应的模式展开宏而不是在上层中直接实现.

  • 如何构造宏展开

    可以在已经有的简单代码上进行修改, 通过删除部分代码和逻辑并替换为程序化生成的部分, 从而实现代码到宏. 这样的缺点往往是动力不强, 毕竟已经实现的代码重新花时间想怎么改, 怎么听都有一种让人提不起来劲的感觉.

    唯一的正反馈估计就是代码更加整洁, 并且可读性大大增强了吧.

  • Layered Programming

    感觉这个点很有意思, 以后可以进一步了解.

重新包装, 将 Session 作为类进行调用

既然已经实现了简单的方法 (method) 调用, 那么就改给这个 WebDriver 提供一个 Lispy 的一个绑定了.

注: 因为我只用少部分的功能, 所以并不会完全实现所有的协议, 不过既然都已经实现了一个通用的调用接口了, 还怕后面实现有困难么?

错误的处理

根据 Errors 中的说明, 在 HTTP 代码为 4xx5xx 的时候, 返回一个 JSON Object 作为错误的值的具体内容:

(define-condition webdriver-error (error)
  ((error-message :initarg :message
		  :reader  error-message)
   (error-code    :initarg :error-code
		  :reader  error-code)
   (stacktrace    :initarg :stacktrace
		  :reader  error-stacktrace)
   (optional-data :initarg :data
		  :reader  error-optional-data)))

后记

其实写到这里我真的很想把这个项目做得比较完整一些, 但是手上出现了更加紧迫的问题: 如何在 Emacs 中使用 Mathematica (或者 Wolfram Engine). 并且最好还能是有一个类似 SLY 这样的 REPL 和自动补全的功能.

另一个并不是很紧迫的想法: 如何用 Emacs + ffmpeg 进行剪视频 (最近打游戏录了一些视频, 寄希望于通过这种低质量视频来换取一些视频平台的创作激励, 给自己来点额外收入… )

虽然到了大四感觉事情少了一些, 实际上却感觉因为太摆烂了所以反而时间少了很多…

(乐, 貌似这篇文章写完了, 但是没发? )