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

ブクログの本棚のデータを新刊.netへ登録するGoogle App Engineのアプリを作ってみる

python gae sinkan booklog

さてさて、僕は漫画が大好きで常に漫画に囲まれていたいとか思っちゃう方なのですが、
記憶力があまり良くなくて「あの本買ったっけかな?」「あの本読んだっけかな?」と時たま思うことことがあります。
それをなるべく減らそうと、

  • 新刊.netにキーワードを登録しておいて発売日を確認する
  • 読んだ内容を忘れちゃうのでブクログにメモを残しておく

ってなこともやっています。



昨日の昼にふとTSUTAYAの前を通った際に
「あぁ、新しく読んだ漫画を新刊.netに登録してないなぁ」
と思い、
「じゃあせっかくだからブクログに登録したデータを新刊.netに登録しようかな」
となり、
Google App Engineでやればcronでいけるのかな」
と思ったのでサクっと作ってみました。


まず仕様の確認

新刊.netへの登録

新刊.netへの登録ですが、ブラウザで行うと、

  1. ログインする
  2. タイトル、著者(姓)、著者(名)、出版社、キーワードのどれかを登録する

なので、今回の場合は著者(姓)と著者(名)を入れたいと思います。



ということで、以下の流れになります。

  1. ログインするためにhttp://sinkan.net/へaction_login_do,email,passwordの3つをPOST
    • これでログイン状態を表すセッションDiscoverSESSIDというcookieが貰えます
  2. リマインダー登録のためのPOSTをしたいけど、CSRF対策のためのkeyを貰わないといけないのでhttp://sinkan.net/?action_keywords=trueをGET
    • これでethna_csrfというkeyが貰えます
  3. リマインダー登録するためにaction_keywords_add,store,ethna_csrf,name_sei,name_meiをPOST

細かいことはあとで考えます。


ブクログからデータをGET

次にブクログ
ブクログhttp://api.booklog.jp/json/<ユーザ名>?category=0&count=100で本棚データ100個を取得できるようですね。(最大100個っぽいかな?)
ということで、新刊.netに登録するために、

  1. http://api.booklog.jp/json/<ユーザ名>?category=0&count=100でJSONをGET
  2. booksという配列のauthorにユーザが入っているのでそれを取り出す

くらいで良さそう。


細かい所を確認しつつ作成

ブクログのデータ整形

Google App Engineを使うのでurlfetchでGETし、simplejsonでparseすると下のような感じで書けば良さそう

user = 'username'   # booklogのユーザ名
result = urlfetch.fetch('http://api.booklog.jp/json/%s?category=0&count=100' % user)
obj = simplejson.loads(result.content)
for book in obj['books']:
    print book['author']:

実際にJSONを取得してみると複数著者の場合は

小川 雄大,柄沢 聡太郎,橋口 誠

みたいに,(カンマ)区切りになってるみたいです。
これはsplitしてあげた方がいいですね。



あと、100個未満のデータしかない場合は実在するデータの後に空の配列が続くみたい
これは除外してあげればよいのかな。



最後に、毎回同じデータを返すのもイマイチなので、処理したidをmemcacheに入れておいてそれ以降のデータを返すようにします。



とういうこで出来上がったBooklogクラスはこんな感じ

class Booklog(object):
    def __init__(self, user):
        self._url = 'http://api.booklog.jp/json/%s?category=0&count=100' % user

    def get_authors(self, since_id=None):
        try:
            result = urlfetch.fetch(self._url)
        except DownloadError, e:
            return

        if result.status_code != 200:
            return

        if not since_id:
            since_id = memcache.get('since_id')
        obj = simplejson.loads(result.content)
        authors = []
        for i in xrange(len(obj['books'])):
            if not obj['books'][i]:
                break

            if i == 0:
                memcache.set('since_id', obj['books'][i]['id'])
            elif i == since_id:
                break
            else:
                for author in obj['books'][i]['author'].split(','): # abc,def があり得るので
                    authors.append(author)

        return authors
