LoginSignup
2
0

More than 1 year has passed since last update.

【センリのラボノート①】自動生成した画像で自動返信【PythonでTwitterを自動化したよ】

Last updated at Posted at 2022-03-23

 ステップ1「はじめに」

 センリの道も一歩から!
 みんなおはよう~、水彩センリだよ

 今日からなんと、センリのラボノートを公開しちゃうよ
 頑張って書いたから、しっかり読んでね!

 この記事は
  ステップ1「はじめに」…………開発環境や概略
  ステップ2「何事も準備が大事!」…………使用する素材、ファイルの準備
  ステップ3「いざコーディング」…………スクリプト
  ステップ4「細かなとこにも手が届く~」…………スクリプトの説明
  ステップ5「未来のために進歩あるのみ!」…………改善点の洗い出し

 っていう進行をするから、よろしくね!!

 第一回はこれ!

 今日紹介するのは、センリが普段送ってる「画像あいさつ」だよ!

 詳細はこんな感じ!

 画像提供に協力してくれたのは柴犬のフラッフィーさん
 映画の紹介が魅力的な配信者さん!

 映画の同時視聴なんかもしてて、映画好きにはたまらない配信かも!
 今月はサメ映画強化月間らしいから、サメが好きな人は特に見に行ってね~
 →(https://www.youtube.com/channel/UCylf8QLkNLkSYBUAHcnzRTw)

 ポイント

 ・画像は自動生成
 ・お名前部分だけ抽出
 ・リプライ実行も自動

 ラボの環境

 センリの開発環境を説明するね

Windows 10 Home
Visual Studio Code
Python 3.10.1

tweepy 4.5.0
Pillow 8.4.0

 こんな感じ! 下の二つのモジュールはインストールして、
 TwitterのAPI取得は各自終わらせちゃってね~

 参考リンク

 元になったのは@Hisan_twi さんのこの記事!
 →PythonでTwitterの画像生成&自動返信botを作る

 大きな変更点としては、モジュールをtwythonからtweepyにしたことと、改行を入れたところかなぁ

 ステップ2「何事も準備が大事!」

 画像は自動生成って書いてるけど、正確に言うとランダムに素材を組み合わせてるだけなんだ~
 まずは素材の準備から!

 画像素材の準備

 まず背景、写真、吹き出しの三つが必要だよ
 最低1枚ずつでも良いけど、たくさんあると嬉しいかなぁ

 まずは背景、今回は1366 x 768 のサイズで作ってるよ~
 他の素材も同じサイズにすると使いやすいね
背景_レース.png

 これがセンリの写真~
 なんだか恥ずかしいね
キャラ_横向き.png

 最後に吹き出しだね
吹き出し_1.png

 写真と吹き出しは透過画像にしないとダメだからね!

 フォントの準備

 ここは参考記事と全く同じ状態だよ!
 センリ、フォントはよく分からないの……

  はなぞめフォント:https://www.asterism-m.com/font/hanazomefont/
  アプリ明朝:http://flopdesign.com/blog/font/5852/
  やさしさゴシック手書き:http://fontna.com/freefont/?p=40
  あずきフォント:http://azukifont.com/font/azuki.html
  うつくし明朝体:https://www.flopdesign.com/freefont/utsukushi-mincho-font.html
  えり字:http://v7.mine.nu/pysco/gallery/font/06.html

 リンクからダウンロードしたら、"Fonts"って名前のフォルダに入れてね

 ディレクトリ

 それじゃあ、ディレクトリがこんな感じになるようファイル作成&配置してね。

/Senri
├── reply-list.txt  (既返信リスト)
├── done-log.txt     (実行ログ)
├── auth.py       (APIファイル)
├── Functions.py	  (関数ファイル)
├── run.py       (実行ファイル)
├── run.bat           (バッチファイル)
└── /imgReply
	├── /Fonts
	│   ├── UtsukushiFONT.otf
	│   ├── azuki.ttf
	│   ├── えり字.otf
	│   ├── アプリ明朝.otf
	│   ├── はなぞめフォント.ttf
	│   └── やさしさゴシック手書き.otf
	├── /生成画像
	└── /素材画像
	    ├── 背景_緑.png
	    ├── 背景_水色.png
	    ├── 背景_ピンク.png
     ……(以下略)

 ステップ3「いざコーディング!」

 センリはAPIファイルと関数ファイル、実行ファイルの三つに分けて動かしてるよ

 APIは大事に保管したいし、実行ファイルは簡潔にまとめたいからね~

 説明用に用語を定義してるよ

【リプライ】
 センリが受け取るもの

【返信】
 センリが送るもの

【既返信リスト】
 センリが返信し終わったツイートたち
 →何度も返信しないようにリスト化してるよ

 APIファイル

 ここにTwitterAPIを入力するよ

auth.py
consumer_key="xxxxxxxxxxxxxxxxxxxxxxxxxx"
consumer_secret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
access_token="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
access_token_secret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

 実行ファイル

 実際に動かすのはこのスクリプトだよ。
 後でバッチファイルを作成して、これを一定間隔で実行するの!

Run.py
from Functions import *

if __name__ == '__main__':
    TLReply()

 関数ファイル

 中身はここに書き込むよ。
 関数部分と実行部分を分けると、機能追加が楽なんだよ~

 すっごく長いから折りたたんでるよ! 読むときは開いてね!!

 

 関数ファイル、折りたたみ
Functions.py
import sys
import time
import datetime
import random
import re
from PIL import Image, ImageDraw, ImageFont
from logging import getLogger, StreamHandler, FileHandler, DEBUG, Formatter

#今回のTwitterモジュール
import tweepy

#auth.pyに書いてるキーをimportします
from auth import(
    consumer_key,
    consumer_secret,
    access_token,
    access_token_secret)

#Tweepyのおまじないです
auth = tweepy.OAuthHandler(consumer_key, consumer_secret)
auth.set_access_token(access_token, access_token_secret)
api = tweepy.API(auth)

#ログ出力のおまじない
logger = getLogger(__name__)
handler1 = StreamHandler()
handler1.setFormatter(Formatter("%(asctime)s %(levelname)8s %(message)s"))
handler2 = FileHandler("done-log.txt", encoding="utf-8-sig")
handler2.setLevel(DEBUG)
handler2.setFormatter(Formatter("%(asctime)s %(levelname)8s %(message)s"))
logger.setLevel(DEBUG)
logger.addHandler(handler1)
logger.addHandler(handler2)
logger.propagate = False

#君の名は。
my_name = "SuisaiSenri"


def TLReply():
    #同じツイートに何回も返信しないよう、既返信リストを取得
    with open("reply-list.txt", mode='r', encoding="utf-8-sig") as f:
        reader = f.readlines()
    f.close()

    #1行ずつ格納します
    alreadylist = [usr.rstrip('\n') for usr in reader]

    #TLを取得
    try:
        #TLの検索をします
        search = api.home_timeline(count=50, exclude_replies=True, tweet_mode='extended')
    except tweepy.errors.TweepyException as e:
        logger.error(e.msg)
        sys.exit(1)

    #検索結果から自分のツイート、リツイートを省きます
    result = [x for x in search if "RT @" not in x.full_text if x.user.screen_name != my_name]
    #古いツイートから順番に返信したいので、reversedを使います
    tweets = reversed(result)

    for tweet in tweets:
        #既返信リストの構え
        usr = tweet.user.screen_name + ":" + tweet.id_str
        #返信できそうな文言を探します。ここの書き方はもう少し賢い方法があるはず
        if "おはよ" in tweet.full_text or "おやす" in tweet.full_text or "寝る" in tweet.full_text:
            #既返信リストを参照
            if usr not in alreadylist:
                try:
                    if "おはよ" in tweet.full_text:
                        imgReply(tweet, 1)
                    elif "おやす" in tweet.full_text or "寝る" in tweet.full_text:
                        imgReply(tweet, 2)
                except tweepy.errors.TweepyException as e:
                    logger.error(e.msg)
                else:
                    logger.info(f"返信しました:\n{tweet.full_text}")
                    alreadylist.append(usr)
                    #いいねします
                    try:
                        api.create_favorite(tweet.id)
                    except:
                        logger.info(f"いいねに失敗しました:\n@{tweet.user.screen_name}")
                    time.sleep(random.uniform(6,10))
    #既返信リストを書き出します。
    with open("reply-list.txt", mode='w', encoding="utf-8-sig") as f:
        f.write('\n'.join(alreadylist))
    f.close()


#名前の加工部分
def nameProcess(name):
    name = name.rstrip('\n')
    r = r"[\((\[<\{<「『“【[〈{《〔‘].*?$"
    if re.sub(r, "", name) == "":
        r = r"\(.+?\)|(.+?)|\[.+?\]|<.+?>|\{.+?\}|<.+?>|「.+?」|『.+?』|“.+?”|【.+?】|[.+?]|〈.+?〉|{.+?}|《.+?》|〔.+?〕|‘.+?’"
    p = re.sub(r, "", name)
    r = r"[@@].*?$"
    if re.sub(r, "", p) == "":
        r = r"[@@]"
    p = re.sub(r, "", p)
    r = r"[//\||::].+?$"
    if re.sub(r, "", p) == "":
        r = r"[//\||::]"
    p = re.sub(r, "", p)
    r = r"[\u0000-\u007F\uFF01-\uFF0F\uFF1A-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\u3000-\u303F\u3041-\u309F\u30A1-\u30FF\uFF66-\uFF9F\u2E80-\u2FDF\u3005-\u3007\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\U00020000-\U0002EBEF]+"
    m = re.search(r, p)
    if m:
        p = m.group()
    else:
        p = ""
    r = r"[!!]+"
    p = re.sub(r, "", p)
    r = r"bot$|Bot$|BOT$"
    p = re.sub(r, "", p)

    #それでも文字数が過剰な場合、空白以降も消します
    if len(p) > 18:
        r = r"[  ].+?$"
        p = re.sub(r, "", p)

    #名前が消滅している場合もあるので場合分け
    if p != "":
        #名前に最初から敬称が付いてる人はそのまま呼んであげます。他はさん付け
        r = r"たん$|さん$|ちゃん$|くん$|君$|様$"
        if re.search(r, p):
            title = ""
        else:
            title = "さん"    
        p = p + title
    return p


#画像de返信
class imgReply:
    #初期化。nはおはよう、おやすみ等の変化
    def __init__(self, tweet, n):
        self.tweet = tweet
        self.n = n
        self.doReply()


    #返信操作の関数です
    def doReply(self):
        #ユーザーIDを取得します
        usr_screen_name = self.tweet.user.screen_name
        #ユーザー名を加工します
        name = nameProcess(self.tweet.user.name)

        #加工で名前が消滅した場合、表示名はIDを使用します
        #絵文字一個みたいなユーザー名を呼ぶ方法がないので、仕方ないです
        if name == "":
            name = usr_screen_name
        
        #ツイートに添付する画像を用意します
        imgpath = self.genImg(name)
        #リプライのためにはID部分の指定が必要
        ID_text = "@" + usr_screen_name

        #リプライを送信します
        #送信部分。statusに本文を入れても良い。in_replyなんとかはリプライ先のツイートID
        api.update_status_with_media(status=ID_text, filename=imgpath, in_reply_to_status_id=self.tweet.id)


    #画像の生成部分です
    def genImg(self,name):
        #完成画像を置くフォルダです
        imgpath = "./imgReply/生成画像/"

        #写真の一覧です。付け足す際はここに追記します
        imgmat1_path = "./imgReply/素材画像/センリ_ジャンプ.png"
        imgmat2_path = "./imgReply/素材画像/センリ_デフォルト.png"
        imgmat3_path = "./imgReply/素材画像/センリ_ほほえみ.png"
        imgmat4_path = "./imgReply/素材画像/センリ_座り込み.png"
        imgmat5_path = "./imgReply/素材画像/センリ_猫.png"
        imgmat6_path = "./imgReply/素材画像/センリ_横向き.png"
        imgmat7_path = "./imgReply/素材画像/センリ_座り.png"

        #背景画像の一覧です
        bgmat1_path = "./imgReply/素材画像/背景_水色.png"
        bgmat2_path = "./imgReply/素材画像/背景_ピンク.png"
        bgmat3_path = "./imgReply/素材画像/背景_星.png"
        bgmat4_path = "./imgReply/素材画像/背景_花.png"
        bgmat5_path = "./imgReply/素材画像/背景_音符.png"
        bgmat6_path = "./imgReply/素材画像/背景_レース.png"
        bgmat7_path = "./imgReply/素材画像/背景_バラ.png"
        bgmat8_path = "./imgReply/素材画像/背景_緑.png"

        #吹き出し画像の一覧です
        baloon1_path = "./imgReply/素材画像/吹き出し_1.png"
        baloon2_path = "./imgReply/素材画像/吹き出し_2.png"
        baloon3_path = "./imgReply/素材画像/吹き出し_3.png"
        baloon4_path = "./imgReply/素材画像/吹き出し_4.png"

        #フォントの一覧です。この辺の記述、列挙以外の方法ないかな?
        font1_path = "./imgReply/Fonts/はなぞめフォント.ttf"
        font2_path = "./imgReply/Fonts/アプリ明朝.otf"
        font3_path = "./imgReply/Fonts/やさしさゴシック手書き.otf"
        font4_path = "./imgReply/Fonts/azuki.ttf"
        font5_path = "./imgReply/Fonts/UtsukushiFONT.otf"
        font6_path = "./imgReply/Fonts/えり字.otf"

        #それぞれをリスト化します
        imgmat_list = [imgmat1_path, imgmat2_path, imgmat3_path, imgmat4_path, imgmat5_path, imgmat6_path, imgmat7_path]
        bgmat_list = [bgmat1_path, bgmat2_path, bgmat3_path, bgmat4_path, bgmat5_path, bgmat6_path, bgmat7_path, bgmat8_path]
        font_list = [font1_path, font2_path, font3_path, font4_path, font5_path, font6_path]
        baloon_list = [baloon1_path, baloon2_path, baloon3_path, baloon4_path]

        #それぞれから使用する素材をランダムに選択します
        character = Image.open(random.choice(imgmat_list)).copy()
        bgimg = Image.open(random.choice(bgmat_list)).copy()
        baloon = Image.open(random.choice(baloon_list)).copy()

        #背景の上に貼り付けます
        bgimg.paste(baloon,(0,0),baloon)
        bgimg.paste(character,(0,0),character)

        #書き出します。描き出すと言うべき?
        draw = ImageDraw.Draw(bgimg)

        #ここからは画像に入れるセリフを用意します
        if self.n == 1:
            text = random.choice(["おはよう!", "おはよう~", "おはよ~"])
        elif self.n == 2:
            text = random.choice(["おやすみ!", "おやすみ~", "おやすみなさい!"])

        #名前が長い場合の折り返し地点などを計算しています。
        #半角なら0.5文字として計算し、6字単位で折り返します
        row = 1 #行数
        p = 0 #半角を調査
        w = 0 #文字数カウント
        z = [0] #改行位置の記録

        #~さんの直前までを計算対象に
        for x in name[:-2]:
            p += 1
            w += 1
            #英字のchrが65~122なので、それに該当する場合0.5字にする
            for y in range(65, 123):
                if chr(y) == x:
                    p -= 0.5
                    break
            #半角12文字(p==6)を超えたら改行
            if p >= 6 and w != len(name[:-2]):
                #改行位置を記録
                z.append(w)
                p = 0
                row += 1

        #文字の初期位置を調整する値です
        #原点は左上で、varは鉛直方向(下)、horiは水平方向(右)
        var_sets = 230
        hori_sets = 780

        #ランダムなフォントを召喚!
        fonts = random.choice(font_list)

        #名前の長さに応じて文字の大きさを良い感じにします
        if p >= 6:
            font = ImageFont.truetype(fonts,50)
        else:
            font = ImageFont.truetype(fonts,60)

        #一行ずつ書き出します
        for k in range(row):
        
            #最終行でない場合の処理です
            if k+1 != row:
                #名前を一行ずつ表示、100ピクセル下に次の行を書きます
                draw.text((hori_sets, var_sets), name[z[k]:z[k+1]], fill = (0, 0, 0), font = font)
                var_sets += 100

            #最終行はz[k+1]が存在しなくてエラーになるから場合分け
            else:
                draw.text((hori_sets, var_sets), name[z[k]:], fill = (0, 0, 0), font = font)
                var_sets += 100
        #名前を書き終えたので本文を書いて終了です
        draw.text((hori_sets+100, var_sets), text, fill = (0, 0, 0), font = font)

        #画像の生成時間をファイル名としてローカルに保存します
        dt_now = datetime.datetime.now()
        imgname = imgpath + dt_now.strftime('%Y%m%d%H%M%S%f') + ".png"
        bgimg.save(imgname)
        return imgname

 
 
 本当に長くてごめんねぇ、難しいところは今から解説するね!!

 ステップ4「細かなとこにも手が届く~」

 コードの解説をするね。

TLReply().
    try:
        search = api.home_timeline(count=50, exclude_replies=True, tweet_mode='extended')

 exclude_replies をTrueにしないと、みんなのリプライにも返信しちゃって大変だから気をつけてね。

 tweet_modeをextendedにしないと、ツイート本文が120字くらいしか取得できないよ。
 ただし、extendedにすると本文がfull_textって形になるから気を付けてね。普段はただのtextだよ。

TLReply().
    result = [x for x in search if "RT @" not in x.full_text if x.user.screen_name != my_name]

    tweets = reversed(result)

 内包表記で見づらいかもだけど、TLの検索結果からリツイート(本文が"RT @"で始まる)と、センリ自身のツイートを省いてるよ。

 取得した検索結果は時系列が逆順になってるから、一応時系列順にしてるよ

imgReply().
        if name == "":
            name = usr_screen_name

 絵文字だけの名前の人みたいに、上手く名前が取れない人がいるよ。
 その場合はIDで呼んであげるんだ~

imgReply().
        imgpath = self.genImg(name)
        ID_text = "@" + usr_screen_name

        api.update_status_with_media(status=ID_text, filename=imgpath, in_reply_to_status_id=self.tweet.id)

 update_status_with_media()は画像付きツイートだよ。
 statusで本文、filenameで画像パス、in_reply_to_status_idは返信先のツイートIDだね。

 注意として、返信の本文は@(返信先ユーザーのTwitterID)を入れないといけないよ。

genImg().
        character = Image.open(random.choice(imgmat_list)).copy()
        bgimg = Image.open(random.choice(bgmat_list)).copy()
        baloon = Image.open(random.choice(baloon_list)).copy()

        bgimg.paste(baloon,(0,0),baloon)
        bgimg.paste(character,(0,0),character)

 bgimg.pasteでbgimg(背景画像)の上に画像を貼り付けるよ。

 第一引数は貼り付ける画像、第二引数は座標(左上が(0,0))。
 第三引数は画像のトリミングだね~。正方形にしたり円形にしたりできるけど、そのものを渡すと画像の透明部分をトリミングしてくれるよ。

genImg().
        row = 1 #行数
        p = 0 #半角を調査
        w = 0 #文字数カウント
        z = [0] #改行位置の記録

        for x in name[:-2]:
            p += 1
            w += 1
            #英字のchrが65~122なので、それに該当する場合0.5字にする
            for y in range(65, 123):
                if chr(y) == x:
                    p -= 0.5
                    break
            if p >= 6 and w != len(name[:-2]):
                #改行位置を記録
                z.append(w)
                p = 0
                row += 1

 ここは改行の準備だよ。
 参考元の記事では扱ってなかったからセンリが考えたけど、あまり上手なコードじゃないかも。。。

 まずpが、半角を0.5とした文字数カウントだよ。
 半角12文字(p==6)を超えたら改行したいの~。

 何文字目で改行したのか(z)、累計何行なのか(row)の情報を残してるよ。

genImg().
        for k in range(row):
        
            if k+1 != row:
                draw.text((hori_sets, var_sets), name[z[k]:z[k+1]], fill = (0, 0, 0), font = font)
                var_sets += 100

            else:
                draw.text((hori_sets, var_sets), name[z[k]:], fill = (0, 0, 0), font = font)
                var_sets += 100
        draw.text((hori_sets+100, var_sets), text, fill = (0, 0, 0), font = font)

 さっき記録したrowの値についてfor文を回していくよ。
 お名前が3行なら、3回に分けて出力する感じだね~

 自動実行

 出来上がったら、Run.pyを自動で実行するバッチファイルを準備するよ。
 タスクスケジューラで数分おきに実行したら完成~

 ただ実行するだけでも良いんだけど、ウィンドウが毎回出てくるのはイヤだから最小化状態で実行してもらうね

Run.bat
@echo off
cd /d %~dp0

:最小化状態で実行する
if not "%X_MIMIMIZED%"=="1" (
    set X_MIMIMIZED=1
    start /min cmd /c,"%~0" %*
    exit
)

cd C:\Users\...\Senri
python run.py

exit /b

 ステップ5「未来のために進歩あるのみ!」

 いくつかまだまだ直せるところはあると思うの!

 その1

 画像素材を列挙してるけど、もっとスマートな書き方がありそう~
 今は素材が少ないから良いけど、100枚ずつ用意したら大変かも

 別のテキストファイルに書き込むとかかなあ?

genImg().
        imgmat1_path = "./imgReply/素材画像/センリ_ジャンプ.png"
        imgmat2_path = "./imgReply/素材画像/センリ_デフォルト.png"
        imgmat3_path = "./imgReply/素材画像/センリ_ほほえみ.png"
        imgmat4_path = "./imgReply/素材画像/センリ_座り込み.png"
        imgmat5_path = "./imgReply/素材画像/センリ_猫.png"
        imgmat6_path = "./imgReply/素材画像/センリ_横向き.png"
        imgmat7_path = "./imgReply/素材画像/センリ_座り.png"

        imgmat_list = [imgmat1_path, imgmat2_path, imgmat3_path, imgmat4_path, imgmat5_path, imgmat6_path, imgmat7_path]

 その2 名前の操作!

 これ、敬称を「さん」を前提として書いてるけど、ちゃんだったら困りそう……

        for x in name[:-2]:
            p += 1
            w += 1

 その3 これはよくないんだよ~

 採用したフォントが常用外の漢字に対応していない場合があって、こんな風に虫さんに食べられちゃうの……

画像で書きたかったのは呂色 朧さん

朧さんは関西のお山から来た鬼狐!
ご自身で作ったアバターが生き生きと喋る、期待の超新星なんだあ~
→(https://www.youtube.com/channel/UCXb-OHQHFOvhZqPZ3Sy2kZg)

 その4 気にした方が良いかも

 センリ、この頃Twitter運営さんからシャドウバンっていう措置を取られちゃうの……
 どれが原因か分からないけど、もしかしたら返信しすぎてるのかも!?

 送信をフォロワーさんだけに限定するとか、一度の検索数を調整するとか、同じ人について一日に一回しか送らないようにするとか、そういう対応が必要なのかなぁ……

 その5 みんなの声

 コードの間違い、改善案があったらコメントで指摘してくれると成長に繋がって嬉しいんだぁ
 逐一修正していきたいから、どしどし指摘してね!

 次回予告

 ラボノートを公開し、返信に使っている画像が実は自動生成だと周知させたセンリ!

 しかし、水彩ラボと呼ばれる謎の研究所から重大なミスを指摘されてしまう!!
 水彩ラボとは何なのか、センリの犯した重大なミスとは!?

 次回「そんなことよりwordleであそぼう!」
  現在テスト稼働中のごはんwordle機能について紹介しちゃうよ、次回も見てねっ!!

2
0
0

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
2
0