Hatena::ブログ(Diary)

るびゅ備忘録 このページをアンテナに追加 RSSフィード


04/17/2009

twitterのログを自動的にバックアップするGAEアプリ(cronを使ってみる)

twitterのログを長期間残しておいて、いつでもアクセス可能にしたいな―と思ったので丁度GAEがcronに対応したことだし、とアプリにしてみた。


http://rubyu-twitterlog.appspot.com/

実際に動いてるところ。


cronで10分ごとに

http://twitter.com/statuses/user_timeline.xml

から、最後に取得したIDより大きいstatusを取ってくる。

んでデータストアに入れといて、あとは適当なviewから参照するだけ。



特に難しいことはしてないが、

  • python2.6だとxmlパーサのあたりでエラーが出る
  • cronが開始されるまでラグがある

あたりでちょっと時間を食った。しかしこれでハードにtwitterを使える!



例によってソースをぺたり。

tw_archiver.py

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

import wsgiref.handlers
import logging
from datetime import datetime
import os
import re

from google.appengine.ext import webapp
from google.appengine.api import urlfetch
from google.appengine.api import users
from google.appengine.ext import db
from google.appengine.ext.webapp import template

import base64
from xml.etree.ElementTree import fromstring

template.register_template_library('custom_filters')

url_timeline = 'http://twitter.com/statuses/user_timeline.xml'

indexTmplPath   = os.path.join(os.path.dirname(__file__), 'index.html')

#settings
username = 'hogehoge'
password = 'hogehoge'
limit = 100

class Status(db.Model):
    index    = db.IntegerProperty(required=True)
    id       = db.IntegerProperty(required=True)
    source   = db.StringProperty(required=True)
    created  = db.DateTimeProperty(required=True)
    text     = db.TextProperty(required=True)

def str2dt(str):
    return datetime.strptime(str, '%a %b %d %H:%M:%S +0000 %Y')

def getTimeLine(sinceid):    
    url = '%s?since_id=%s'% (url_timeline, sinceid)
    base64string = base64.encodestring('%s:%s' % (username, password))[:-1]
    headers = {'Authorization': "Basic %s" % base64string} 
    result = urlfetch.fetch(url, method=urlfetch.GET, headers=headers)
    elem = fromstring(result.content)
    statuses = elem.findall('.//status')
    results = []
    for status in statuses:
        id      = int( status.findtext('./id') )
        text    = status.findtext('./text')
        source  = status.findtext('./source')
        created = str2dt( status.findtext('./created_at') )
        results.append({'id': id,
                        'text': text, 
                        'source': source,
                        'created': created
                        })
    results.reverse()
    return results

def getPages(page):
    maxTicks = 30
    tickStep = 1
    downTicks = []
    upTicks   = []
    for i in xrange(1, (maxTicks / 2) + 1):
        d = tickStep * i
        n = page - d
        if 0 < n:
            downTicks.append(n)
        upTicks.append(page + d)
    downTicks.reverse()
    return {'up': upTicks,
            'down': downTicks }

def getState():
    sinceid  = 1
    maxindex = -1
    statuses = Status.gql('ORDER BY index DESC LIMIT 1')
    if statuses:
        for status in statuses:
            sinceid  = status.id
            maxindex = status.index
            break
    logging.info('sinceid: %s'  % sinceid)
    logging.info('maxindex: %s' % maxindex)
    return (sinceid, maxindex)

class MainHandler(webapp.RequestHandler):
    def get(self, page):
        if None == page:
            page = 1
        page = self.v_page(page)
        if not page:
            self.redirect('/')
            return
        
        (sinceid, maxindex) = getState()
        offset = maxindex - (page - 1) * limit
        statuses = Status.gql('WHERE index <= :1 ORDER BY index DESC', offset).fetch(limit)
        temp = []
        for status in statuses:
            temp.append({'id': status.id,
                         'text': status.text,
                         'source': status.source,
                         'created': status.created })
        statuses = temp
        self.response.headers['Content-Type'] = 'text/html; charset=utf-8'
        self.response.out.write(template.render(indexTmplPath, {'username': username,
                                                                'statuses': statuses,
                                                                'page': page,
                                                                'pages': getPages(page), 
                                                                }))
        
    def v_page(self, n):
        try:
            n = int(n)
            if 0 < n:
                return n
        except:
            logging.warning('v_page() fail.')
            return


