OSDN Git Service

bd1cf79a33fe607e9a3bff311c94b58fc16c077b
[hengband/web.git] / score / tools / tweet_score.py
1 #!/usr/bin/python
2 # -*- coding: utf-8
3
4 '''スコアをツイートする
5
6 OSDNのサーバでrequests_oauthlibを使う方法のメモ
7
8 1. 環境変数PYTHONUSERBASEでインストール先をWebコンテンツ上の任意のディレクトリに指定し、
9    pipに--userオプションをつけてインストールを実行
10    以下は /home/groups/h/he/hengband/htdocs/score/local 以下にインストールする例
11
12    `$ PYTHONUSERBASE=/home/groups/h/he/hengband/htdocs/score/local
13       pip install --user requests_oauthlib`
14
15 2. パスは通っているはずなのにシステムにインストールされているrequestsとurllib3が何故か読み込みに失敗するので、
16    上でインストールしたディレクトリにコピーする
17
18    `$ cp -a /usr/lib/python2.7/dist-packages/requests
19       /usr/lib/python2.7/dist-packages/urllib3
20       /home/groups/h/he/hengband/htdocs/score/local/lib/python2.7/site-packages`
21
22 3. sys.path.appendで上でインストールしたディレクトリにパスを通してからrequests_oauthlibをimportする
23
24    `import sys`
25    `sys.path.append('/home/groups/h/he/hengband/htdocs/score/local/lib/python2.7/site-packages')`
26    `import requests_oauthlib`
27 '''
28
29 import sys
30 import datetime
31 import sqlite3
32 import gzip
33 import re
34 import config
35
36
37 def get_score_data(score_id):
38     '''DBからスコアデータを取得する。
39
40     Args:
41         score_id: 取得するスコアのスコアID。
42             Noneの場合最新のスコアを取得する。
43
44     Returns:
45         取得したスコアのデータを格納した辞書。
46         指定のスコアIDに該当するスコアが見つからない場合None。
47     '''
48     if score_id is None:
49         cond = 'ORDER BY score_id DESC LIMIT 1'
50     else:
51         cond = 'WHERE score_id = :score_id'
52
53     with sqlite3.connect(config.config['ScoreDB']['path']) as con:
54         con.row_factory = sqlite3.Row
55         sql = '''
56 SELECT
57   *,
58   CASE
59     WHEN realm_id IS NOT NULL THEN '(' || group_concat(realm_name) || ')'
60     ELSE ''
61   END AS realms_name,
62   CASE
63     WHEN killer = 'ripe' THEN '勝利の後引退'
64     WHEN killer = 'Seppuku' THEN '勝利の後切腹'
65     ELSE killer || 'に殺された'
66   END AS death_reason
67 FROM
68  (SELECT
69     *
70   FROM
71     scores_view
72   {cond})
73 NATURAL LEFT JOIN
74   score_realms
75 NATURAL LEFT JOIN
76   realms
77 GROUP BY
78   score_id
79 '''.format(cond=cond)
80         c = con.execute(sql, {'score_id': score_id})
81         score = c.fetchall()
82
83     return score[0] if len(score) == 1 else None
84
85
86 def get_daily_score_stats(year, month, day):
87     '''DBから指定した日付のスコア統計データを得る
88
89     Args:
90         year: 指定する年。
91         month: 指定する月。
92         day: 指定する日。
93
94     Returns:
95         取得したスコア統計データを格納した辞書。
96         'total_count': 総スコア件数, 'winner_count': 勝利スコア件数
97     '''
98     with sqlite3.connect(config.config['ScoreDB']['path']) as con:
99         con.row_factory = sqlite3.Row
100         sql = '''
101 SELECT
102   count(*) AS total_count,
103   count(winner = 1 OR NULL) AS winner_count
104 FROM
105   scores
106 WHERE
107   date >= date('{target_date}') AND date < date('{target_date}', '+1 day')
108 '''.format(target_date=datetime.date(year, month, day).isoformat())
109         c = con.execute(sql, {})
110         score = c.fetchall()
111
112     return score[0]
113
114
115 def get_death_reason_detail(score_id):
116     '''ダンプファイル内から詳細な死因を取得する。
117
118     Args:
119         score_id: ダンプファイルのスコアID。
120
121     Returns:
122         詳細な死因を表す文字列。
123         ダンプファイルが無い、もしくは詳細な死因が見つからなかった場合None。
124     '''
125     subdir = (score_id // 1000) * 1000
126     try:
127         with gzip.open("dumps/{0}/{1}.txt.gz"
128                        .format(subdir, score_id), 'r') as f:
129             dump = f.readlines()
130     except IOError:
131         return None
132
133     # NOTE: 死因の記述は31行目から始まる
134     death_reason = unicode(''.join([l.strip() for l in dump[30:33]]), "UTF-8")
135     match = re.search(u"…あなたは、?(.+)。", death_reason)
136
137     return match.group(1) if match else None
138
139
140 def create_tweet(score_id):
141     '''ツイートするメッセージを生成する。
142
143     Args:
144         score_id: 指定するスコアID。Noneの場合最新のスコアを取得する。
145
146     Returns:
147         生成したツイートメッセージ文字列。
148         なんらかの理由により生成できなかった場合None。
149     '''
150     score_data = get_score_data(score_id)
151     if score_data is None:
152         return None
153
154     death_reason_detail = get_death_reason_detail(score_data['score_id'])
155     if death_reason_detail is None:
156         death_reason_detail = (u"{0} {1}階"
157                                .format(score_data['death_reason'],
158                                        score_data['depth']))
159
160     summary = (u"【新着スコア】{personality_name}{name} Score:{score}\n"
161                u"{race_name} {class_name}{realms_name}\n"
162                u"{death_reason_detail}"
163                ).format(death_reason_detail=death_reason_detail, **score_data)
164
165     dump_url = ("https://hengband.osdn.jp/score/show_dump.php?score_id={}"
166                 ).format(score_data['score_id'])
167     screen_url = ("https://hengband.osdn.jp/score/show_screen.php?score_id={}"
168                   ).format(score_data['score_id'])
169
170     tweet = (u"{summary}\n\n"
171              u"dump: {dump_url}\n"
172              u"screen: {screen_url}\n"
173              u"#hengband"
174              ).format(summary=summary,
175                       dump_url=dump_url,
176                       screen_url=screen_url)
177     return tweet
178
179
180 def create_daily_stats_tweet(year, month, day):
181     '''デイリースコア統計データのツイートを生成する
182
183     Args:
184         year: 指定する年。
185         month: 指定する月。
186         day: 指定する日。
187
188     Returns:
189         生成したツイートメッセージ文字列。
190         なんらかの理由により生成できなかった場合None。
191     '''
192     daily_stats = get_daily_score_stats(year, month, day)
193
194     tweet = (u"{year}年{month}月{day}日のスコア\n"
195              u"全 {total_count} 件, 勝利 {winner_count} 件\n"
196              u"#hengband"
197              ).format(year=year, month=month, day=day,
198                       **daily_stats)
199
200     return tweet
201
202
203 def tweet(tweet_contents):
204     '''ツイートする。
205
206     Args:
207         oauth: requests_oauthlib.OAuth1Sessionの引数に渡すOAuth認証パラメータ。
208         tweet_contents: ツイートする内容を表す文字列。
209     '''
210     from requests_oauthlib import OAuth1Session
211     from requests.adapters import HTTPAdapter
212     from requests import codes
213     from logging import getLogger
214     logger = getLogger(__name__)
215
216     twitter = OAuth1Session(**config.config['TwitterOAuth'])
217
218     url = "https://api.twitter.com/1.1/statuses/update.json"
219
220     params = {"status": tweet_contents}
221     twitter.mount("https://", HTTPAdapter(max_retries=5))
222
223     logger.info("Posting to Twitter...")
224     logger.info(u"Tweet contents:\n{}".format(tweet_contents))
225     res = twitter.post(url, params=params)
226
227     if res.status_code == codes.ok:
228         logger.info("Success.")
229     else:
230         logger.warning("Failed to post: {code}, {json}"
231                        .format(code=res.status_code, json=res.json()))
232
233
234 def parse_option():
235     '''コマンドライン引数をパースする。
236
237     Returns:
238         パースした結果を表す辞書。OptionParser.parse_args()のドキュメント参照。
239     '''
240     from optparse import OptionParser
241     parser = OptionParser()
242     parser.add_option(
243         "-s", "--score-id",
244         type="int", dest="score_id",
245         help="Tweet score with specified id.\n"
246              "When this option and -d are not set, latest score is specified.")
247     parser.add_option(
248         "-d", "--daily-stats",
249         type="string", dest="stats_date",
250         help="Tweet statistics of the score of the specified day.")
251     parser.add_option("-c", "--config",
252                       type="string", dest="config_file",
253                       default="tweet_score.cfg",
254                       help="Configuration INI file [default: %default]")
255     parser.add_option("-l", "--log-file",
256                       type="string", dest="log_file",
257                       help="Logging file name")
258     parser.add_option("-n", "--dry-run",
259                       action="store_true", dest="dry_run",
260                       default=False,
261                       help="Output to stdout instead of posting to Twitter.")
262     return parser.parse_args()
263
264
265 def setup_logger(log_file):
266     '''ロガーをセットアップする。
267
268     Args:
269         log_file: ロガーの出力先ファイル名。
270             Noneの場合、ファイルには出力せず標準エラー出力のみに出力する。
271     '''
272     from logging import getLogger, StreamHandler, FileHandler, Formatter, INFO
273     logger = getLogger(__name__)
274     logger.setLevel(INFO)
275     sh = StreamHandler()
276     logger.addHandler(sh)
277     if log_file:
278         formatter = Formatter('[%(asctime)s] %(message)s')
279         fh = FileHandler(log_file)
280         fh.setFormatter(formatter)
281         logger.addHandler(fh)
282
283
284 if __name__ == '__main__':
285     (options, arg) = parse_option()
286     setup_logger(options.log_file)
287     from logging import getLogger
288     logger = getLogger(__name__)
289
290     try:
291         config.parse(options.config_file)
292         if 'Python' in config.config:
293             sys.path.append(config.config['Python']['local_lib_path'])
294
295         if options.stats_date:
296             target_datetime = datetime.datetime.strptime(
297                 options.stats_date, "%Y-%m-%d")
298             tweet_contents = create_daily_stats_tweet(
299                 target_datetime.year,
300                 target_datetime.month,
301                 target_datetime.day
302             )
303         else:
304             tweet_contents = create_tweet(options.score_id)
305
306         if tweet_contents is None:
307             logger.warning('No score data found.')
308             sys.exit(1)
309
310         if (options.dry_run):
311             print(tweet_contents.encode("UTF-8"))
312         else:
313             tweet(tweet_contents)
314     except Exception:
315         from traceback import format_exc
316         logger.critical(format_exc())