LoginSignup
1

More than 3 years have passed since last update.

過去に聞いた曲のランキングを毎週ツイートするbotを作った(Last.fm)

Last updated at Posted at 2019-10-11

完成物のランキング画像↓
EnzkcTDWEAAs4fc.jpeg

完成物のツイート↓
https://twitter.com/sorarakara/status/1332190766471507970?s=20
Screen Shot 2020-11-27 at 14.54.09.png

どんな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の環境変数に登録して使ってください。
また、デプロイ前にローカル環境でデバックを行い、実際に正しくツイートされることを確認してください。

main.py
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時にプログラムを実行したい場合には、下の画像のように設定しました。
Screen Shot 2019-10-12 at 3.26.24.png

音楽を聞きまくる

たくさん音楽を聞いて、Last.fmでscrobbleしましょう。

最後に

この記事の編集途中にページ遷移してしまって、戻ると内容がすごく遡った状態になってて青ざめたけど、下書き保存したらちゃんと回復してた。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1