読者です 読者をやめる 読者になる 読者になる

Pythonista+スクレイピング で Advent Calendar 管理

Python Pythonista

Python Advent Calendar 2016 その2 の7日目の記事です。
qiita.com

★ はじめに

Advent Calendarのサービスの有名所と言えば「Qiita Advent Calendar」と「Adventar」がありますね。
私は両サービスとも利用しているのですが、どちらのサービスに、いつ、どんなカレンダーに参加しているのかたまに忘れてしまうことがあるので、それらがひと目で分かるように、参加登録しているカレンダー情報を一箇所にまとめるiOSアプリをPythonistaで作成しました。

今日はどんな風に作ったのか?というのをつらつら書いていきます。
特に目新しい技術・トリッキーなアルゴリズム等は使ってないのであしからず。

「各サービスのカレンダーをこまめにチェックするなりGoogleカレンダーに登録しておけば良いのでは?」みたいな話はひとまず置いておいて下さい。
技術で無駄に遊びたいときってあるじゃないですか。アレですアレ。
(実はGoogleカレンダーにもいちいち登録するのも面倒くさかったというのが主な理由なんて言えない。)

* その前に、Pythonistaって何?

簡単に説明すると、PythoniOSアプリを開発することができるiOSアプリケーションです。
今回はPythonistaの詳細についてはここでは述べませんが、 私が今年のPyconJPで話したPythonistaについての発表資料があるのでよければ御覧ください。

* 開発環境

今回作ったアプリのコードは https://github.com/Omega014/AdventCalendarCollection に置いてあります。よければ参考までに。

* やりたいことのゴール

↓ 完成形の姿です。

f:id:equal_001:20161207033718j:plain

少ない...。
でもこんな感じでいっぺんに管理したい。


★ 実装方針

タイトルからお察しの通り、スクレイピングで自分が登録しているカレンダー情報を取得します。
流れとしては、

1. 「Qiita Advent Calendar」と「Adventar」のカレンダーのURLを取得
2. 取得したURLからカレンダーページを開き、Beautiful Soupを用いて自分が登録しているカレンダーのタイトルと日にちを抜き出す
3. 抜き出したカレンダーのタイトルと日にちをjsonに保存する(雑にDB代わりにjsonファイルを使う)
4. PythonistaのUIパーツを駆使して好みのカレンダーを作る
5. jsonからデータを読み出し、カレンダーの各日にちと紐づくUIパーツに、カレンダーのタイトルが表示されるようにする

みたいな感じです。
特に目新しい技術を使っているわけではないです。


ということで、各工程について書いていきます。

1. 「Qiita Advent Calendar」と「Adventar」のカレンダーのURLを取得

