Clojure
スクレイピング

アドベントカレンダーのいいねをClojureでスクレイピングして数える

アドベントカレンダーのいいねをスクレイピングで数えるを読んで、Clojureで書いたらどうなるか試してみました。
HTTP KitとEnliveを使うことで、思っていたよりも手早く簡易スクレイパーを作成することができた為、記録として残します。

結果

  • WEB SCRAPING WITH CLOJUREのおかげで、Clojureでスクレイピングする際の最低限必要な知識をすぐに得ることができた。
  • Enliveで抽出したドキュメント(マップ)から目的の値を取得する方法に、煩わしさを感じる。
  • 普段Rubyでスクレイパーを書いているが、Clojureでも同じくらい手早く書くことができた。
  • マクロで書いた部分の恩恵は若干のタイプ数削減だけであった為、素直に関数定義で良かった。
    変数捕捉を使っている為、シンプルではなくなってしまった。
  • 再帰処理の為にマルチメソッドやパターンマッチを使わない方が見通しが良い。

ルール

元記事と同じ。

  • 対象アドベントカレンダーは株式会社エイチームさん
  • アドベントカレンダーの参加メンバーは除外
  • Qiitaの記事に限る

Clojureのバージョンと使用ライブラリ

project.clj
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [http-kit "2.2.0"]
                 [enlive "1.1.6"]]

コード

core.clj
(ns count-likes.core
  (:gen-class)
  (:require [net.cgrand.enlive-html :as html]
            [org.httpkit.client :as http]
            [clojure.string :as str]
            [clojure.data :as data]
            [clojure.pprint :as pp])
  (:import [java.nio.file Paths]))

(def qiita-url "https://qiita.com")
(def selector {:contents       [:.adventCalendarItem_calendarContent]
               :author         [:.adventCalendarItem_authorIcon]
               :url            [:.adventCalendarItem_entry :a]
               :next-page-link [:.js-next-page-link]
               :likers         [:.UserInfo__name]})

(defn make-qiita-url [path] (str qiita-url path))

(defn get-dom [url]
  (Thread/sleep 1000)
  (html/html-snippet (:body @(http/get url {:insecure? true}))))

(defmacro html-select [] `(html/select ~'dom (~'key selector)))
(defmacro extract-items
  ([]     `(distinct (html-select)))
  ([form] `(distinct (map ~form (html-select)))))

(defmacro extract-item
  ([]     `(first (extract-items)))
  ([form] `(first (extract-items ~form))))

(defn extract [key dom]
  (case key
    :contents       (extract-items)
    :author         (extract-item  #(some-> % :attrs :alt))
    :url            (extract-item  #(some-> % :attrs :href))
    :next-page-link (extract-item  #(some-> % fnext fnext :href))
    :likers         (extract-items #(some-> % :content first :content first))
    ()))

(defn get-articles [contents]
  (map #(array-map
         :author (extract :author %)
         :url    (extract :url %))
       contents))

(defn likers-page [url]
  (get-dom (str url "/likers")))

(defn next-page [path]
  (get-dom (make-qiita-url path)))

(defn collect-likers [likers dom]
  (concat likers (extract :likers dom)))

(defn find-likers [likers dom]
  (let [next-page-link (extract :next-page-link dom)
        current-likers (collect-likers likers dom)]
    (if (nil? next-page-link)
      current-likers
      (find-likers current-likers
                   (next-page next-page-link)))))

(defn remove-ignored-users [users ignored-users]
  (filter (complement nil?)
          (first
           (data/diff
            (set users)
            (set ignored-users)))))

(defn get-likers [article]
  (->> (likers-page (:url article))
       (find-likers ())
       distinct))

(defn valid-likers [article authors]
  (remove-ignored-users
   (get-likers article)
   authors))

(defn count-likes [article authors]
  (if (seq (:url article))
    (assoc article :likes (count (valid-likers article authors)))
    (assoc article :likes 0)))

(defn advent-calendar [year calendar-id]
  (get-dom
   (str qiita-url
        (.toString
         (Paths/get "/advent-calendar"
                    (into-array [year calendar-id]))))))

(defn get-authors [articles]
  (distinct (map #(:author %) articles)))

(defn total [key articles]
  (reduce + (map #(key %) articles)))

(defn articles-by [key val articles]
  (filter #(= (key %) val) articles))

(defn total-likes-by-author [articles]
  (->> (get-authors articles)
       (map
        #(let [articles-by-author (articles-by :author % articles)]
           {:author %
            :articles articles-by-author
            :total-likes (total :likes articles-by-author)}))))

(defn aggregate [dom]
  (let [articles (get-articles (extract :contents dom))]
    (-> (map #(count-likes % (get-authors articles)) articles)
        total-likes-by-author
        (#(array-map :total-likes (total :total-likes %)
                     :articles %)))))

(defn -main [& args]
  (let [[year calendar-id] args]
    (pp/pprint
     (aggregate (advent-calendar year calendar-id)))))

使い方

leiningenで実行する場合は、

$ lein run 2017 a-t-brides

または、uberjarで実行可能jarを作成して実行します。

$ lein uberjar
$ java -jar ./target/uberjar/count_likes-0.1.0-SNAPSHOT-standalone.jar 2017 a-t-brides

参考資料