この記事はエイチームブライズアドベントカレンダー1日目の記事です。
はじめに
今年はエイチームグループの各社がアドベントカレンダーを実施しています。
せっかくなのでどこが一番よい記事を提供できたのか、グループ間で競い合いたいというイベントにもなっています。
というわけで、いいねの数を数えるようにしてみましょう。
レギュレーション
対象のアドベントカレンダー
集計対象
アドベントカレンダーに参加しているメンバーのいいねは除く。
※このため、アドベントカレンダーにある「総いいね数」は使えない
カウント対象の記事
Qiitaに限る
実装開始
QiitaはAPIを提供していますが、リクエスト回数に制限1があるのでスクレイピングします。
スクレイピングの対象はアドベントカレンダーのページ、いいねページです。
いいねページは記事URLに/likers
をつけることで、すべてのいいねしたユーザが参照できます。
あと、ただのスクレイピングでは巷に記事が溢れているので、僕の好きな言語(ruby, elixir, go)で、それぞれの言語らしさあふれる実装にしましょう。
ruby
スクレイピングといえば定番nokogiriです。
rubyらしさというところで、きちんとOOPしましょう。
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:"
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.text.strip
@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
def ignore_users(users)
@@ignored_users = users
end
private
def get_likers(likers_url)
doc = get_document likers_url
liker_ids = doc.search ".UserInfo__name"
likers = liker_ids.map{|l| self.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 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
使い方
$ count_likes.rb year advent-calendar-id
昨年の引越し侍アドベントカレンダーではこうなります。
$ count_likes.rb 2016 a-hikkoshi
らしいところ
言わずもがなのOOPですね。
アドベントカレンダー、記事、いいね、ユーザと行った部分でクラスを作って、見通しを良くしたつもりです。
nokogiriで取得する部分の共通化も単に追い出すのではなくて、Helperのmix-inで対応できるところがrubyらしい2と感じるところです。
再起でいいねを取得している部分は、あまりに多すぎるとスタックオーバーフローになる恐れがありますが、1ページ20ユーザ表示のため、数万いいねまでは対応可能なので横着しました。
elixir
様々なライブラリを知っているわけではないですが、HTTPoisonでコンテンツを撮ってきてFlokiで解析するのが定番でしょうか。
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とさほど変わらず、コンパイルして
$ count_likes 2016 a-hikkoshi
のようにすればOKです。
らしいところ
パターンマッチとパイプですね。
ifを書いたら負け、eachを書いたら負け、という意識で書きました。
rubyの後に書いたので、rubyの設計に引きずられた部分もあり、無駄なモジュールが増えた感が反省点です。
パターンマッチでうまくやっていくのは必要ですが、Qiita.Article.likersあたりは無駄に重複させて、逆にメンテ性を損なっています。
rubyに比べて経験の少なさが目につき、elixir wayはまだ遠いと痛感しました。
go
goでスクレイピングというとgoqueryが使いやすいと思います。
pretty printするために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)
}
非常にシンプルになりました。
らしいところ
今回の処理ではgoroutineもinterfaceも不要だったので、至ってシンプルな手続き型の処理になったのが、goらしいといえばらしいと思います。
goではLLでやりがちな「何でもハッシュマップ戦略」をとらないですね。みんなのGo言語でも言及されています。構造体を作って詰め込んでいくのが王道です。
goは再起が得意ではないのですが、どうせsleepするのだから速度的には問題ないと思います。
uniqueとかinclude/contains的な関数がないので自分で書くのもgoらしいですね。
言語仕様がシンプルで必要な処理は自分で素直に実装していくあたりに、Cの匂いを感じます。
deferが好きですが、今回使いどころがありませんでした(笑)
まとめ
それぞれの言語らしさが出せたかはわかりませんが、こんな実装になりました。
goが一番短くなったのは予想通りでした。
「らしく」書こうとすると、クラスを定義したり、パターンマッチによる定義など、コード量は増える傾向があると思います。
ただ、多少のコード量の増加に目をつぶっても、それぞれの戦略(OOPで整理する、副作用や条件分岐をなくす)で書きやすくメンテ時の影響が少ないように言語設計がされていると感じます。
明日
明日は弊社のホープ@phigasuiが、機械学習の知見を生かしてQiita記事を分析するそうです。
楽しみにしてください!