1. sho0211

    Posted

    sho0211
Changes in title
+アドベントカレンダーのいいねをスクレイピングで数える
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,379 @@
+この記事は[エイチームブライズアドベントカレンダー](https://qiita.com/advent-calendar/2017/a-t-brides)1日目の記事です。
+
+# はじめに
+
+今年はエイチームグループの各社がアドベントカレンダーを実施しています。
+せっかくなのでどこが一番よい記事を提供できたのか、グループ間で競い合いたいというイベントにもなっています。
+というわけで、いいねの数を数えるようにしてみましょう。
+
+# レギュレーション
+## 対象のアドベントカレンダー
+- 我らが[エイチームブライズ](https://qiita.com/advent-calendar/2017/a-t-brides)
+- [エイチーム引越し侍](https://qiita.com/advent-calendar/2017/hikkoshi)
+- [エイチームライフスタイル](https://qiita.com/advent-calendar/2017/ateam-lifestyle)
+
+## 集計対象
+
+アドベントカレンダーに参加しているメンバーのいいねは除く。
+
+## カウント対象の記事
+
+Qiitaに限る
+
+# 実装開始
+
+Qiitaは[API](https://qiita.com/api/v2/docs#いいね)を提供していますが、リクエスト回数に[制限](https://qiita.com/api/v2/docs#利用制限)[^1]があるのでスクレイピングします。
+スクレイピングの対象はアドベントカレンダーのページ、いいねページです。
+いいねページは記事URLに`/likers`をつけることで、すべてのいいねしたユーザが参照でき亜m素。
+あと、ただのスクレイピングでは巷に記事が溢れているので、僕の好きな言語(ruby, elixir, go)で、それぞれの言語らしさあふれる実装にしましょう。
+
+## ruby
+
+スクレイピングといえば定番nokogiriです。
+rubyらしさというところで、きちんとOOPしましょう。
+
+```ruby:count.rb
+require 'nokogiri'
+require 'open-uri'
+require 'json'
+require 'time'
+require 'pp'
+
+module Qiita
+ URL = "https://qiita.com"
+
+ module Helper
+ @@last_get = Time.now - 1
+ def get_document(url)
+ sleep 1 if Time.now < @@last_get + 1 # スクレイピングのマナーとして1秒以上待つ
+ charset = nil
+ url.sub! /^http:/, "https:" # リンクがhttpになっているものがあるのでhttpsにする
+ html = open(url) do |f|
+ charset = f.charset
+ f.read
+ end
+
+ @@last_get = Time.now
+ Nokogiri::HTML.parse(html, nil, charset)
+ end
+ end
+
+ class AdventCalendar
+
+ include Helper
+
+ def initialize(year, name)
+ @url = File.join URL, "advent-calendar", year.to_s, name
+ end
+
+ def get_articles
+ doc = get_document @url
+ days = doc.search ".adventCalendarItem"
+ days.map {|day| Article.new day}
+ end
+ end
+
+ class User
+ attr_reader :id
+ @@users = []
+
+ def self.find_or_create(user_id)
+ @@users.find { |u| u.id == user_id } || User.new(user_id)
+ end
+
+ def ==(other)
+ @id == other.id
+ end
+
+ protected
+
+ def initialize(user_id)
+ @id = user_id
+ @@users << self
+ end
+ end
+
+ class Article
+ attr_reader :author, :url
+
+ def initialize(calendar_day)
+ author_link = calendar_day.search(".adventCalendarItem_author a").first
+ user_id = author_link["href"].split("/").last
+ @author = User.find_or_create(user_id)
+ @url = calendar_day.search(".adventCalendarItem_entry a").first["href"]
+ end
+
+ def likes
+ Like.get(@url)
+ end
+ end
+
+ class Like
+ @@ignored_users = []
+
+ extend Helper
+
+ class <<self
+ def get(article_url)
+ url = File.join(article_url, "likers")
+ get_likers(url)
+ end
+
+ private
+
+ def get_likers(likers_url)
+ doc = get_document likers_url
+ liker_ids = doc.search ".UserInfo__name"
+ likers = liker_ids.map{|l| Like.new(l.text)}
+ next_link = doc.search(".js-next-page-link").first
+ if next_link
+ url = File.join(URL, next_link["href"])
+ likers += get_likers(url)
+ end
+ likers
+ end
+ end
+
+ def self.ignore_users(users)
+ @@ignored_users = users
+ end
+
+ def initialize(user_id)
+ @user = User.find_or_create(user_id)
+ end
+
+ def ignored?
+ @@ignored_users.include? @user
+ end
+
+ def valid?
+ !ignored?
+ end
+ end
+
+end
+
+if $0 == __FILE__
+ year, calendar_name = *ARGV
+ advent_calendar = Qiita::AdventCalendar.new year, calendar_name
+ articles = advent_calendar.get_articles
+ joined_users = articles.map(&:author).uniq
+ Qiita::Like.ignore_users(joined_users)
+
+ all_likes = 0
+ result = {all_likes: 0, by_user:{}}
+
+ articles.each do |article|
+ likes = article.likes.select(&:valid?)
+ result[:all_likes] += likes.size
+ result[:by_user][article.author.id] ||= {articles: [], all_likes: 0}
+ user_result = result[:by_user][article.author.id]
+ user_result[:articles] << { url: article.url, likes: likes.size }
+ user_result[:all_likes] += likes.size
+ end
+
+ pp result
+end
+```
+
+### 使い方
+
+```shell-session
+$ count.rb year advent-calendar-id
+```
+
+昨年の引越し侍アドベントカレンダーではこうなります。
+
+```shell-session
+$ count.rb 2016 a-hikkoshi
+```
+
+### らしいところ
+
+言わずもがなのOOPですね。
+アドベントカレンダー、記事、いいね、ユーザと行った部分でクラスを作って、見通しを良くしたつもりです。
+nokogiriで取得する部分の共通化も単に追い出すのではなくて、Helperのmix-inで対応できるところがrubyらしい[^2]と感じるところです。
+再起でいいねを取得している部分は、あまりに多すぎるとスタックオーバーフローになる恐れがありますが、1ページ20ユーザ表示のため、数万いいねまでは対応可能なので横着しました。
+
+## elixir
+
+様々なライブラリを知っているわけではないですが、HTTPoisonでコンテンツを撮ってきてFlokiで解析するのが定番でしょうか。
+
+```ex
+require Logger
+defmodule Qiita do
+ @qiita "https://qiita.com/"
+
+ def url do
+ @qiita
+ end
+
+ defmodule HTML do
+ def body( %{body: body} ) do
+ body
+ end
+
+ def body(url) do
+ :timer.sleep(1000)
+ String.replace(url, "http://", "https://")
+ |> HTTPoison.get!
+ |> body
+ end
+ end
+
+ defmodule AdventCalendar do
+ @day_selector ".adventCalendarItem"
+ def url(year, name) when is_integer(year) do
+ url(Integer.to_string(year), name)
+ end
+
+ def url(year, name) do
+ Path.join [Qiita.url, "advent-calendar", year, name]
+ end
+
+ def get_days(year, name) do
+ url(year, name)
+ |> HTML.body
+ |> Floki.find(@day_selector)
+ end
+
+ def get_result(user_result) do
+ all_likes = user_result |> Enum.reduce(0, fn (u, acc) -> elem(u, 1).all_likes + acc end )
+ %{all_likes: all_likes, by_user: user_result}
+ end
+
+ end
+
+ defmodule Article do
+ @author_selector ".adventCalendarItem_author a"
+ @entry_selector ".adventCalendarItem_entry a"
+ @liker_selector ".UserInfo__name"
+ @next_link_selector ".js-next-page-link"
+
+ def aggregate(body) do
+ %{ author: author(body), likers: likers(body), url: entry_url(body) }
+ end
+
+ def author(%{author: id}) do
+ id
+ end
+
+ def author(body) do
+ Floki.find(body, @author_selector)
+ |> Enum.at(0)
+ |> Floki.text
+ |> String.strip
+ end
+
+ def entry_url(body) do
+ Floki.find(body, @entry_selector)
+ |> Enum.at(0)
+ |> Floki.attribute("href")
+ end
+
+ def likers_url(entry) do
+ Path.join(entry, "likers")
+ end
+
+ def likers(body) do
+ url = entry_url(body)
+ |> likers_url
+ likers url, []
+ end
+
+ def likers([], current) do
+ current
+ end
+
+ def likers([tag | _], current) do
+ Path.join([Qiita.url, Floki.attribute(tag, "href")])
+ |> likers(current)
+ end
+
+ def likers(url, current) do
+ body = HTML.body(url)
+ users = Floki.find(body, @liker_selector)
+ |> Enum.map(&Floki.text/1)
+ |> Enum.map(&String.strip/1)
+ next_link = Floki.find(body, @next_link_selector)
+ likers(next_link, current ++ users)
+ end
+ end
+
+ defmodule Like do
+ def ignore(%{likers: likers}, ignore_users) do
+ likers -- ignore_users
+ end
+
+ def ignore([], _) do
+ []
+ end
+
+ def ignore([article| tail], ignore_users) do
+ ignored = %{article | likers: ignore(article, ignore_users)}
+ [ignored | ignore(tail, ignore_users)]
+ end
+
+ end
+
+ defmodule User do
+ def aggregate(articles) do
+ articles
+ |> Enum.reduce(%{}, &aggregate/2)
+ end
+
+ def aggregate(article, acc) do
+ current = acc[article.author] || %{articles: [], all_likes: 0}
+
+ info = %{}
+ |> Map.put(:url, article.url)
+ |> Map.put(:likes, Enum.count(article.likers))
+ acc |> Map.put(article.author, %{articles: current.articles ++ [info], all_likes: current.all_likes + info.likes})
+ end
+
+ def joined(articles) do
+ Enum.map(articles, &Qiita.Article.author/1)
+ end
+
+ end
+
+end
+
+defmodule Count do
+ def main([year, name]) do
+ articles = Qiita.AdventCalendar.get_days(year, name)
+ |> Enum.map(&Qiita.Article.aggregate/1)
+ ignore_users = Qiita.User.joined(articles)
+ result = articles
+ |> Qiita.Like.ignore(ignore_users)
+ |> Qiita.User.aggregate
+ |> Qiita.AdventCalendar.get_result
+ Logger.debug inspect(result, pretty: true)
+ end
+end
+```
+
+mix.exsの内容は割愛しますが、一般的な構成です。
+使い方もrubyとさほど変わらず、コンパイルして
+
+```shell-session
+$ count 2016 a-hikkoshi
+```
+
+のようにすればOKです。
+
+### らしいところ
+
+パターンマッチとパイプですね。
+ifを書いたら負け、eachを書いたら負け、という意識で書きました。
+rubyの後に書いたので、rubyの設計に引きずられた部分もあり、無駄なモジュールが増えた感が反省点です。
+パターンマッチでうまくやっていくのは必要ですが、Qiita.Article.likersあたりは無駄に重複させて、逆にメンテ性を損なっています。
+rubyに比べて経験の少なさが目につき、elixir wayはまだ遠いと痛感しました。
+
+## go
+
+実装中
+
+# 脚注
+[^1]: 言うまでもないことですが、APIを使うときはこういう制限などがよくあるので、最初に抑えておくことが大事です。実は今回、APIで実装後に気づきましたw 業務でそうなると手戻りが発生するので笑えません。
+[^2]: ユーティリティクラスを作るのはOOPらしくないと思ってるので、こういった細やかなところが好きです。