class FetchHandler(webapp.RequestHandler):
    def get(self):
        (sinceid, maxindex) = getState()
        
        statuses = getTimeLine(sinceid)
        def addStatus(key, index, id, text, source, created):
            obj = db.get( db.Key.from_path( "Status", key ) )
            if not obj:
                obj = Status(key_name=key,
                             index=index,
                             id=id,
                             text=text,
                             source=source,
                             created=created )
                obj.put()
        for status in statuses:
            try:
                maxindex += 1
                id  = status['id']
                key = 's%s' % id
                db.run_in_transaction(addStatus, key, maxindex, id, status['text'], status['source'], status['created'])
                logging.info( 'add status: %s(%s, %s)' % (status['text'], maxindex, id) )
            except Exception, e:
                logging.error( 'add fail: %s' % e )
                self.response.headers['Content-Type'] = 'text/html'
                self.response.out.write('fetch() fail.')
                return #失敗したらそこで終了 後は次にまかせる
        
        self.response.headers['Content-Type'] = 'text/html'
        self.response.out.write('fetch() OK.')

def main():
    application = webapp.WSGIApplication([
                                          ('^/(\d+)?', MainHandler),
                                          ('^/fetch', FetchHandler),
                                          ],
                                        #debug=True)
                                        debug=False)
                                        
    wsgiref.handlers.CGIHandler().run(application)

if __name__ == "__main__":
    main()



index.html

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<head>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>tw-archiver</title>
<style type="text/css">
//css部分省略
</style>
</head>
<body>

<div id="main">
<div id="header">
archive of <a href="http://www.twitter.com/{{ username }}">http://www.twitter.com/{{ username }}</a>
</div>

<div class="pages top">
{% for p in pages.down %}
<a href="/{{ p }}">{{ p }}</a>
{% endfor %}
{{ page }}
{% for p in pages.up %}
<a href="/{{ p }}">{{ p }}</a>
{% endfor %}
</div>


{% for status in statuses %}
<div class="status">
	<div class="text">{{ status.text|autolink }}</div>
	<div class="created">{{ status.created|JST }}</div>
	<div class="id">[id:{{ status.id }}]</div>
	<div class="source">from {{ status.source }}</div>
</div>
{% endfor %}

<div class="pages bottom">
{% for p in pages.down %}
<a href="/{{ p }}">{{ p }}</a>
{% endfor %}
{{ page }}
{% for p in pages.up %}
<a href="/{{ p }}">{{ p }}</a>
{% endfor %}
</div>

</div>
</body></html>



custom_filters.py

import re
import datetime
from google.appengine.ext.webapp import template

register = template.create_template_register()

@register.filter
def JST(time):
    return ( time + datetime.timedelta(hours = 9) ).strftime('%y/%m/%d %H:%M:%S')

re_autolink = re.compile( r'(http://[^\s]+)' )
@register.filter
def autolink(str):
    return re.sub(re_autolink, r'<a href="\1">\1</a>', str)




cron.yaml

cron:
- description: fetch twitter
  url: /fetch
  schedule: every 10 minutes




app.yaml

application: rubyu-twitterlog
version: 1
runtime: python
api_version: 1

handlers:
- url: /(\d+)?
  script: tw_archiver.py

- url: /fetch
  script: tw_archiver.py
  login: admin

のりんむらのりんむら 2009/05/13 00:08 はじめまして。
中国の上海在住の「のりんむら」と言います。
現在、rubyuさんの作成されたこのアプリをGAEに設置しようとしてるのですが、なぜかうまく反映されません。
Cron.yamlがエラーの対象となっているので、取得しにいけていないのかもしれませんが、私が見る限りは正しいと思います。

index.htmlのCSSが省略された部分には、rubyuさんが実際に公開されているページからCSS部分を抜き取り、貼り付けました。
index.htmlの{{ username }}の部分には自分のTwitterIDを入力し、{ p }}の部分はからにしました。
私の公開したGAEのアドレスは、
http://norinmura-twitter.appspot.com/
です。

それと、もしよろしければ、リンクさせていただいてもよろしいでしょうか?

よろしくお願いします。

ruby-Uruby-U 2009/05/13 01:39 おー、適当にでっち上げたアプリですが、使ってくださる方がいるとは。嬉しいです。
リンクなどはご自由にどうぞ、べたべた貼ってやってください。


http://norinmura-twitter.appspot.com/
を見るとHTMLは出力されているので、MainHandler内でのエラーではないと思います。

Cronジョブは
http://norinmura-twitter.appspot.com/fetch
をコールします。
それがエラーになる → データストアにデータが登録されない ということだと思いますが、ちょっと原因が特定できないですね。

とりあえず、
http://norinmura-twitter.appspot.com/fetch
をブラウザで開いてみて、(adminログインが要求されます)正常にデータが登録されるようなら、Cronジョブの実行が何らかの原因で行われないという状態でしょう。
逆にデータが登録されないようならば、FetchHandler内で何か問題が起きています。


debug=Trueにして、tryを外して、ローカルでテストしてみてください。


ちなみに私のローカル環境で、全くデータストアが空の状態からテストしてみると
(/fetchにアクセス)