新刊.netへ著者データを登録

まずログイン。
urlfetch使ってPOSTし、レスポンスにあるset-cookieの値を取得すればログイン状態を保持できるので、

form_fields = {
    'email': '',
    'password': '',
    'action_login_do': 'dummy',
}
form_data = urllib.urlencode(form_fields)
result = urlfetch.fetch(url='http://sinkan.net/',
                      payload=form_data,
                      method=urlfetch.POST,
                      headers={
                            'Content-Type': 'application/x-www-form-urlencoded',
                      },
                      follow_redirects=False)

cookies = result.headers.get('set-cookie').split(', ')
cookies.reverse() # なるべく最後にあるセッションの値が欲しいので逆順にする
for cookie in cookies:
    if cookie.find('DiscoverSESSID') == 0:
        # DiscoverSESSID=49a1e0a118020e199f0fb9301e026237ddfaa0c2343845de8e3ab6cb0defc0c4; path=/
        print cookie.split('; ')[0]
        break 

こんな感じで良さそうです。follow_redirectsがTrueだと勝手にリダイレクト処理しちゃうのでFalseにしてあります。



csrf用のkey取得はただGETするだけなので、

cookie = '' # さっき取得したcookie
result = urlfetch.fetch(url='http://sinkan.net/?action_keywords=true',
                                    headers={'Cookie': cookie})

csrf_key = 'ethna_csrf' # ethnaなのかな
m = re.compile(r'%s"\svalue="([^"]+)"' % csrf_key).search(result.content)
if m.groups() and m.group(1):
    print m.group(1)

こんな感じ取れそうです。



最後にこれらをまとめてキーワード登録の時にPOSTしてあげれば良いですね。

form_fields = {
    'action_keywords_add': 'dummy',
    'add'                : 'dummy',
    'store'              : '1',        # 本
    'title'              : '',
    'name_sei'           : u'ねむ'.encode('utf-8'),
    'name_mei'           : u'ようこ'.encode('utf-8'),
    'publisher'          : '',
    'keyword'            : '',
    'ethna_csrf'         : '', # さっき取得したcsrf用のkey
}
form_data = urllib.urlencode(form_fields)
urlfetch.fetch(url='http://sinkan.net/',
               payload=form_data,
               method=urlfetch.POST,
               headers={
                   'Cookie': '',       # 取得したcookie
                   'Content-Type': 'application/x-www-form-urlencoded',
               },
               follow_redirects=False)

ログインの時と同じようなPOSTだから簡単ですね。



これらをまとめてclassにすると下のような感じ。

