完成物のツイート↓
https://twitter.com/sorarakara/status/1332190766471507970?s=20
#どんなBot?
毎週、毎月、毎年、スマホやPCで聞いた楽曲の再生回数ランキングをツイートします。
ツイート内容
- 順位(10位まで)
- タイトル
- アーティスト
- 再生回数
ソースコード
#対象者
プログラミング超初心者以外を想定しました。
もしもPythonとHerokuを使用した経験があれば、Last.fmの登録をして、ソースコードのリポジトリをクローンすれば多分すぐにできます。
#前提
Last.fmというサービスに登録しておいて、何曲か音楽をScrobble(再生した楽曲をリアルタイム記録)しておいてください。もちろん無料。
PCでもスマホでも、対応するアプリを入れれば自動で記録してくれます。2つ以上のscrobbleソフトを使うと記録が重複してしまうので注意。
Androidスマホだと、おすすめはこのアプリ。
通信データ量はほぼなし。
詳しくはググってください。
##環境
- プログラミング言語:Python 3.7.3
- 使用API:Last.fm API
- 使用SDK:Tweepy, line-bot-sdk(LINE Bot)
- デプロイ先:Heroku
- ローカルPC:Mac OS
- 開発環境(IDE):Pycharm CE
#手順
##環境を構築
PythonやPyCharmなどをローカルにインストールします。
##API関連の登録
###1. Twitter
Twitter Developerのページで登録またはログインして、今回作るBot用のAppを作成します。
そして、作成したAppの
CONSUMER_KEY
CONSUMER_SECRET
ACCESS_TOKEN
ACCESS_TOKEN_SECRET
の4つをメモしましょう。
詳しくは、ググってください。
###2. Last.fm
とりあえずAPI公式ページはこちら。
あまり複雑な構成ではないドキュメントだと思います(英語ですが)。
####APIアカウントを作成する
もしもLast.fmのアカウントを作っていない場合は、APIアカウントを作る前に登録しておきましょう。
普通のアカウントがあれば、アカウント登録ページでAPIアカウントの登録をします。
Callback URLは、今回は入力不要です。
Application homepageも、空欄でいいみたいです。
登録完了ページで出てくるアカウント情報は必ずメモしておきましょう。
API KEYは絶対に使います。
まあ、何かで失敗したら何度でも作り直せます。
####ドキュメントを読む
と言っても今回使うのはuser.getTopTracksというメソッドのみ。
読まなくていいです。後述のコードをコピペしてください。
HTTP GETで、指定した直近の期間で聞いた曲が、再生回数の多い順に取得できます。
データはXML形式とJSON形式を選んで取得できますが、今回はJSONを選んでいます。
##コードを書く
冒頭のimport直後の環境変数への代入は、各自の情報をHerokuの環境変数に登録して使ってください。
また、デプロイ前にローカル環境でデバックを行い、実際に正しくツイートされることを確認してください。
import os
import tweepy
import urllib.request
import json
import unicodedata
import datetime
from PIL import Image, ImageFont, ImageDraw
import random
import dropbox
import sys
from linebot import (
LineBotApi, WebhookHandler
)
from linebot.exceptions import (
InvalidSignatureError
)
from linebot.models import (
MessageEvent, TextMessage, TextSendMessage, ImageSendMessage
)
YOUR_CHANNEL_ACCESS_TOKEN = os.environ['LineMessageAPIChannelAccessToken']
YOUR_CHANNEL_SECRET = os.environ['LineMessageAPIChannelSecret']
line_bot_api = LineBotApi(YOUR_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(YOUR_CHANNEL_SECRET)
TWITTER_CONSUMER_KEY = os.environ['TWITTER_CONSUMER_KEY']
TWITTER_CONSUMER_SECRET = os.environ['TWITTER_CONSUMER_SECRET']
TWITTER_ACCESS_TOKEN = os.environ['TWITTER_ACCESS_TOKEN']
TWITTER_ACCESS_TOKEN_SECRET = os.environ['TWITTER_ACCESS_TOKEN_SECRET']
DROPBOX_TOKEN = os.environ['DROPBOX_TOKEN']
LASTFM_API_KEY = os.environ['LASTFM_API_KEY']
LINE_USER_ID = os.environ['LINE_USER_ID']
global period
global theme_color
class Period:
SEVEN_DAYS = '7day'
ONE_MONTH = '1month'
TWELVE_MONTH = '12month'
FONT1 = 'fonts/azuki.ttf' # http://azukifont.com/font/azuki.html
FONT2 = 'fonts/Ronde-B_square.otf' # https://moji-waku.com/ronde/
FONT3 = 'fonts/851letrogo_007.ttf' # http://pm85122.onamae.jp/851letrogopage.html
FONT4 = 'fonts/logotypejp_mp_b_1.1.ttf' # https://logotype.jp/corporate-logo-font-dl.html#i-11
FONT5 = 'fonts/logotypejp_mp_m_1.1.ttf' # https://logotype.jp/corporate-logo-font-dl.html#i-11
def main():
data = get_last_fm_tracks()
# ツイートする文字列
tweet_str = initial_tweet_str()
draw_ranking_img(data)
twitter_api = initialize_twitter_api()
twitter_api.update_with_media(filename='ranking.jpg', status=tweet_str)
img_url1, img_url2 = upload_img_to_dropbox()
line_send_message(tweet_str, img_url1, img_url2)
def initialize_twitter_api():
auth = tweepy.OAuthHandler(TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET)
auth.set_access_token(TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET)
return tweepy.API(auth)
def get_last_fm_tracks():
global period
url = 'http://ws.audioscrobbler.com/2.0/'
params = {
'format': 'json',
'api_key': LASTFM_API_KEY,
'method': 'user.getTopTracks',
'user': 'SoraraP',
'period': period,
}
req = urllib.request.Request('{}?{}'.format(url, urllib.parse.urlencode(params)))
with urllib.request.urlopen(req) as res:
body = res.read()
body = json.loads(body)
return body
def initial_tweet_str():
tweet = 'そららPが'
if period == Period.SEVEN_DAYS:
tweet += '今週'
elif period == Period.ONE_MONTH:
tweet += '先月'
elif period == Period.TWELVE_MONTH:
tweet += '今年'
tweet += '聞いた曲ランキング\n'
return tweet[:-1]
def draw_ranking_img(data):
global period
global theme_color
theme_color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
img_size = (1080, 2160)
img = Image.new('RGB', img_size, color=theme_color)
draw = ImageDraw.Draw(img)
# 画像見出し
font_size = 70
font = ImageFont.truetype(font=FONT4, size=font_size)
position = (int(img_size[0] / 2 - draw.textsize(initial_tweet_str(), font)[0] / 2), 10)
draw.text(xy=position, text=initial_tweet_str(), fill='white', font=font)
draw_table(draw, img_size, data)
# 日付
font_size = 40
font = ImageFont.truetype(font=FONT4, size=font_size)
position = (img_size[0] - draw.textsize(str(today), font)[0] - 10, img_size[1] - draw.textsize(str(today), font)[1] -10)
draw.text(xy=position, text=str(today), fill='white', font=font)
img.save('ranking.jpg')
img2 = img.resize((120, 240))
img2.save('ranking_preview.jpg')
def draw_table(draw, size, data):
global theme_color
width, height = size
num_songs = 10
margin_top = 100
margin_bottom = 50
side_margin = 20
left_top = (side_margin, margin_top)
right_top = (width - side_margin, margin_top)
left_bottom = (side_margin, height - margin_bottom)
right_bottom = (width - side_margin, height - margin_bottom)
# 四角
draw.rectangle((left_top, right_bottom), fill='white', width=0)
draw.line((left_top, left_bottom), fill=theme_color, width=5)
draw.line((right_top, right_bottom), fill=theme_color, width=5)
draw.line((left_top, right_top), fill=theme_color, width=5)
draw.line((left_bottom, right_bottom), fill=theme_color, width=5)
table_height = height - margin_top - margin_bottom
# 横線
xy = (left_top, right_top)
for n in range(num_songs):
xy = [list(xy[0]), list(xy[1])]
xy[0][1] = xy[0][1] + int(table_height/11)
xy[1][1] = xy[0][1]
xy = (tuple(xy[0]), tuple(xy[1]))
draw.line(xy, fill=theme_color, width=5)
# 縦線
rank_width = 100
xy = (left_top[0] + rank_width, left_top[1], left_bottom[0] + rank_width, left_bottom[1])
draw.line(xy, fill=theme_color, width=5)
titles, artists, playcount = ['タイトル'], ['アーティスト'], ['再生回数']
track_num = 0
for track in data['toptracks']['track']:
if track_num < num_songs:
if int(track['playcount']) >= 1:
titles.append(track['name'])
artists.append(track['artist']['name'])
playcount.append(track['playcount'])
track_num += 1
if track_num != num_songs:
print('再生履歴が少なすぎます')
sys.exit()
# 順位
rank_size = 80
rank_xy = (left_top[0] + 10, left_top[1] + 10)
for n in range(num_songs + 1):
if n == 0:
font = ImageFont.truetype(font=FONT1, size=rank_size)
text = '順\n位'
draw.text(xy=rank_xy, text=text, fill=0, font=font)
else:
text = str(n)
if n < 10:
rank_size = 100
rank_xy = (left_top[0] + 25, left_top[1] + 10 + n*int(table_height/(num_songs+1)))
else:
rank_size = 85
rank_xy = (left_top[0] + 5, left_top[1] + 10 + n*int(table_height/(num_songs+1)))
font = ImageFont.truetype(font=FONT3, size=rank_size)
draw.text(xy=rank_xy, text=text, fill='purple', font=font)
# タイトル
title_size = 75
font = ImageFont.truetype(font=FONT4, size=title_size)
title_xy = (left_top[0] + rank_width + 20, left_top[1] + 10)
for n in range(num_songs + 1):
draw.text(xy=title_xy, text=titles[n], fill=(255, 130, 39), font=font)
title_xy = list(title_xy)
title_xy[1] = title_xy[1] + int(table_height/(num_songs+1))
title_xy = tuple(title_xy)
# 再生回数
playcount_xy = (left_top[0] + rank_width + 20, left_top[1] + 110)
playcount_size = 60
font = ImageFont.truetype(font=FONT5, size=playcount_size)
for n in range(num_songs + 1):
text = playcount[n]
if n != 0: # 凡例には「回」を付けない
text += '回'
draw.text(xy=playcount_xy, text=text, fill=0, font=font)
playcount_xy = list(playcount_xy)
playcount_xy[1] = playcount_xy[1] + int(table_height/(num_songs+1))
playcount_xy = tuple(playcount_xy)
# アーティスト
for n in range(num_songs + 1):
artist = '' + artists[n]
artist_xy = (width - side_margin - draw.textsize(artist, font)[0] - 20,
left_top[1] + 110 + n * int(table_height/(num_songs+1)))
artist_size = 65
font = ImageFont.truetype(font=FONT5, size=artist_size)
while draw.textsize(artist, font)[0] > width - 2*side_margin - rank_width - 200:
artist = artist[:-2] + '…'
artist_xy = (width - side_margin - draw.textsize(artist, font)[0] - 20,
left_top[1] + 110 + n * int(table_height / (num_songs + 1)))
font = ImageFont.truetype(font=FONT5, size=artist_size)
draw.text(xy=artist_xy, text=artist, fill='black', font=font)
artist_xy = list(artist_xy)
artist_xy[1] = artist_xy[1] + int(table_height/(num_songs+1))
artist_xy = tuple(artist_xy)
def len_tweet(text):
count = 0
for c in text:
if unicodedata.east_asian_width(c) in 'Na':
count += 1
else:
count += 2
return count
def upload_img_to_dropbox():
dbx = dropbox.Dropbox(DROPBOX_TOKEN)
# dbx.users_get_current_account()
with open('ranking.jpg', "rb") as f:
dbx.files_upload(f.read(), '/ranking.jpg', mode=dropbox.files.WriteMode.overwrite)
with open('ranking_preview.jpg', "rb") as f:
dbx.files_upload(f.read(), '/ranking_preview.jpg', mode=dropbox.files.WriteMode.overwrite)
# ファイルのリンクを取得
# setting = dropbox.sharing.SharedLinkSettings(requested_visibility=dropbox.sharing.RequestedVisibility.public)
# link = dbx.sharing_create_shared_link_with_settings(path='/ranking.jpg', settings=setting)
# links = dbx.sharing_list_shared_links(path='/ranking.jpg', direct_only=True).links
# if links is not None:
# for link in links:
# img_url = link.url
# img_url = img_url.replace('www.dropbox', 'dl.dropboxusercontent').replace('?dl=0', '')
# print(img_url)
# return img_url
# 上の方法だとlink取得時にshared_link_already_existsエラーが出る
# ただしurlは毎回以下のようになる。
return 'https://dl.dropboxusercontent.com/s/thcrs9h1x1031ti/ranking.jpg', \
'https://dl.dropboxusercontent.com/s/thcrs9h1x1031ti/ranking_preview.jpg'
def line_send_message(text, img_url1, img_url2):
user_id = LINE_USER_ID
messages = [
ImageSendMessage(
original_content_url=img_url1,
preview_image_url=img_url2
),
TextSendMessage(text=text)
]
line_bot_api.push_message(user_id, messages=messages)
if __name__ == "__main__":
global period
today = datetime.date.today()
weekday = today.weekday()
day = today.day
month = today.month
if weekday == 6: # Sunday
period = Period.SEVEN_DAYS
main()
if day == 1:
period = Period.ONE_MONTH
main()
if month == 12 and day == 30:
period = Period.TWELVE_MONTH
main()
print(today)
print('process end.')
period変数は最初は引数で渡していこうとしましたが、なぜかif文の比較がうまくできなかったので、グローバル変数にしました。
改良案があれば、コメントもらえると嬉しいです。
##Herokuの前準備
###Herokuに登録
Herokuに登録します。
詳しくはググってください。
###Heroku CLIのインストール
こういうところがちょっと面倒と感じました。
こちらの記事を参照してください。
##Herokuにデプロイ
こちらの記事を見ながら行いました。
# 関連モジュールの一覧を作成
pip freeze > requirements.txt
# ローカルにインストールしたモジュールがすべてが出力されてしまうので、不要なものは削除すること
ここ↑ですが、私の場合はrequirements.txtは下記↓の感じでOKでした。
jsonschema
tweepy
unicodecsv
urllib3
requests
requests-oauthlib
click
line-bot-sdk
pillow
dropbox
chardet
##Herokuでプログラムの定期実行を設定する
これもこちらの記事を参考にしました。Heroku Schedulerというアドオンを使うといいらしいです。
設定は、私の場合は全てブラウザ上でできました。
フィーリングでもいけると思いますが、適宜ググってください。
ちなみに、Heroku Schedulerでは「毎週」という項目がなかったため、「毎日」で設定します。(上のコードもそれが前提です)
また、時間設定は世界協定時刻のみ対応しているらしく、設定したい日本時間から9時間引いた値にします。
毎日午後9時にプログラムを実行したい場合には、下の画像のように設定しました。
##音楽を聞きまくる
たくさん音楽を聞いて、Last.fmでscrobbleしましょう。
##最後に
この記事の編集途中にページ遷移してしまって、戻ると内容がすごく遡った状態になってて青ざめたけど、下書き保存したらちゃんと回復してた。