INFO 2009-05-12 16:27:30,066 dev_appserver.py] "GET /fetch HTTP/1.1" 200 -
INFO 2009-05-12 16:27:32,894 tw_archiver.py] sinceid: 1
INFO 2009-05-12 16:27:32,894 tw_archiver.py] maxindex: -1
INFO 2009-05-12 16:27:34,253 tw_archiver.py] add status: firefoxで開いてるタブの数が減ったら4コアへの渇望が薄れた(0, 1736804940)
INFO 2009-05-12 16:27:34,286 tw_archiver.py] add status: お、TwitterFoxで一度ポストに失敗したあと再ポストで成功した場合テキストエリアがクリアされない(1, 1736816583)
...以下続く

のようになります。

ruby-Uruby-U 2009/05/13 01:50 あ、ちょっと引っかかったんですが
#settings
username = 'hogehoge'
password = 'hogehoge'
の部分、ご自分のusernameとpasswordに書き換えられてますか?

ここを書き換えておくと
>index.htmlの{{ username }}の部分には自分のTwitterIDを入力し、
ということをしなくてもいいんですが。


もちろん、twitterにアクセスしてデータを取ってくる際にusernameなどの情報は必要ですので、hogehogeのまま=エラーになります。

気になったので念のため、お聞きしておきます。

のりんむらのりんむら 2009/05/13 21:17 こんばんは。
#SettingsのところにはTwitterのID/PWを入力してました。
そして、さっき
http://norinmura-twitter.appspot.com/fetch
にアクセスしたところ、
http://norinmura-twitter.appspot.com/
にTwitterのログが表示されるようになったので、
cronがうまく動作してないようです。
何かcronをアップロードするときに設定しないといけないのでしょうか?

のりんむらのりんむら 2009/05/13 21:19 こんばんは。
#SettingsのところにはTwitterのID/PWを入力してました。
そして、さっき
http://norinmura-twitter.appspot.com/fetch
にアクセスしたところ、
http://norinmura-twitter.appspot.com/
にTwitterのログが表示されるようになったので、
cronがうまく動作してないようです。
何かcronをアップロードするときに設定しないといけないのでしょうか?

ruby-Uruby-U 2009/05/13 21:45 >何かcronをアップロードするときに設定しないといけないのでしょうか?
cron.yamlを同一ディレクトリに配置するだけで問題ないはずなんですが−、うーんなんですかね。

ええと、管理画面で(main -> cron jobs)cronについて
-------
Cron Job Schedule/Last Run/Last Status (All times are UTC)
-------
/fetch every 10 minutes (UTC)
fetch twitter 2009/05/13 12:37:53 on time Success
-------
っぽいのが表示されていますか?

cron.yamlがスクリプトと同一ディレクトリに設置してあり、かつこれが登録されていないのであれば、SDKのバージョンが古いとかそういう原因しか思い当たりませんね。
cron機能はつい最近追加されたので、古いSDKだとアップロードされなかったり…するかもしれません。

のりんむらのりんむら 2009/05/13 23:01 ありがとうございます。
原因はSDKのバージョンが古いためでした。
てっきりSDKはどのバージョンでもいいのかと思っておりまして、
1.1の物をインストールしてました。
1.22をインストールして、
appcfg.py update
を実行したところ、Dashboardに反映されました。
現在の「cron job」の表示は:
/fetch
fetch twitter every 10 minutes (UTC)
2009/05/13 13:55:04 on time Success
です。
お忙しいところいろいろとすみませんでした。
明日あたり、ブログの方に紹介させていただこうと思います。
ありがとうございました。

ruby-Uruby-U 2009/05/13 23:20 おお、うまくいったようで何よりです。
これはtwitterのログをとりあえずGAEに保存しておきたかったために作ったアプリですので、表示側はとても適当です。
用途や趣味に合わせて適当に改良しないとちょっとみすぼらしいかなーという気がしますので、そのあたりは必要でしたらテンプレートをいじったりしてみてください。
私もそのうち気が向いたらAjaxな感じにしてみようかなと思っています。

のりんむらのりんむら 2009/05/13 23:59 私も自動的にログを保存できれば良いと思っていたので、
ちょうどruby-uさんの作成されたこちらのアプリがいいかんじでした。
テンプレートは時間があるときに少し弄ってみようかと思っています。
何か良いテンプレートができたらお知らせしますね!

ruby-Uruby-U 2009/05/14 00:14 おー、楽しみにしています。
コードで修正できる点は
・発言の数を取得してる=最大ページ数が計算できる
・キャッシュ機構を組み込み
テンプレートは
・cssで、日付のところのtext-alignがleftになってる(多分)
ぐらいですかね。
時間があったら修正したいと思います。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証