444 views
## CollegeUnion Wiki [topに戻る](https://hackmd.io/s/rkFqu9xKg) [Common Lispに戻る](https://hackmd.io/s/BycE9MMKl) # Common Lisp ### Webにまじりて情報をとりつつ、万のことに使いけり 大インターネット時代ですね。普段からWebの海を渡っている皆さんなら当たり前に感じることでしょうが、RSS配信していないサイトや天気予報とか、ブラウザでいちいち開いて確認するのはわりと面倒くさい。エンジニアであれば自動化したいところです。最近だと多くの言語で実践されているスクレイピングですが、Common Lispもご多分にもれず、便利なライブラリが存在していたりします。 ## 予備知識 分かる方はすっ飛ばしてください。 ### ライブラリの使い方 Common Lispのライブラリ管理ツールのデファクトスタンダードとして、[Quicklisp](https://www.quicklisp.org/beta/)というものがあります。これはRoswellのセットアップと共に導入されるはずなので、インストール方法は割愛します。 またQuicklispとは別に標準的な管理ツールも存在していて、これはASDFと言います。Quicklispは基本的にWeb上のライブラリのインストーラーとして使い(npmとかpipみたいなもの)、ローカルでの作業はASDFを使います。ASDFが適切に設定されていれば`(require :hoge)`って感じでライブラリが読み込めます。 #### ASDFの設定 ASDFのサーチパスは`$HOME/common-lisp`と`$HOME/.local/share/common-lisp/source`以下になっています。しかしQuicklispが別の場所にインストールしてしまうので、ASDFの設定ファイルにそのディレクトリを追記する形で対策をします。ASDFの設定ファイルは`$HOME/.config/common-lisp/source-registry.conf`で、無ければ新たに作成してください。中身は以下のような感じ。 ```lisp (:source-registry (:tree "~/.roswell/impls/ALL/ALL/quicklisp/dists/quicklisp/software/") (:tree "~/lisp-dev/") :INHERIT-CONFIGURATION) ``` ライブラリのインストール場所(一番上)とは別に`~/lisp-dev`というディレクトリもサーチパスに含めました。下のものほど検索優先度が高いため、自作ライブラリの開発ディレクトリとして使います。ちなみにASDFのサーチパスはQuicklispも利用しています。 #### インストール(+読み込み) ```lisp (ql:quickload :library-name) ``` #### 読み込み ```lisp (require :library-name) ``` #### 名前空間の定義 Common Lispでは名前空間をパッケージという単位で管理しています。ライブラリもパッケージなので、新たに定義したパッケージに他のパッケージを読み込んで利用するという感じです。 ```lisp (require :package1) (require :package2) (defpackage :my-package (:use :cl) (:import-from :package1 :foo :bar)) (in-package :my-package) (foo 1 2 3) (bar "わーい!") (package2:hoge "Welcome to ようこそCommon Lisp!") ``` 上の例だとまず`package1`と`package2`というライブラリを読み込んでいます。そして新たに`my-package`というパッケージを定義し、`package1`から`foo`と`bar`というシンボルをインポートしています。そして`(in-package :my-package)`でそのパッケージに入っています。インポートしたシンボルは名前空間の指定無しに直接利用出来ます。それに対して`package2`の`hoge`というシンボルはインポートしていないので、名前空間越しに利用する形になります。 この辺りがCommon Lisp初心者の躓きやすい大きなポイントなので、分からなくなったらここを振り返ってみて下さい。 ## 必要なライブラリの準備 スクレイピングするには、まずその対象のソースが必要ですね。Webから落としてくるために、HTTPクライアントをインストールします。 ```bash $ ros install dexador ``` 対象ソースを取得した後は、それをパースしてLispオブジェクトとして扱えるようにしなければなりません。HTML/XMLをパースしてくれるライブラリをインストールします。 ```bash $ ros install plump ``` plumpと一緒に使うjQueryセレクタっぽいものもインストールします。 ```bash $ ros install clss ``` 以上でライブラリのインストールは終わりです。 ## スクリプトのテンプレート作成 今回はスクリプトっぽいノリでやっていくので、roswellのスクリプト実行機構を利用するためにそのテンプレートを作成します。単にコマンド一発で作れるので、これは全く面倒くさくありません。 ```bash $ ros init scraping ``` こうするとカレントディレクトリ以下にscraping.rosというファイルが出来ます。これに`chmod +x scraping.ros`とかして実行権限付与すれば、スクリプトを直接実行出来ます。中身はシェルスクリプトっぽくコメントが書かれただけのCommon Lispソースコードなので、特殊なことをしているわけではありません。 ## HTMLを取ってくる まずはWebからHTMLを落とします。と、その前にテンプレートの書き方に従ってライブラリを読み込ませます。先頭の方に`ql:quickload`と書かれたところがあるので、そこにリスト表記でライブラリ名を書いていきます。最後の`uiop`はASDFにくっついてるやつで、OS操作の差異吸収をしてくれるライブラリです。今回は環境変数を扱うために使います。 ```lisp (progn ;;init forms (ros:ensure-asdf) #+quicklisp (ql:quickload '(:dexador :plump :clss :uiop) :silent t)) ``` というわけで早速HTMLを取得してみましょう。今回はPacktの[Free Technology eBooks](https://www.packtpub.com/packt/offers/free-learning)の情報を取得してみることにします。 ```lisp (defparameter +url+ "https://www.packtpub.com/packt/offers/free-learning") (defparameter +html+ (dex:get +url+)) ``` はい、これでHTMLの取得が完了しました。簡単ですね。 ## パースして目的のデータを取ってくる まずパースします。 ```lisp (defparameter +tree+ (plump:parse +html+)) ``` これでplumpの構造体としてHTMLを扱えるようになりました。欲しい部分は以下のdiv要素の中身(a要素が同階層にいっぱい入ってる)です。 ![欲しい部分](https://i.imgur.com/P36JtNU.png) まずこの要素の親要素で、idを持っているものを手作業で見つけます。一番近いのは`id=content`のdiv要素のはずです。とりあえずこの要素を取得してみましょう。 ```lisp (clss:select "#content div" +tree+) => #(#<PLUMP-DOM:ELEMENT div {100546F833}>) ``` 配列で要素が返ってくるのでそれを取り出しつつ、子要素を見てみます。 ```lisp (plump:children (aref * 0)) => #(#<PLUMP-DOM:TEXT-NODE {100546FBE3}> #<PLUMP-DOM:ELEMENT div {1005470833}> #<PLUMP-DOM:TEXT-NODE {1005477553}> #<PLUMP-DOM:ELEMENT div {1005477E43}> #<PLUMP-DOM:TEXT-NODE {1005479853}> #<PLUMP-DOM:ELEMENT a {100547A483}> #<PLUMP-DOM:TEXT-NODE {100547A8A3}> #<PLUMP-DOM:ELEMENT div {100547B0D3}> #<PLUMP-DOM:TEXT-NODE {1005540A33}>) ``` ここでさらっと`*`を使いましたが、これはREPL上では1つ前の評価値を意味します。シェルスクリプトの`$?`みたいな感じです。さて、結果を見てみると、上からdiv・div・a・divと並んでいるのでHTML通りちゃんと取れているみたいです。目的の要素は最後のdiv要素なので、配列から取り出してさらに深く潜っていきます。 ```lisp (plump:children (aref * 7)) => #(#<PLUMP-DOM:TEXT-NODE {100733D493}> #<PLUMP-DOM:ELEMENT div {100733D4B3}> #<PLUMP-DOM:TEXT-NODE {100733D4D3}> #<PLUMP-DOM:ELEMENT div {100733D4F3}> #<PLUMP-DOM:TEXT-NODE {100733D513}> #<PLUMP-DOM:ELEMENT div {100733D533}> #<PLUMP-DOM:TEXT-NODE {100733D553}>) ``` するとdiv要素が3つ出てくるので、上と同様にして・・・ ```lisp (plump:children (aref * 5)) => #(#<PLUMP-DOM:TEXT-NODE {1007354983}> #<PLUMP-DOM:ELEMENT div {10073549A3}> #<PLUMP-DOM:TEXT-NODE {10073549C3}> #<PLUMP-DOM:ELEMENT div {10073388F3}> #<PLUMP-DOM:TEXT-NODE {10073549E3}>) ``` 次で最後です! ```lisp (plump:children (aref * 3)) => #(#<PLUMP-DOM:TEXT-NODE {1007351D83}> #<PLUMP-DOM:ELEMENT a {1007311D53}> #<PLUMP-DOM:TEXT-NODE {1007351DA3}> #<PLUMP-DOM:ELEMENT a {1007311D73}> #<PLUMP-DOM:TEXT-NODE {1007351DC3}> #<PLUMP-DOM:ELEMENT a {1007311D93}> #<PLUMP-DOM:TEXT-NODE {1007351DE3}> #<PLUMP-DOM:ELEMENT a {1007311DB3}> #<PLUMP-DOM:TEXT-NODE {1007351E03}> #<PLUMP-DOM:ELEMENT a {1007311DD3}> #<PLUMP-DOM:TEXT-NODE {1007351E23}> #<PLUMP-DOM:ELEMENT a {1007311DF3}> ... ``` さて、目的のa要素たちを見つけることが出来ました。ただ、これだとTEXT-NODEが邪魔なので、a要素だけを選択します。 ```lisp (clss:select "a" (aref ** 5)) => #(#<PLUMP-DOM:ELEMENT a {1007311D53}> #<PLUMP-DOM:ELEMENT a {1007311D73}> #<PLUMP-DOM:ELEMENT a {1007311D93}> #<PLUMP-DOM:ELEMENT a {1007311DB3}> #<PLUMP-DOM:ELEMENT a {1007311DD3}> #<PLUMP-DOM:ELEMENT a {1007311DF3}> #<PLUMP-DOM:ELEMENT a {1007311E13}> #<PLUMP-DOM:ELEMENT a {1007311E33}> #<PLUMP-DOM:ELEMENT a {1007311E53}> #<PLUMP-DOM:ELEMENT a {1007311E73}> ... ``` 最後に、これをまとめた関数を書いておきましょう。 ```lisp (clss:select "a" (aref (plump:children (aref (plump:children (aref (plump:children (aref (clss:select "#content" +tree+) 0)) 7)) 5)) 3)) ``` キモい!もうちょい良い感じに書きたいので、ちょっとしたマクロを書いてみましょう。 ```lisp (defmacro eref (parent idx) `(aref (plump:children ,parent) ,idx)) ``` これを使うと・・・ ```lisp (defun get-aseq () (clss:select "a" (eref (eref (eref (aref (clss:select "#content" +tree+) 0) 7) 5) 3))) ``` まあ、さっきよりはマシになりましたね。erefを連続適用しているのでここもマクロで良い感じに書けそうですが、これは読者への宿題とする!(言ってみたかった) というわけで、欲しい情報をa要素以下から取り出してみましょう。今回は本の無料配信情報が欲しいので、無料配信されている本の画像URLと準備中の本の配信日付を取得してみます。DOMで言うと以下の画像の要素になります。 ![画像URL](https://i.imgur.com/lMjFbDC.png) ![配信日付](https://i.imgur.com/VIg9s4l.png) まずは画像URLを取り出す関数を定義します。引数は`(get-aseq)`で取得した配列の1つの要素です。 ```lisp (defun get-img (a) (plump:attribute (aref (clss:select "img.bookimage" a) 0) "src")) ``` そして日付を取り出す関数。 ```lisp (defun get-day (a) (plump:text (aref (clss:select "div.fl-notavailable-text" a) 0))) ``` これらの関数を配列のa要素にそれぞれ適用します。配信されたか準備中かの見分けはa要素のhrefを見れば分かるので、これに応じて関数を切り替えるようにしましょう。 ```lisp (defun scrap () (map 'list (lambda (a) (if (string= "#" (plump:attribute a "href")) (cons :unavailable (get-day a)) (cons :available (format nil "https:~A" (get-img a))))) (get-aseq))) (scrap) => ((:AVAILABLE . "https://~~~.jpg") (:AVAILABLE . "https://~~~.jpg") (:AVAILABLE . "https://~~~.jpg") ... (:UNAVAILABLE . "Mar 3rd") (:UNAVAILABLE . "Mar 4th") (:UNAVAILABLE . "Mar 5th") (:UNAVAILABLE . "Mar 6th")) ``` ## 更新をキャッシュする さっきの関数の結果をキャッシュとしてファイルに保存しましょう。コードをデータとして扱えるというLispの特徴を活かして、これをそのまま出力すれば読み出しが可能です。 ```lisp (defun save-cache (data path) (with-open-file (out path :direction :output :if-exists :supersede) (print data out))) (save-cache (scrap) (merge-pathnames "data.cache" (truename "./"))) ``` 読み出しは単にファイルを開いて`read`すれば良いです。簡単ですね。ファイルの有無は`probe-file`で確認しています。 ```lisp (if (probe-file +cache-path+) (with-open-file (in +cache-path+ :direction :input) (read in nil)) nil) => キャッシュの中身がLispオブジェクトとして解釈される ``` ## (おまけ)更新分だけSlackに投げる スクレイピング出来たら、得た情報をどこか目に付くところに通知したいですよね。ここではSlackで知らせるようにしてみます。なので[SlackのWeb APIのテストトークンを取得](https://api.slack.com/web)しておいてください。まずはSlackにメッセージ投稿します。 ```lisp (defun post-message (channel text token) (dex:post "https://slack.com/api/chat.postMessage" :content `(("token" . ,token) ("channel" . ,channel) ("text" . ,text) ("as_user" . "false") ("username" . "PacktReminder")))) (post-message "#general" "Common Lispでテスト投稿" "<トークン>") ``` トークンをソースコードにハードコーディングするのはちょっと忍びないので、環境変数から取得するようにしましょう。 ```lisp (defparameter +token+ (uiop:getenv "SLACK_TOKEN")) ``` 以下のようにスクリプトを実行すれば`+token+`にトークンが代入されるはずです。 ```bash $ SLACK_TOKEN=<トークン> ./scraping.ros ``` さて、更新分だけをSlackに投稿するわけですが、更新された差分を返す関数が必要ですね。先ほどのキャッシュは、`(:unavailable . "Feb 23rd")`のような形式で保存してあるので、このセルの先頭部分が変わっていれば更新されたと言えそうです。なのでこの関数は以下のようになります。 ```lisp (defun diff-cache (old new) (if (/= (length old) (length new)) new (loop for o in old for n in new unless (eq (car o) (car n)) collect n))) ``` 引数はキャッシュ全体と最新の結果`(scrap)`を与えます。リストの長さがそれぞれ異なると困るので、はじめに長さを比較しています。その後は各セル要素の先頭を比較して、同じで無ければ`collect`して新しいリストを作って返します。この`loop`マクロは頻繁に使うので覚えておくと楽です。 よーし、これで後はSlackに投稿するだけ!・・・の前に、画像URLの空白をエンコードしましょう。SlackではURLに空白文字が入るとそこで区切ってしまうので、正しくリンクが貼られないのでした。エンコードは単純に空白文字を`%20`に置き換えれば良いので、これも関数を書いてしまいましょう。 ```lisp (defun encode-space (uri) (coerce (loop for char across uri if (eq char #\Space) collect #\% and collect #\2 and collect #\0 else collect char) 'string)) ``` なかなか無理矢理な実装ですが、まあ良いでしょう。もっと厳密にやりたい方は[cl-ppcre](http://weitz.de/cl-ppcre/)という正規表現ライブラリがあるのでそちらを使ってみてください。 それでは今度こそ、投稿してみたいと思います。キャッシュが最新の場合は何も投稿されないので注意を。 ```lisp (loop for book in (diff +cache+ (scrap)) if (eq (car book) :available) do (post-message "#general" (format nil "Now available new book!~%~A" (encode-space (cdr book))) +token+)) ``` ![スクショ](https://i.imgur.com/KvsNJhR.png) 以上です![サンプルのスクリプトファイルはGithubに上げておきます。](https://github.com/tamamu/cl-playground/blob/master/packt-free/packt.ros)