Ruby
Go
AdventCalendar
スクレイピング
Elixir

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

この記事はエイチームブライズアドベントカレンダー1日目の記事です。

はじめに

今年はエイチームグループの各社がアドベントカレンダーを実施しています。
せっかくなのでどこが一番よい記事を提供できたのか、グループ間で競い合いたいというイベントにもなっています。
というわけで、いいねの数を数えるようにしてみましょう。

レギュレーション

対象のアドベントカレンダー

集計対象

アドベントカレンダーに参加しているメンバーのいいねは除く。
※このため、アドベントカレンダーにある「総いいね数」は使えない

カウント対象の記事

Qiitaに限る

実装開始

QiitaはAPIを提供していますが、リクエスト回数に制限1があるのでスクレイピングします。
スクレイピングの対象はアドベントカレンダーのページ、いいねページです。
いいねページは記事URLに/likersをつけることで、すべてのいいねしたユーザが参照できます。
あと、ただのスクレイピングでは巷に記事が溢れているので、僕の好きな言語(ruby, elixir, go)で、それぞれの言語らしさあふれる実装にしましょう。

ruby

スクレイピングといえば定番nokogiriです。
rubyらしさというところで、きちんとOOPしましょう。

count_likes.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:"
      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で解析するのが定番でしょうか。

count_likes.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とさほど変わらず、コンパイルして

$ count_likes 2016 a-hikkoshi

のようにすればOKです。

らしいところ

パターンマッチとパイプですね。
ifを書いたら負け、eachを書いたら負け、という意識で書きました。
rubyの後に書いたので、rubyの設計に引きずられた部分もあり、無駄なモジュールが増えた感が反省点です。
パターンマッチでうまくやっていくのは必要ですが、Qiita.Article.likersあたりは無駄に重複させて、逆にメンテ性を損なっています。
rubyに比べて経験の少なさが目につき、elixir wayはまだ遠いと痛感しました。

go

goでスクレイピングというとgoqueryが使いやすいと思います。
pretty printするためにppモジュールを使っています

count_likes.go
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記事を分析するそうです。
楽しみにしてください!

脚注


  1. 言うまでもないことですが、APIを使うときはこういう制限などがよくあるので、最初に抑えておくことが大事です。実は今回、APIで実装後に気づきましたw 業務でそうなると手戻りが発生するので笑えません。 

  2. ユーティリティクラスを作るのはOOPらしくないと思ってるので、こういった細やかなところが好きです。