Adventar はユーザ毎の登録したカレンダー一覧のページがPublicになっているので、そのURLを使います。
(ちなみに私のユーザページのURLは http://www.adventar.org/users/7109
「?year=****」 を付けなくても今年のページに飛びますが、簡単に昔の年のデータも取得できるようにYEARで管理してます。

YEAR = "2016"
ADVENTAR_URL = "http://www.adventar.org/users/7109?year=[]".format(YEAR)

Qiita Advent Calendar もユーザ毎の参加中のカレンダーを見ることができるページがあるのですが、ログインしないと閲覧できないためQiita Advent Calendar 2016の全てのカレンダーのページを対象とし、その中から自分が登録しているものだけを抽出する方法を取ります。

全てのカレンダーはいずれかのカテゴリーに属しているらしく、各カテゴリーページを見るとカレンダーのリンク一覧が見れるので、これを利用してカレンダーページのURLを特定します。

QIITA_CATEGORIES = [
    "to_be_decided",
    "programming_languages",
    "libraries",
    "databases",
    "web_technologies",
    "mobile",
    "devops",
    "iot",
    "os",
    "editors",
    "academic",
    "services",
    "company",
    "miscellaneous"
]

def _scrape_qiita_advent_calendar():
    """ Qiita Advent Calendarから
        参加登録中のカレンダーのタイトルと年月日を取得して返す関数
    """
    # 公開されている全てのカレンダーのURLをリストに集約
    urls = []
    for category in QIITA_CATEGORIES:
        html = urllib.request.urlopen(
            "http://qiita.com/advent-calendar/{}/categories/{}".format(YEAR, category)
        ).read().decode('utf-8')
        soup = BeautifulSoup(html)

        # クラス名変更で動かなくなる可能性がある
        target_xml = soup.select("[class~=adventCalendarList_calendarTitle]")
        for target in target_xml:
            title = target.find_all("a")[-1].string
            url = "http://qiita.com{}".format(target.find_all("a")[-1]["href"])
            urls.append(url)

ちなみに何故全件を対象にしたのかというと、
Qiita Advent Calendar APIなるものがなく、かといってrequestsのpostを使ってログインを試みたのですが対策されてるのかログイン出来ず、Adventarのようにまとまったページに容易にアクセスできなかったので、
スクレイピング処理走らせるのせいぜい3回くらいやろ」ということで全件取得になりました。。

2. Beautiful Soupを用いて自分が登録しているカレンダーのタイトルと日にちを抜き出す

ちなみにbs4はPythonistaに組み込まれているので pip installしなくてもそのまま使えます。BeautifulSoup(html, "lxml") と書きたいなら pip install lxmlすればokです。

アドベントカレンダーでDOMツリーが異なるので、それぞれに合ったスクレイピング処理を実装しています。
デザインの変更などで動かなくなる可能性はありますが、それは仕方ないのでその時はちまちま直しましょうという空気感でやってます。

def _scrape_adventar():
    """ ADVENTARから
        参加登録中のカレンダーのタイトルと年月日を取得して返す関数
    """
    html = urllib.request.urlopen(ADVENTAR_URL).read().decode('utf-8')
    soup = BeautifulSoup(html)
    # クラス名変更で動かなくなる可能性がある
    registrations = []
    for data in soup.find_all("span"):
        date = data.parent.find("span").string[:10]  # '2016-12-05(月)'から曜日を削る
        title = data.parent.find("a").string
        registrations.append({"title": title, "date": date})
        
    return registrations


# TwitterとGithubとでどっちも登録しちゃった時用の為にリストにしておく  
QIITA_TARGET_USERS = ["OMEGA014"]

def _scrape_qiita_advent_calendar():
    """ Qiita Advent Calendarから
        参加登録中のカレンダーのタイトルと年月日を取得して返す関数
    """

             ~ (省略) ~ 

    registrations = []
    # 自分が登録してるカレンダーだけを探して日付とタイトルを辞書に格納
    for url in urls:
        html = urllib.request.urlopen(url).read()
        soup = BeautifulSoup(html)

        title = soup.title.string

        # 特定のユーザ名がauthorの日付を探す
        # クラス名変更で動かなくなる可能性がある
        target_xml = soup.select("[class~=adventCalendarCalendar_day]")
        for idx, target in enumerate(target_xml):
            # 参加登録されていない空き日は飛ばす
            if not target.img:  # imgのaltでユーザ名を取得...
                continue
            user_name = target.img["alt"]
            if user_name in QIITA_TARGET_USERS:
                date = "{}-12-{}".format(YEAR, idx+1)
                registrations.append({"title": title, "date": date})
        time.sleep(60)  # 1minだと短すぎるかな...
        
    return registrations

URL毎にurlopenしてはアクセスしてるので、アクセス先のサーバに負担がかからないようにtime.sleepしてます。
507カレンダーもあったので1分待つのも長く感じるのですが、データの取得を何度もやらないという前提でtime.sleepするくらいで置いてます(12月入ってからカレンダーの追加登録とかそうそうしないやろという)。
ちゃんとやるならaiohttpとかasyncioあたりを使って効率良くデータを取得したほうが良いですね。

3. 抜き出したカレンダーのタイトルと日にちをjsonに保存する

Pythonistaでデータを何処に持たせるかという問題がありまして、外部サーバにDBを作って通信することも可能ですが、そこまで大それたものを作るわけではないので、アプリのディレクトリにjsonファイルでデータを持たせておくというのをよくやります。
読み込むときも楽だし、簡単な実装で扱えるのでjsonでデータ管理はおすすめです。

    # 毎回読み込みすると重くて辛いのでjsonに最新のデータを持たせておく
    filepath = os.path.join(os.path.realpath('./'), 'registrations.json')
    os.remove(filepath)
    with open(filepath, 'a') as f:
        json.dump(registrations, f, indent=4)

ここまでは単なるスクレイピングのお話。

4. PythonistaのUIパーツを駆使して好みのカレンダーを作る

ここからPythonistaの話になります。

PythonistaにはUIパーツを指先一つでスイスイ組み立てれるUI Designerという機能があるので、これを使ってUIパーツを配置し、好みのカレンダーのデザインを作っていきます。(もちろんPythonコードでもUIを生成することも可能です)。

↓骨組み完成図。UI Designer上でみるとこんな感じ。
f:id:equal_001:20161207043844j:plain

使ったUIパーツは以下:

  • Label: 曜日・日付を表示させるだけ
  • TextView: カレンダーのタイトルを表示させる
  • Button: カレンダー情報の更新のアクション(self.refresh)を登録する

この時、LabelとTextViewの命名規則として label1, textview1のように日付と対応するように名前をつけてます。
こうすることで、たとえば12/7のTextViewを呼び出すときに以下のように記述することができるようになって便利です。

day = '7'
self.view['textview'+day]

ちなみに手でポチポチとUIパーツを配置しても良いですが、かなり疲れるのでUIパーツを一気に選択してコピペで書き換えれば楽に生成出来ます。
さらにちなむと、pyuiの実態はただのjsonで、UIパーツの座標や種類などの設定がずらーっと書いてあるだけなので、中身をコピペ&置換する方法でも短時間で生成出来ます。

5. 日付情報と紐づくUIパーツにカレンダーのタイトルが表示されるように設定する

やっていることは単純で、3で保存したjsonからカレンダーのタイトルと日付を読み込み、その日付に該当するTextViewにタイトルを入れる(self.view['textview'+day].text = title) というだけです。
これを登録しているカレンダー分実行しているという感じです。

self.refreshはButtonパーツと紐付けている(UI DesignerでButtonをタップするとActionという項目があるのでそこにself.refreshと入力するだけ)ので、refreshボタンを押したらスクレイピング処理->あたらしいjsonを再度読み込んで反映を自動でやってくれます。

import json
import os
from datetime import datetime

import ui  # Pythonistaのuiモジュール

import scraper  # 1~3で作ったスクレイピング処理をするモジュール


class AdventCalendarCollection (object):
    def __init__(self):
        self.view = ui.load_view('AdventCalendarCollection')
        self.view.background_color = "#ffebd5"
        self.view.present('fullscreen')
        self.view.name = 'AdventCalendarCollection'
        self.reload_data(None)
        self.change_today_bg_color()
        
    def refresh(self, sender):
        scraper.main()
        self.reload_data(None)

    def reload_data(self, sender):
        filepath = os.path.join(os.path.realpath('./'), 'registrations.json')
        with open(filepath, 'r') as f:
            registrations = json.load(f)
        # jsonに保存する時点でdayだけ保存しても良いかもなぁ...
        for data in registrations:
            day = data['date'].split('-')[-1]
            if day[0] == '0':
                day = day[1]
            title = data['title']
            self.view['textview'+day].text = title
    
    def change_today_bg_color(self):
        # 今日の日だけカレンダーの背景に色をつける
        today_datetime = datetime.now()
        if today_datetime.month == 12:
            today = str(today_datetime.day)
            self.view['textview'+today].background_color = '#ffd9d9'


AdventCalendarCollection()

ちなみに地味に self.change_today_bg_color() で今日の日付のTextViewの背景だけ色をつけてます。そんな処理も関数にちょいと書くだけで実現できるPythonistaとても便利!


以上で出来上がりです。簡単ですね!
これで幾つか登録したあとに心配になって何度も担当日をチェックしに行ったりせずにすむ...。

★ まとめ

  • 自分が登録したAdvent Calendarの情報をスクレイピング処理で取得して一つのカレンダーにまとめて見れるiOSアプリをPythonistaで作ってみた
  • Pythonistaを使うとちょっとのPythonコードを書くだけで自分が欲しいアプリを作れて楽しいよ
  • みんなもPythonista触ってみよう! <- これが言いたかった