class Sinkan(object):
    def __init__(self, fields):
        self._url = 'http://sinkan.net/'
        self._form_fields = {
            'login': {
                'action_login_do': 'dummy',
            },
            'add': {
                'action_keywords_add': 'dummy',
                'add'                : 'dummy',
                'store'              : '1',        # 本
            },
        }
        self._session_sess = {
            'id'   : 'DiscoverSESSID',
            'value': '',               # requestで渡しやすいよう id=xxxx が入る
        }

        self.login(fields)

    def add(self, fields):
        # 変なデータを送らないようにするため
        for key in ['title', 'name_sei', 'name_mei', 'publisher', 'keyword']:
            self._form_fields['add'][key] = fields[key].encode('utf8') if fields.get(key) else ''

        csrf = self._get_csrf()
        self._form_fields['add'][csrf['id']] = csrf['value']

        result = self._post(self._form_fields['add'])

        return result

    # login できる or login 状態ならTrue
    def login(self, fields):
        if self._session_sess['value']:
            return True

        for key in ['email', 'password']:
            self._form_fields['login'][key] = fields[key] if fields.get(key) else ''

        for val in self._form_fields.values():
            if not val:
                return False

        result = self._post(self._form_fields['login'])

        if not result:
            return False

        cookies = result.headers.get('set-cookie').split(', ')
        cookies.reverse() # なるべく最後にあるセッションの値が欲しいので逆順にする
        for cookie in cookies:
            if cookie.find(self._session_sess['id']) == 0:
                # DiscoverSESSID=49a1e0a118020e199f0fb9301e026237ddfaa0c2343845de8e3ab6cb0defc0c4; path=/
                self._session_sess['value'] = cookie.split('; ')[0]
                break

        return True if self._session_sess['value'] else False

    # csrf用のkeyもしくは空文字を戻す
    def _get_csrf(self):
        try:
            result = urlfetch.fetch(url='%s/?action_keywords=true' % self._url,
                                    headers={'Cookie': self._session_sess['value']})
        except DownloadError, e:
            return ''

        csrf_key = 'ethna_csrf'
        m = re.compile(r'%s"\svalue="([^"]+)"' % csrf_key).search(result.content)
        if m.groups() and m.group(1):
            return {'id': csrf_key, 'value': m.group(1)}
        else:
            return ''

    def _post(self, form_fields):
        form_data = urllib.urlencode(form_fields)
        try:
            return urlfetch.fetch(url=self._url,
                                  payload=form_data,
                                  method=urlfetch.POST,
                                  headers={
                                      'Cookie': self._session_sess['value'],
                                      'Content-Type': 'application/x-www-form-urlencoded',
                                  },
                                  follow_redirects=False)
        except DownloadError, e:
            return
それぞれのclassを使う

作ったクラスを操作して実際に動かしてみます。
scriptの中にユーザ名とかパスワード入れない方が良いのでconfig.pyを作って以下のようにします。

sinkan = {
   "email"    : "新刊.netに登録してあるメアド",
   "password" : "新刊.netに登録してあるパスワード",
}
booklog = {
   "user": "ブクログに登録してあるユーザ名"
}

あとはこれをimportして確認。

import config

authors = Booklog(config.booklog['user']).get_authors()

登録してある著者が配列で返ってきます。



僕の場合は

というように空白区切りの人と・区切りの人がいます。
なので、これらで姓名をsplitします。

>>> import re
>>> author_split_re = re.compile(u'[\s・]')
>>> author = u'オノ・ナツメ'
>>> full_name = author_split_re.split(author)
>>> 
>>> print full_name
[u'\u30aa\u30ce', u'\u30ca\u30c4\u30e1']

上手くいってます。
pythonのシェルで日本語入力ができなかったので、ここを参考にしながら「sudo /usr/bin/easy_install-2.5 readline」しました。)



「・」がコードの中に入っていてわかりにくいなという場合はunicode表示にすれば良さそうです。

>>> u'・'
u'\u30fb'
>>> import re
>>> author_split_re = re.compile(u'[\s\u30fb]')
>>> author = u'オノ・ナツメ'
>>> full_name = author_split_re.split(author)
>>> 
>>> print full_name
[u'\u30aa\u30ce', u'\u30ca\u30c4\u30e1']

おんなじようにいけますね。



姓名に分けることができたので、新刊.netにPOSTすれば良いのですが、一度POSTした著者を再度POSTするのは迷惑だなと思うので、POSTした著者を管理しておきます。
著者を保存するデータストアを作って、

class Author(db.Model):
    name = db.StringProperty(required=True)

あとは「データにあればPOSTしない、ないならPOST後にデータストアへ保存」とすればいいですね。

author_data = Author.get_by_key_name(author)
if author_data:
    logging.debug(author_data.name)
else:
    full_name = author_split_re.split(author)

    keywords = {}
    keywords['name_sei'] = full_name[0]
    keywords['name_mei'] = full_name[1] if len(full_name) > 1 else '' 

    result = sinkan.add(keywords)
    Author(key_name=author, name=author).put()

