PHP
MySQL
AdventCalendar
スクレイピング
QiitaAPI

テーマごとに「Qiita Advent Calendar」への投稿のいいね数を集計してランキングを表示する

この記事は、ファーストサーバ Advent Calendar 2017 19日目の記事です。


「ファーストサーバ Advent Calendar」への参加は、2年目となる ishikun です。

毎年のことながら、「これをやろうかな」「あれをやろうかな」と考えているうちに自分の番がやってきてしまいました。

今回は、テーマごとに「Qiita Advent Calendar」のいいね数を集計してランキングを表示することに挑戦します。

※ 「テーマ」は、Qiita で作成された「Advent Calendar」の単位(例:「ファーストサーバ Advent Calendar」という単位)のことです。

きっかけと目的

昨年の Advent Calendar ファーストサーバ Advent Calendar 2016 の投稿の中で、私の投稿した記事 が思いがけずいいね数が伸び、会社の表彰制度にて「YYY(よーやったやん)賞」をいただきました。(読んでいただきありがとうございました!)

それぞれの Advent Calendar ごとのいいね合計数は Advent Calendar ランキング で表示されていますが、記事ごとのいいね数はランキング表示されていないようです。

きっと昨年はこの表彰のために、弊社の担当者が Advent Calendar の記事を地道に1つずつブラウザで開き、いいね数を集計したと推察されます。

また、Qiita は「プログラミングに関する知識を記録・共有するサービス」ですから、企業の Advent Calendar においては、自社外のエンジニア(世間)が自社のエンジニアが持っているどのような知識に興味を持っているかということを知る指標になるかもしれません。

以上のような理由により、パッといいね数がわかるようなページを作ってみようと思いました。

使用する言語やミドルウェア

今回は、以下の3つの個人的かつ単純な理由により PHPMySQL を使用します。

  1. 直近の開発案件で使用している技術が PHP と MySQL のため
  2. 直近の開発案件でもスクレイピングに使用する PHP の DOM 拡張モジュール を使用したため
  3. PHP と MySQL は、弊社サービスであるクラウド型レンタルサーバー Zenlogic で動作するため

しくみ

以下のようなしくみで動作するように実装していきます。

しくみ

ただし、Qiita Advent Calendar は、投稿公開時点で約600ありますので、取得する Calendar は制限します。
いいね数ランキング閲覧ページを最初に開いた1日後から取得するようにします。

実装

思っていたよりもファイル数が多くなってしまったので、どうやって取得しているかをお伝えします。

その他の部分は、GitHub にソースファイルをアップロードしておりますので、気になる方はそちらもご覧ください。1

スクレイピング

今回は、Advent Calendar のテーマを取得するために、HTML からスクレイピングを行い、その後、テーマごとのカレンダーへの投稿を取得するために、購読フィード (Atom)からスクレイピングを行いました。

なぜスクレイピングを使うのかというと、単純に API で取得できないからです。

ただし、スクレイピングは、気をつけないといけないことが多くあります。よく考えて使用したほうがよさそうです。

いずれも PHP の DOMDocument クラスと DOMXpath クラスを使いました。

HTMLのスクレイピング

以下のコードで、URL から HTML を取得し、Xpath にて取得したい要素を指定して、情報を取得しています。

当初、 $dom->loadHTML$dom->loadHTMLFile で直接URLを指定して取得したのですが、 meta タグで指定された charset でエンコードを判断しており、 HTML5 の <meta charset="UTF-8"> は認識できないことがわかったため、 file_get_contents で取得したあと、
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> に置換しています。

Xpath の使い方は、最下部に記載させていただいた参考資料がよくまとまっており、理解しやすかったです。

class/qac_calendar.php
    /**
     * スクレイピングして差分を作成する
     * @param object $qac_year
     */
    public function createByScraping( $qac_year ) {
        $url = $qac_year->url . $qac_year->index_path;

        // ページ数の取得
        $html = file_get_contents( $url );
        $html = preg_replace('/<meta charset="(.*)"/i', '<meta http-equiv="Content-Type" content="text/html; charset=$1"', $html);
        $dom = new DOMDocument;
        @$dom->loadHTML($html);
        $xpath = new DOMXpath($dom);
        if ( preg_match( '/\?page=(\d+)$/', $xpath->query( '//ul[@class="pagination"]/li[last()]/a/@href' )[0]->value, $matches ) ) {
            $max_page = (int) $matches[1];
        } else {
            return false;
        }

        // 全ページスクレイピング
        $calendars = array();
        for ( $i = 1; $i <= $max_page; $i++ ) {
            $target_url = $url . '?page=' . $i;
            $html = file_get_contents( $target_url );
            $html = preg_replace( '/<meta charset="(.*)"/i', '<meta http-equiv="Content-Type" content="text/html; charset=$1"', $html );
            @$dom->loadHTML( $html );
            $xpath = new DOMXpath( $dom );
            foreach ( $xpath->query( '//td/a' ) as $node ) {
                $calendars[] = array(
                    'name' => $node->textContent,
                    'url'  => $xpath->evaluate( 'concat("https://qiita.com", @href)', $node ),
                );
            }
            sleep(2);
        }
        krsort( $calendars );

        // データベースから全件取得
        $db_calendars = $this->all( array( 'year_id' => $qac_year->id ) );

        // 一致するのものがなければデータ作成
        foreach ( $calendars as $calendar ) {
            foreach ( $db_calendars as $db_calendar ) {
                if ( $db_calendar->name === $calendar['name'] && $db_calendar->url === $calendar['url'] ) {
                    continue 2;
                }
            }
            $calendar['year_id'] = $qac_year->id;
            if ( ! $this->create( $calendar ) ) {
                return false;
            }
        }

        return true;
    }

