1. sho0211

    No comment

    sho0211
Changes in body
Source | HTML | Preview
@@ -1,379 +1,519 @@
この記事は[エイチームブライズアドベントカレンダー](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`をつけることで、すべてのいいねしたユーザが参照できます。
あと、ただのスクレイピングでは巷に記事が溢れているので、僕の好きな言語(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
-実装
+goでスクレイピングというとgoqueryが使いやすいと思います。
+pretty printするために[ppモジュール](https://github.com/k0kubun/pp)を使っています
+
+```
+package main
+
+import (
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "time"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/k0kubun/pp"
+)
+
+const (
+ Qiita = "https://qiita.com"
+ AdventCalendar = "advent-calendar"
+ ArticleSelector = ".adventCalendarItem"
+ AuthorSelector = ".adventCalendarItem_author a"
+ EntrySelector = ".adventCalendarItem_entry a"
+ LikersSelector = ".UserInfo__name"
+ NextLinkSelector = ".js-next-page-link"
+)
+
+type User struct {
+ id string
+ articles []*Article
+ likes int
+}
+
+type Article struct {
+ url string
+ author *User
+ likes int
+}
+
+var users []*User
+var uniqUserID map[string]*User
+var allLikers int
+
+func existsUser(s []*User, id string) bool {
+ for _, u := range s {
+ if id == u.id {
+ return true
+ }
+ }
+ return false
+}
+
+func getUser(id string) *User {
+ user, e := uniqUserID[id]
+ if !e {
+ u := User{id: id, articles: make([]*Article, 0), likes: 0}
+ user = &u
+ uniqUserID[id] = user
+ users = append(users, user)
+ }
+ return user
+}
+
+func getArticle(s *goquery.Selection) *Article {
+ authorID := strings.TrimSpace(s.Find(AuthorSelector).Text())
+ author := getUser(authorID)
+ url, _ := s.Find(EntrySelector).Attr("href")
+ article := Article{url: url, author: author}
+ author.articles = append(author.articles, &article)
+ return &article
+}
+
+func getArticles(year string, name string) []*Article {
+ url, _ := url.Parse(Qiita)
+ url.Path = path.Join(url.Path, AdventCalendar, year, name)
+
+ doc, _ := goquery.NewDocument(url.String())
+
+ articles := make([]*Article, 0)
+
+ doc.Find(ArticleSelector).Each(func(_ int, s *goquery.Selection) {
+ article := getArticle(s)
+ articles = append(articles, article)
+ })
+ return articles
+}
+
+func countLikes(likersURL string) int {
+ time.Sleep(1 * time.Second)
+ doc, _ := goquery.NewDocument(likersURL)
+
+ likers := doc.Find(LikersSelector).FilterFunction(func(_ int, s *goquery.Selection) bool {
+ return !existsUser(users, strings.TrimSpace(s.Text()))
+ })
+ likes := likers.Length()
+ nexturl, exists := doc.Find(NextLinkSelector).First().Attr("href")
+ if exists == true {
+ url := Qiita + nexturl
+ likes += countLikes(url)
+ }
+ return likes
+}
+
+func (a *Article) countLikes() {
+ likersURL, _ := url.Parse(a.url)
+ likersURL.Path = path.Join(likersURL.Path, "likers")
+ url := strings.Replace(likersURL.String(), "http:", "https:", 1)
+ a.likes += countLikes(url)
+ a.author.likes += a.likes
+ allLikers += a.likes
+}
+
+func main() {
+ allLikers = 0
+ uniqUserID = make(map[string]*User)
+ articles := getArticles(os.Args[1], os.Args[2])
+ for _, a := range articles {
+ a.countLikes()
+ }
+ pp.Println(users)
+ pp.Println(allLikers)
+}
+```
+
+非常にシンプルになりました。
+
+### らしいところ
+
+今回の処理ではgolangもinterfaceも不要だったので、至ってシンプルな手続き型の処理になったのが、goらしいといえばらしいと思います。
+goではLLでやりがちな「何でもハッシュマップ戦略」をとらないですね。[みんなのGo言語](http://gihyo.jp/book/2016/978-4-7741-8392-3)でも言及されています。構造体を作って詰め込んでいくのが王道です。
+[goは再起が得意ではない](http://ymotongpoo.hatenablog.com/entry/2015/02/23/165341)のですが、どうせsleepするのだから速度的には問題ないと思います。
+uniqueとかinclude的な関数がないので自分で書くのもgoらしいですね。
+言語仕様がシンプルで必要な処理は自分で素直に実装していくあたりに、Cの匂いを感じます。
+deferが好きですが、今回使いどころがありませんでした(笑)
+
+# まとめ
+
+それぞれの言語らしさが出せたかはわかりませんが、こんな実装になりました。
+goが一番短くなったのは予想通りでした。
+「らしく」書こうとすると、OOPでクラスを定義したり、パターンマッチによる定義など、コード量は増える傾向があると思います。
+ただ、多少のコード量の増加に目をつぶっても、それぞれの戦略(OOPで整理する、副作用や条件分岐をなくす)で書きやすくメンテ時の影響が少ないように言語設計がされていると感じます。
# 脚注
[^1]: 言うまでもないことですが、APIを使うときはこういう制限などがよくあるので、最初に抑えておくことが大事です。実は今回、APIで実装後に気づきましたw 業務でそうなると手戻りが発生するので笑えません。
[^2]: ユーティリティクラスを作るのはOOPらしくないと思ってるので、こういった細やかなところが好きです。