著者名に「__名前__」はないはずなので著者名をkey_nameにし、get_by_key_nameで著者を取得、なければ姓名にわけ、分けられない場合は姓にセットしてPOSTという流れです。



以上でおしまい。


Google App EngineにDeploy

https://appengine.google.com/へ行って「Create Application」からアプリケーションを作りましょう。
名前は適当に。




その後、GoogleAppEngineLauncherを立ち上げて「+」ボタンを押して
さっき登録した名前をApplication Nameへ
で、アプリを置くディレクトリを指定してCreateボタンをクリック




そうするとディレクトリが勝手に作成されて、

[16:19]% tree
.
├── app.yaml
├── index.yaml
└── main.py

こんな感じでファイルが作られます。



main.pyに今まで作った内容を書けばいいので編集して行きます。
まず日本語を扱うので一番上に

# -*- coding: utf-8 -*-

ですね。これ書かないと文字化けしたまま登録されちゃって迷惑なので必ず。



def main()の中のwebapp.WSGIApplicationでURLのマッピングをしているようなので適当に修正します。

    application = webapp.WSGIApplication([('/cron', CronHandler)],
                                         debug=True)

/cronで来たアクセスをCronHandlerに処理させるようにしました。



あとはCronHandlerにさっき書いた内容を入れればいいのでこんな感じ

class CronHandler(webapp.RequestHandler):
    def get(self):
        #author_split_re = re.compile(u'[\s\u30fb]')
        author_split_re = re.compile(u'[\s・]')

        authors = Booklog(config.booklog['user']).get_authors()
        if not authors:
            return

        sinkan = Sinkan({'email': config.sinkan['email'],
                         'password': config.sinkan['password']})
        for author in authors:
            author_data = Author.get_by_key_name(author)
            if author_data:
                logging.debug(author_data.name)
            else:
                full_name = author_split_re.split(author)

                keywords = {}
                keywords['name_sei'] = full_name[0]
                keywords['name_mei'] = full_name[1] if len(full_name) > 1 else ''

                result = sinkan.add(keywords)
                Author(key_name=author, name=author).put()

このままだと誰にでもアクセスできてしまうので、app.yamlを開いてアクセス制限をかけます。

handlers:
- url: .*
  script: main.py
  login: admin

最後のlogin: adminってやつですね。



で、最後にcronで定期的に実行させたいので1日ごとに実行するようにcron.yamlを作ってあげましょう。

cron:
- description: daily summary job
  url: /cron
  schedule: every 24 hours

おぉぉ。できました。
GoogleAppEngineLauncherで確認して問題なければ「Deploy」しておしまいです。
https://appengine.google.com/見て問題ないか確認したりしましょう。


補足

普通はキーワードをPOSTした後にステータスコード302でリダイレクトされるのですが、ステータスコード200で返ってくる場合があります。
例えば、

  • 既に新刊.netに登録されているような似たキーワードをPOSTした場合。「すでに類似したキーワードが登録されています。」というメッセージのページが出る。
  • 自分が既に登録してあるキーワードをPOSTした場合。「既に登録済みのキーワードです。」というメッセージのページが出る。

と言った具合。
その辺りもDatastoreに入れて管理しようかなとか考えたのですが、そこまで頑張る所でもないのでやめました。



あとは登録する時に非同期リクエストを試してみようかなと思ったのですが、さすがに同じ所に非同期で何個もリクエストするのは良くないなと思いやめました。
あとは、あとは、エラー処理が甘い気がするのでテストしようと思い、「nosetests」を使おうと思ったのですが、ImportError: No module named fancy_urllibのpathが通ってないエラーが出て、「あぁ、何かextra_pathを見てないっぽいのかな?中身書き換えていいのかな?」と面倒になりやめました。
その辺はまた別の機会にやってみます。
今回作ったものはhttps://github.com/monmon/from-booklog-to-sinkanにあげました。