XMLのスクレイピング / API

SimpleXML を使ってもよいのですが、HTML のスクレイピングを DOM で行ったので、同じ方法で行けるだろうと思い、DOM を使いました。

HTML では、loadHTML を使いましたが、XML ではお気づきの通り loadXML を使うだけです。
と、思いきや実は、ネームスペースが含まれていると、ネームスペースを優先してしまい、ネームスペースがついていないタグが Xpath で取得できなくなります。

<feed xml:lang="ja-JP" xmlns="http://www.w3.org/2005/Atom" xmlns:re="http://purl.org/atompub/rank/1.0">

ですので、<feed> に置換することによって、正常にパースすることができました。

/class/qac_item.php
    /**
     * スクレイピングして差分を作成する
     * @param object $qac_calendar
     */
    public function createByScraping( $qac_calendar ) {
        $qac_year = QacYear::getInstance();
        $year = $qac_year->find( $qac_calendar->year_id )->year;
        $target_url = $qac_calendar->url . '/feed';

        $xml = file_get_contents( $target_url );
        $xml = preg_replace( '/<feed.*>/i', '<feed>', $xml );
        $dom = new DOMDocument;
        @$dom->loadXML( $xml );
        $xpath = new DOMXpath( $dom );

        $items = array();
        foreach ( $xpath->query( '//entry' ) as $node ) {
            $url   = $xpath->evaluate( 'string(link/@href)', $node );
            if ( preg_match( '/^https:\/\/qiita.com/i', $url ) ) {
                $title = $xpath->evaluate( 'string(title)', $node );
                $is_qiita = true;
            } else {
                $title = $xpath->evaluate( 'string(content)', $node );
                $is_qiita = false;
            }

            if ( $is_qiita ) {
                if ( preg_match( '|https://qiita.com/.+?/items/([0-9a-f]+?)|i', $url, $matches ) ) {
                    $item_id = $matches[1];
                    $context = stream_context_create(
                        array(
                            'http' => array(
                                'method' => 'GET',
                                'header' => 'Authorization: Bearer ' . QIITA_ACCESS_TOKEN . "\n",
                            ),
                        )
                    );
                    $response = file_get_contents( "https://qiita.com/api/v2/items/{$item_id}", false, $context );
                    $json_response = json_decode( $response );
                    $likes_count = $json_response->likes_count;
                }
            } else {
                $likes_count = 0;
            }

            $items[] = array(
                'calendar_id' => $qac_calendar->id,
                'title'       => $title,
                'url'         => $url,
                'date'        => date('Y-m-d', strtotime( $year . '-12-' . $xpath->evaluate( 'string(rank)', $node ) ) ),
                'is_qiita'    => $is_qiita,
                'author'      => $xpath->evaluate( 'string(author/name)', $node ),
                'likes_count' => $likes_count,
            );

            sleep(1);
        }

        krsort($items);

        foreach ($items as $item) {
            $db_item = $this->find_by( array( 'calendar_id' => $item['calendar_id'], 'date' => $item['date']) );
            if ( $db_item !== false ) {
                $this->update( $item, array( 'id' => $db_item->id ) );
            } else {
                $this->create( $item );
            }
        }
    }

なお、いいね数を取得する部分に Qiita API v2 を使っています。

アクセストークンを渡さなくても取得できるのですが、Qiita API v2 のドキュメントによると、

認証している状態ではユーザごとに1時間に1000回まで、認証していない状態ではIPアドレスごとに1時間に60回までリクエストを受け付けます。

とのことなので、アクセストークンを渡すようにしています。

レスポンスは、JSON 形式ですので、 json_decode するだけですね :smile:

設置

以下に設置してみました。きっと正常に動いてくれることと思います :pray_tone1:
https://2017.qac.ishikun.me/firstserver

上記URL の firstserver の部分を Qiita Advent Calendar を作成するときに指定したURL (https://qiita.com/advent-calendar/2017/firstserverfirstserver の部分) に変更すると、ファーストサーバ Advent Calendar 以外もランキング取得できちゃいます :notes:

まとめ

公式的に提供されていないものでも、スクレイピングやAPIを使うと、自分が欲しい機能ができちゃうこともあります。

ただし、スクレイピングは、APIのように制限がかかっていないかもしれないので、短時間に多くの回数実行してしまうと、相手側のサーバーに負荷がかかってしまいます。
常識の範囲内で実行すべきですし、利用規約で禁じているサイトもあるので、気をつけないといけないですね!

参考資料


  1. 本来ならリファクタリングすべき部分が多数あります… :bow_tone1: