いなにわうどん

うどんの話に見せかけて技術的な話をしたい(できない)

3日間。Python でつくる、Twitter カウントダウン Bot

月日はかくも速く流れていくもので、今年度も残すところ11日。皆様も新たな門出を迎えるべく、忙しく準備を進めている最中と存じます。
日本が年号変更の渦中に立たされる中、皆さんお馴染みのTwitterも日本上陸から10年の節目を迎えることになります。最早「Twitterなしでは生きていけねぇぇぇぇ」という方も多いのではないでしょうか。
今回はそんな現代人に必要不可欠な存在であるTwitterにゴミBot機能を追加するお話です。

(もの凄く長いです。35000字あるので目次)

はじめに

Botとは

具体的には奴bot全日本もう帰りたい教会黒塗りの高級車に追突してしまうbotに代表されるようなTwitterアカウントのことです。凄いですねこの人達、四六時中画面の前にでもへばりついているんしょうか……と手作業でこんなに高頻度で更新できる訳もなく、これらはシステムを用いて自動的に投稿されています。【定期】などのツイートが回ってきたら間違いなくそれもBotです。現在はtwittBotなどの手軽にBot開設が出来るサービスも誕生していますが、いかんせん機能に制限が多い、一括処理に対応していない等、使い物にならないものが林立している現状なのでPythonを用いて実際に一からBotを制作したいと思います。

目標

日付、卒業から・新年度/新学期までの日数をカウントダウン(アップ)する画像を生成し、Twitterに投稿するまでの一連の過程を自動で行うプログラムの制作


※画像はイメージです

準備するもの

  • PC

今回はTOIBE IndexC++に次ぐ人気を獲得した、みなさんご存じPythonを用いてコーティングを進めていきます。使い慣れた画像編集ソフトなんかもあると便利かもしれません。

  • A「えっ、ツイッターに自動でツイートしてくれるマシンが作れるの?でもぼくみたいな無知な人でも大丈夫かな?」
  • B「大丈夫大丈夫。ソースコード付きで解り易く解説するから心配ないですよ」
  • A「お、じゃぁ早速教えてよ」
  • B「まずpipからpillowモジュールをインストールした後、import文を用いてPILを読み込む。更にImage.new関数をImageクラスをインスタンス化し、ImageFontクラスにTrueTypeのソースを…」
  • A「は?」
  • B「いやだからImageFont」
  • A「は?」

一日目:画像を自動生成しよう!!

ただ「卒業から〇〇日」「新年度まで〇〇日」とテキストで書くのもつまらないので、華やかに画像でも掲載してみましょう。一日目はそんな画像処理のお話。
PythonにはPillow(PIL)という鉄板ライブラリがあるので早速利用します。OpenCVの方が知名度は高いと思いますが、敢えてのPillowを採用した理由はフォントが変更できるから。それに尽きる。微に入り細を穿つ画像生成、ここに極まれり…

コマンドプロンプトからpip(パッケージ管理システム)を用いてパッケージを導入。

pip install Pillow

新たな.pyファイルを作成し、PILモジュールをインポートします。

from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance

まずは画像生成

手始めに、画像生成用のクラスを定義し、コンストラクタを設けます。中身は指定されたサイズでPillowのImageインスタンスを作成するというもの。生成したImageオブジェクトはインスタンス変数(メンバ変数)としてクラス内部に保持させています。後に機能を加えることも考え、テキスト描画用のインスタンス変数も定義しておきます。

class CreateImage :
	def __init__(self, size, log) :
		self.imgSize = size;
		self.img = Image.new("RGB", size, (255, 255, 255))
		self.text = { "font" : None, "size" : None, "rgba" : None }

レイヤーという提案

画像編集に付き物な機能として、「レイヤー」というものがあります。
例えば写真に文字を加える場合、画像にそのまま書き込むのではなく、テキストレイヤーを生成し、加工などを施した上で最後に1枚の画像に纏めて出力するのです。今は無きセルアニメでいう「セル」の部分に当たります。
あんな感じの機能があると写真やテキストを加工するのに便利ですね。仕組みを取り入れましょう。

CreateImageクラスのインスタンス変数として、self.imgの他にもう一つ、self.nextImgというImageインスタンスを保持するための変数を設けておきます。以後、self.imgをメインレイヤー、self.nextImgをサブレイヤーと呼称することにします。この二つを擬似レイヤーとして活用していきましょう。
まず、self.nextImgを指定された色で初期化する関数(メソッド)を定義します。引数にselfを取るあたり、Pythonっぽいですね。なお、戻り値にself(CreateImageクラス)を指定しているのは、img.関数().関数()…と連続して呼び出す為です。

#CreateImage
def addNext(self, r, g, b) :
	self.nextImg = Image.new("RGBA", self.imgSize, (r, g, b, 0))
	return self

次いで、サブレイヤーの内容をメインレイヤーにコピーする関数を定義します。

#CreateImage
def addImage(self, pos = (0, 0) , size = None) :
	size = size if size else self.imgSize
	self.img.paste(self.nextImg, pos, self.nextImg)

今回の画像生成では、
サブレイヤー:初期化 → 要素追加 → 加工 → メインレイヤーにコピー
といった流れで工程を勧めていきます。

それでは早速、サブレイヤーに諸々の要素を追加するための関数を定義していきます。

テキストの生成

画像に掲載したいものは

  • 月/日/曜日
  • 「卒業から~」、「新年度/新学期まで残り~」の日数

の2つです。これらの値は毎日更新する必要がありますが、例えば「新年度まで残り~」というテキストを表示する場合、その部分を毎日変更する必要はありません。
プログラムの効率化を図る為、事前にテンプレート用の画像を準備しておきます。画像サイズは1000px×500px、Illustratorを用いて作成しました。

テンプレートの画像を追加するため、サブレイヤーに画像を追加するための関数を設けます。その名もnextImage。Image.Open()で画像を展開し、Image.pasteでself.nextImgに貼り付けを行います。位置やサイズに関しては、指定がない限りは既定値を使用します。

#CreateImage
def nextImage(self, src, pos = (0, 0) , size = None) :
	size = size if size else self.imgSize
	img = Image.open(src).resize(size)
	self.nextImg.paste(img, pos)
	del img
	return self

文字生成用の関数も定義します。ImageFontインスタンスにTrueType/OpenTypeのフォントファイル及びテキストサイズを指定し、ImageDrawオブジェクトを作成、ImageDraw.text()を用いて出力します。この際、コンストラクタ指定時に言及したself.textに値を指定しておくことで、文字色やフォント、サイズの個々の指定を省くことが可能になるようにしています。

def nextText(self, text, pos, rgba = None, font = None, size = None) : 
	rgbaSet = rgba if rgba else self.text["rgba"]
	color = (rgbaSet[0], rgbaSet[1], rgbaSet[2], round(rgbaSet[3] * 255))
	fontSet = font if font else self.text["font"]
	sizeSet = size if size else self.text["size"]
	imagefont = ImageFont.truetype(font = fontSet , size = sizeSet , encoding = "unic")

	draw = ImageDraw.Draw(self.nextImg)
	draw.text(pos, text = text, imagefont = fontSet, fill = color)
	del draw
	return self

続いて関数呼び出し側に入ります。CreateImageクラスをインスタンス化した後、サブレイヤーを使用するためにaddNext()を呼び出し、続けてnextImage()やnextText()を指定した後、最後にaddImage()によってメインレイヤーにコピーしています。addNext()の引数には、文字色と同じ色を指定したほうが無難です(綺麗に仕上がります)。
実際にmainスコープから呼び出してみましょう。取り敢えずは曜日の出力から。

if __name__ == "__main__"
	imgSize = (1000, 500)
	textColor = (255, 255, 255, 1.0)
	fontSrc = {
		"ShinGo-EL" : フォントファイルのパス,
		"Gotham-Thin" : フォントファイルのパス
	}
	imgSrc = {
		"template" : "img/template.png",
		"template-date" : "img/template-date.png",
	}
	
	img = CreateImage(imgSize, log)
	img.addNext(255, 255, 255).nextImage(imgSrc["template"]).addImage()
	img.addNext(255, 255, 255).nextImage(imgSrc["template-date"]).addImage()
	img.text["rgba"] = textColor
	img.text["font"] = fontSrc["ShinGo-EL"]
	img.addNext(255, 255, 255).nextText("金", (311,380), size = 70).addImage()

おおーー

「卒業から」といった部分も埋めていきます。

#if __name__ == "__main__"
x = 704
y = 194
img.text["size"] = 120
img.addNext(255, 255, 255).nextText("00", (x, y - 150))
img.nextText("00", (x, y))
img.nextText("00", (x, y + 150)).addImage()

さらっと20行程度で終わってしまい癪なのですが(微調整にかなり時間掛かっています)
……まあいいや、次いで月日を入れていきます。フォントは報道ステーションなんかで利用されているGothamを使用。と、ここで問題なのが日付の部分。

日付の部分は右寄せで表現しようと思ったのですが、一ヶ月って1から30まであるじゃないですか。それはゼロパディングで解決するとして、焦点はその文字幅。欧文フォントは「0」「1」「2」…とみんな文字幅が異なるわけです。故に右揃えが出来ない。全体での出力は諦めて、文字列の幅を取得した後、while文で一文字ずつ出力していくしかなさそう…

文字例の幅はImageDraw.textSize()で取得出来ないこともないのですが、全く当てにならない値をばかり返してくるため、力業で0─9の文字幅を計測、配列に突っ込んでおき、出力の際に参照するという形を取りました。計測にはGIMPを使用。

#CreateImage
def nextDateText(self, text, pos, rgba = None, font = None, size = None) :
	GothamThinWidth = [115, 33, 96, 97, 112, 97, 101, 92, 104, 100]
	rgbaSet = rgba if rgba else self.text["rgba"]
	color = (rgbaSet[0], rgbaSet[1], rgbaSet[2], round(rgbaSet[3] * 255))
	fontSet = font if font else self.text["font"]
	sizeSet = size if size else self.text["size"]
	imagefont = ImageFont.truetype(font = fontSet , size = sizeSet , encoding = "unic")

	draw = ImageDraw.Draw(self.nextImg)
	i = len(text) - 1
	totalWidth = 0
	while i >= 0 :
		char = text[i]
		totalWidth += GothamThinWidth[int(char)] + size / 10
		posSet = (pos[0] - totalWidth, pos[1])
		draw.text(posSet, text = char, font = imagefont, fill = color)
		i -= 1;
	del draw
	return self

#if __name__ == "__main__" :
img.text["font"] = fontSrc["Gotham-Thin"]
img.addNext(255, 255, 255).nextText("4"), (62,110), size = 120)
img.nextDateText("00", (420,170), size = 200).addImage()


日時の取得

ところで、日付の部分が 00 になっています。そりゃそうだ。コード内に文字列で日付を直に記述するのではなく、今日の日付を取得して出力するように修正すれば良い訳です。
こちらはPython標準ライブラリであるdatetimeを使用します。

import datetime

datetime.datetime.now()で日付+時間が、datetime.date.today()で日付が取得可能です。
日時を取得し、辞書(連想配列)に格納しています。先述の通り、適宜ゼロパディング処理を施し、文字列に変換しています。

datetime.date(年, 月, 日)でdateオブジェクトを初期化することも可能です。「残り~日」の部分に使用する為、卒業/新年度/新学期のdateオブジェクトを作成しておきます。

#if __name__ == "__main__" :
weekday = ("月", "火", "水", "木" ,"金", "土", "日")
time = { "datetime" : datetime.datetime.now() }
time["year"] = time["datetime"].year
time["month"] = time["datetime"].month
time["day"] = time["datetime"].day
time["time"] = time["datetime"].strftime("%H:%M:%S:%f")
time["date"] = "%d.%d/%d" % (time["year"], time["month"], time["day"])
time["day-of-week"] = weekday[time["datetime"].weekday()]

date = {
	"today" : datetime.date.today(),
	"graduation" : datetime.date(2018, 3, 9),
	"new-year" : datetime.date(2018, 4, 1),
	"new-semester" : datetime.date(2018, 4, 6)
}

日付の差分については普通の計算のように、dateオブジェクトと減算演算子(-)を用いて行なうことが出来ます。0を過ぎた場合はmax()を用いて、00でカウントストップする仕様にします。

datedelta = {
	"graduation" : "d" % (date["today"] - date["graduation"]).days,
	"new-year" : "d" % max((date["new-year"] - date["today"]).days, 0),
	"new-semester" : "d" % max((date["new-semester"] - date["today"]).days, 0)
}

それでは、mainスコープのコードにも修正を入れていきます。

#if __name__ == "__main__"
img.addNext(255, 255, 255).nextText(str(time["month"]), (62,110), size = 120)
img.nextDateText(str(time["day"]), (420,170), size = 200)
img.nextText(time["day-of-week"], (311,380), font = FontSrc.ShinGoEL, size = 70).addImage()

img.addNext(255, 255, 255).nextText(datedelta["graduation"], (x, y - 150))
img.nextText(datedelta["new-year"], (x, y))
img.nextText(datedelta["new-semester"], (x, y + 150)).addImage()

〈画像処理編・完結〉


というのもなんなので。


背景写真を入れる


ただ無地に文字を載せるのも淋しい話なので、背景画像をどーんと載せてみます。写真もbingみたいに日替わりだと素敵ですね。春っぽい画像と、郷愁を誘うために母校の写真を10―20枚程用意してみました。粋な演出。これを上手い具合に組み合わせていきましょう。

#if __name__ == "__main__"
imgSrc = {
	"spring" : "img/spring/0.png",
	"template" : "img/template.png",
	"template-date" : "img/template-date.png"
}
img.addNext(255, 255, 255).nextImage(imgSrc["spring"]).addImage()

アルファチャンネルも設定したし、これでOK!……と言いたい、言いたかった。
が、どうもテンプレート画像の背景が透けない。――あっ、透過PNGで保存したかと思ったら間違えてJPEGで保存してましたテヘペロ、とかいう話ではないです。
どうもImage.Pasteを使用する際にはマスクを指定する必要があるんだとか。

先程のaddImage関数に改良を加え、self.img.pasteの第3引数にマスク画像を読み込ませるようにしました。また、別画像のアルファチャンネルをマスクとして使用する機能も加えておきます。

#CreateImage
def nextImage(self, src, pos = (0, 0) , size = None, alpha = None) :
	size = size if size else self.imgSize
	img = Image.open(src).resize(size)
	alphaImg = Image.open(alpha).resize(size).convert("RGBA") if alpha else img
	self.nextImg.paste(img, pos, alphaImg)
	del img, alphaImg
	return self


ガンマ補正

少し色が薄いですね。ガンマ補正でも掛けてみましょうか――といってもPillowにはガンマ補正なんて高度な機能はない訳です(OpenCVにしておけば良かった)。以前「ガンマ補正は配列に変換した後ルックアップテーブルを使ってやるのが定説だよ!」と何処かのドキュメントでお見かけしたので、Numpyの多次元配列に変換してからの処理を試みます。

という訳で、高機能数学ライブラリ・Numpyをpipから導入。

pip install Numpy

迷わずimport。Numpyはnpという別称を付けてインポートするのが通例です。

import Numpy as np

ガンマ補正y = 255(x/255)^{(1/\gamma)}の式を参考に、ルックアップテーブルを作成。Numpy.vectorize()で要素一つ一つに独自の関数を適応することができるので、ラムダ式を用いて記述します。

#CreateImage
def nextGamma(self, gamma) :
	lookup = [min(round((i / 255) ** (1 / gamma) * 255), 255) for i in range(256)]
	imgArray = np.array(self.nextImg)
	toGamma = np.vectorize(lambda x : lookup [x])
	imgArray = toGamma(imgArray)
	self.nextImg = Image.fromarray(np.uint8(imgArray))
	del imgArray
	return self

#if __name__ == "__main__"
	img.gamma(0.8)

いい感じ。


母校入れ忘れた


アルファチャンネルを駆使

折角アルファチャンネルを利用したマスク機能まで付けたんですから、存分に活用していきましょう。この画像の上に先程の桜の画像を重ね、


この画像のアルファチャンネルを用いてマスクをかけていきます。

#if __name__ = "__main__" :
imgSrc = {
	"minamigawara" : "img/minamigawara/0.png",
	"spring" : "img/spring/0.png",
	"template" : "img/template.png",
	"template-date" : "img/template-date.png",
	"cherry-blossom" : "img/cherry-blossom_blur.png"
	"spring-alpha" : "img/cherry-bg-alpha.png",
}

img.addNext(255, 255, 255).nextImage(imgSrc["minamigawara"])
img.nextImage(imgSrc["spring"], alpha = imgSrc["spring-alpha"]).nextGamma(0.6).addImage()

もう少しインパクトのある構図にしたいですね。日付の部分に座布団(文字の下に敷くあれ)が欲しい。春ですし、開花宣言も間近の桜(花びら)なんかいいんじゃないですか。

桜の部分だけ切り抜いて透過pngで保存。少しぼかして被写界深度浅めを演出してみました。本来はプログラムで後ろの背景をぼかし処理したいところですが、そうするとまた煩雑な処理に追われることになるので、桜の画像に予めガウシアンぼかしを掛けておきます。

画像の縦横比を合わせるため、縦/横どちらかの値を指定すれば、比率を保持したままサイズをタプルの形式で投げてくれる関数も定義しました。

def getImageSize(self, imgSrc, size, angle) :
	img = Image.open(imgSrc)
	if angle == "width" :
		return (size , int(round(img.height / img.width * size)))
	else :
		return (int(round(img.width / img.height * size)), size)


ランダムで選択される画像

このようなファイル階層にしておきます。

img
┣ minamigawara
┃ ┣ 0.png
┃ ┗ 1.png ...(学校の画像)
┗ cherry
  ┣ 0.png
  ┗ 1.png ...(桜の画像)

先程、Numpy というライブラリをご紹介しましたが、この中に乱数を返す関数が存在します。Numpy.random.rand()を指定すると 0―1.0 の間で乱数を生成してくれるので、生成された値に画像の枚数を乗算し、四捨五入することで、画像をランダムに選択するように改良を加えます。前回の作動と同じ画像を選択することがないよう、乱数の結果をテキストファイルに記録しておくことで、重複を避けています。

def random(path, max) :
	if os.path.exists(path) :
		with open(path, "r", newline="") as file :
			lastRand = rand = file.read()
			while rand == lastRand:
				rand = str(round(np.random.rand() * max))
	else :
		rand = str(round(np.random.rand() * max))

	with open(path, "w", newline="") as file :
		file.write(rand)
	return rand

#if __name__ == "__main__" :
imgRand = {
		"minamigawara" : random("new_data/minamigawara.rand", 5),
		"spring" : random("new_data/spring.rand", 9)
	}
	imgSrc = {
		"minamigawara" : "img/minamigawara/" + imgRand["minamigawara"] + ".png",
		"spring" : "img/spring/" + imgRand["spring"] + ".png",
	}

シャドウをつける

なんか文字見にくいなー、と思っていたところ、友人がPIL/Pillow チートシート - Qiitaというページを発見してくれました。ありがとうございます、早速使わて頂きましょう。文字の下にシャドウ(影)をつけ、文字の視認性をアップさせます。
シャドウは文字を膨張させ(縁取り)、ぼかしをかけることで実現することが可能です。

ImageFilterモジュールのImageFilter.MaxFilter()を用いて膨張を、ImageFilter.GaussianBlur()を用いてガウスぼかしを実装しています。また、色を変更するためにImageEnhance.Brightness()を用いて画像の明度を調節する関数も作成しました。

#CreateImage
def newExpansion(self, count) :
	for i in range(count) :
		self.nextImg = self.nextImg.filter(ImageFilter.MaxFilter())
	return self

def nextBlur(self, blur) :
	self.nextImg = self.nextImg.filter(ImageFilter.GaussianBlur(blur))
	return self

def nextBrightness(self, brightness) :
	enhancer = ImageEnhance.Brightness(self.nextImg)
	self.nextImg = enhancer.enhance(brightness)
	del enhancer
	return self

ぼかし部分のみ抜粋して掲載してみます。

#if __name__ = "main" :
shadowColor = (0, 0, 0, 1.0)
dateShadowColor = (133, 67, 105, 1.0)
dateShadowRgb = (dateShadowColor[0], dateShadowColor[1], dateShadowColor[2])
blur = 6.0
expansion = 2

#テンプレート
img.addNext(0, 0, 0).nextImage(imgSrc["template"]).nextBrightness(0).newExpansion(expansion + 2).nextBlur(blur).addImage()
img.addNext(*dateShadowRgb).nextImage(imgSrc["template-date_blur"]).newExpansion(expansion).nextBlur(blur).addImage()

#日付
img.text["rgba"] = dateShadowColor 
img.addNext(*dateShadowRgb).nextText(str(time["month"]), (62,110), size = 120)
img.nextDateText(str(time["day"]), (420,170), size = 200)
img.nextText(time["day-of-week"], (311,380), font = FontSrc.ShinGoEL, size = 70).newExpansion(expansion + 2).nextBlur(blur).addImage()

#カウントダウン
img.text["rgba"] = shadowColor
img.addNext(0, 0, 0).nextText(datedelta["graduation"], (x, y - 150))
img.nextText(datedelta["new-year"], (x, y))
img.nextText(datedelta["new-semester"], (x, y + 150)).newExpansion(expansion).nextBlur(blur).addImage()


画像の出力

最後に画像を保存しましょう。今回は新たにnew_imgというフォルダを作成し、その中に保存していくこととします。ファイル名は日時。
実際はImage.save()関数をただラッピングしただけの関数です。実にオブジェクト指向じゃないですか!(?)

def save(self, filePath, fileType) :
	self.img.save(filePath, fileType)

呼び出し側では引数fileTypeに保存形式(PNG)、ファイルパスを指定。ファイル名には月日を表記する際よく使用される /(スラッシュ)や :(コロン)が使用出来ないため

YYYY-MM-DD.hh-mm-ss.png

といった形式で出力しています。datetime.strftime()を用いて、適当な形に整形します。

#if __name__ == "__main__" :
mediaPath = "new_img/" + time["datatime"].strftime("%Y-%m-%d.%H-%M-%S") + ".png"
img.save(mediaPath, "PNG")

2日目 Twitter APIを操作しよう!!

毒にも薬にもならない文章をここまでお読みいただいて有難うございます。
画像の方は一丁あがり!ということで続いて投稿の処理作業に移りたいと思います。Twitterをプログラムから操作する場合、公式に提供されている Twitter REST API というものを使用することが出来ます。

OAuth認証

基本的にはTwitter側から指定されたURLにHTTPSPOSTリクエストを用いて送信するだけなのですが、アカウント乗っ取りが散見される昨今Twitter側も対策を講じたようで、現在はOAuth認証という方法が取られています。……と、このOAuth認証というのが大変厄介な代物で、ぶっちゃけ常人には理解出来不能。幸いPythonには通信系のライブラリも充実してるので、今回はrequests_oauthlibというライブラリを活用することにします。

なお、TwitterのAPI仕様はオープンに公開されています。有志の方が日本語に翻訳して下さったようなので、参考にさせて頂きました。
検索用API - Twitter 開発者ドキュメント 日本語訳

下記のページ(英文)から登録を行うことで、APを利用する為の識別子(トークン)を取得することが可能です。
https://apps.twitter.com/

取得した「Consumer Key」「Consumer Secret」をメモします。
他人にアプリケーションを提供する(連携アプリケーション)の場合は更に認証を挟む必要がありますが、API取得者本人のアカウントの場合はアクセストークンを取得することが可能なため、「Access Token」「Access Token Secret」もメモします。

Python側ではRequestsrequests_oauthlib、並びに標準ライブラリのjsonを使用するため、例によってpipからインストールし、インポートします。

pip install Requests
pip install requests_oauthlib
import requests
from requests_oauthlib import OAuth1
import json

Twitterクラスを定義し、先程のトークンとURLを辞書に定義します。コンストラクタでOAuthを実体化し、認証セッションを開始します。

class Twitter :
	API = {
		"CONSUMER_KEY" : コンシューマーシークレット,
		"CONSUMER_SECRET" : コンシューマーシークレット,
		"ACCESS_TOKEN" : アクセストークン,
		"ACCESS_SECRET" : アクセストークンシークレット
	}
	URL = {
		"update" : "https://api.twitter.com/1.1/statuses/update.json",
		"media" : "https://upload.twitter.com/1.1/media/upload.json"
	}

ツイートの投稿

"https://api.twitter.com/1.1/statuses/update.json

に各種パラメータを設定した上でリクエストを送信することで、Twitterへの投稿が可能となります。

Twitterクラスにupdate関数を内包させました。OAuth1インスタンスを作成し、コンシューマーキー、コンシューマーシークレット、アクセストークン、アクセスシークレットの順で引数に渡します。次いで各種パラメータを設定し、requests.postの引数にURL、パラメーター、OAuth1オブジェクトを指定してリクエストを送信します。(仕様はこちら
最後にHTTPステータスコードを確認し、200が返ってきた場合は投稿成功となります。成功した場合はTrueを、失敗した場合はエラーが発生した場合はFalseを返すと伴にJSONを解析し、HTTPステータスコード/エラーコード/メッセージを返すように定義しました。

#Twitter
def update(self, txt, mediaId) :
	params = { "status" : txt, "media_ids" : (mediaId) }
	request = requests.post(Twitter.URL["update"], params = params, auth = self.OAuth)
	requestJson = json.loads(request.text)

	if request.status_code == 200 :
		print("投稿に成功しました\n")
		print(self.log.add("・id:%s" % requestJson["id"])
		print("・投稿内容:%s" % requestJson["text"])
		return True;
	else :
		errorMsg = requestJson["errors"][0]
		print("投稿に失敗しました")
		print("・HTTPステータスコード\t:%s" % (request.status_code))
		print("・エラーコード\t:%s" % (errorMsg["code"]))
		print("・エラーメッセージ\t:%s" % (errorMsg["message"]))
		return False;

早速呼び出してみます。(Twitterでは同内容の連続投稿はスパム扱いされるため、投稿内容に日時を追加)

twitter = Twitter()
twitter.update("PythonからTwitter APIを叩くテストです!!\n" + time["date"] + " " + time["time"])

いい感じです。

なんか急に画像も地味になってつまんねぇなおい、といったところでしょう。画像を投稿してみます。

media-idの取得

画像は

https://upload.twitter.com/1.1/media/upload.json

にバイナルファイルを渡してアップロードを完了させ、返ってきたJSONに含まれるmedia_idを先程のツイートのパラメータに付加することで、投稿することが可能です。(仕様はこちら
オブジェクト指向らしく関数も分けてみました。メディアファイルをバイナリで読み込んだ後、filesというパラメータを設定してAPIを叩きます。投稿に失敗した場合は先程と同様に、成功した場合はJSON形式で返ってきたmedia-idを返り値として渡します。

#Twitter
def mediaUpload(self, mediaSrc) :
	media = open(mediaSrc, "rb")
	params = { "media" : media }
	request = requests.post(Twitter.URL["media"], files = params, auth = self.OAuth)
	requestJson = json.loads(request.text)

	if request.status_code == 200 :
		print("画像のアップロードに成功しました\n")
		print("・media-id:%s" % requestJson["media_id"] + "\n")
		return {"state" : True, "media-id" : requestJson["media_id"] }
	else :
		errorMsg = requestJson["errors"][0]
		print("画像のアップロードに失敗しました")
		print("・HTTPステータスコード:%s" % request.status_code)
		print("・エラーコード:%s" % errorMsg["code"])
		print("・エラーメッセージ:%s" % errorMsg["message"] + "\n")
		return { "state" : True, "status-code" : request.status_code, "code" : errorMsg["code"], "message" : errorMsg["message"] }

def update(self, txt, mediaId) :
	params = { "status" : txt, "media_ids" : (mediaId) }
	(後略)

それでは画像を実際に投稿してみましょう。

twitter = Twitter()
media = twitter.mediaUpload("iya_sonorikutsuha.jpg")
if media["state"] :
	twitter.update("PythonからTwitter APIを叩くテストです!!", media["media-id"])

どどどん!!

それでは、先程生成した画像と組み合わせてみます。

text = "殺伐としたタイムラインに春休み終焉を告げる足音が!!"
media = twitter.mediaUpload(mediaPath)
	if media["state"] :
		twitter.update(text, media["media-id"])


3日目:実際に動かしてみよう

ログなんかも出してみると本格的かもしれません。CreateImageやTwitterクラスのコンストラクタにLogクラスのインスタンスを渡し、要所要所でログを出力してもらう様にしました。

class Log :
	def __init__(self) :
		self.text = ""

	def add(self, text) :
		self.text += text + "\r\n"
		print(text)

	def save(self, path) :
		with open(path, "a", newline="") as file :
			file.write(self.text + "\r\n")

#if __name__ == "__main" :
log = Log()
log.add("実行日時:" + time["date"] + "(" + time["day-of-week"] + ")" + time["time"])
log.save("new_data/data.log")

サーバーにアップロード

さて、この.pyを何処で動かすか、という話になります。タスクスケジューラ(cron)などを用いて自宅PCで動かすのも一つの手ですが、電気代の0が一桁くらい上がりそうな気もするのでレンタルサーバ上で動かすことにします。あっ、勿論無料で動かすんですよ。

今回使用させて頂いたのは「XREA」という無料サーバー。Pythonが使える数少ないレンタルサーバーのうちの一つです。
www.xrea.com

Python3の構文が反応しない、画像が上手く透過されない…と紆余曲折を経ること4時間、ようやくサーバー上で実行させることに成功しました。

SSHを用いてサーバーにログインし、pipによって各種モジュールをインストールします。


加えて先程のPythonソースコードの冒頭に

#!/usr/local/bin/python
# -*- coding: utf-8 -*-
print('Content-type: text/html; charset=UTF-8\n')

と記述した上で、FFFTPを用いて転送し、パーミッションを705に変更します。

また.htaccessを作成、

AddHandler cgi-script .py

と記述し、.pyファイルをCGIスクリプトとして作動させるように設定することで、PythonをWebサーバー上で作動させることが可能です。

cronの設定

ただ、このサーバーcron(一定時刻に命令を実行するコマンド)が使用できないんですね。則ち、定期実行ができないと。こりゃまずいということで外部サービスを利用します。
サーバー同様今回利用したのは、cron-job.orgというサイトです。UIは英語かドイツ語限定なのですが、簡潔な操作で操作を完了させることが出来ます。

Free cronjobs - from minutely to once a year. - cron-job.org

―― 祝 ☆ 完成!! ――

結びに代えて

というわけで、今回はPythonを用いたTwitterBotの開発過程を解説させていただきました。
やはりPythonスマートですね。140字しか呟くことが出来ないツイッターも、API仕様を調べてみると案外奥が深いことを実感した次第です。是非今後の参考にしていただければと思います。ん?スパゲティ(コード)とか言うんじゃねぇ。

ソースコード

最後に、実際にサーバーにアップロードしたソースコードを掲載してさせて頂きます。

#!/usr/local/bin/python
# -*- coding: utf-8 -*-
import os, datetime, csv, json
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter, ImageEnhance
import requests
from requests_oauthlib import OAuth1

print('Content-type: text/html; charset=UTF-8\n')

class Twitter :
	API = {
		"CONSUMER_KEY" : コンシューマーキー,
		"CONSUMER_SECRET" : コンシューマーシークレット,
		"ACCESS_TOKEN" : アクセストークン,
		"ACCESS_SECRET" : アクセストークンシークレット
	}
	URL = {
		"update" : "https://api.twitter.com/1.1/statuses/update.json",
		"media" : "https://upload.twitter.com/1.1/media/upload.json"
	}

	def __init__(self, log) :
		self.OAuth = OAuth1(Twitter.API["CONSUMER_KEY"], Twitter.API["CONSUMER_SECRET"], Twitter.API["ACCESS_TOKEN"], Twitter.API["ACCESS_SECRET"])
		self.log = log

	def mediaUpload(self, src) :
		media = open(src, "rb")
		params = { "media" : media }
		request = requests.post(Twitter.URL["media"], files = params, auth = self.OAuth)
		requestJson = json.loads(request.text)

		if request.status_code == 200 :
			self.log.add("画像のアップロードに成功しました")
			self.log.add("・media-id:%s" % requestJson["media_id"])
			return {"state" : True, "media-id" : requestJson["media_id"] }
		else :
			errorMsg = requestJson["errors"][0]
			self.log.add("画像のアップロードに失敗しました・HTTPステータスコード:%s" % request.status_code)
			self.log.add("・エラーコード:%s" % errorMsg["code"])
			self.log.add("・エラーメッセージ:%s" % errorMsg["message"])
			return { "state" : True, "status-code" : request.status_code, "code" : errorMsg["code"], "message" : errorMsg["message"] }

	def update(self, txt, mediaId) :
		params = { "status" : txt, "media_ids" : (mediaId) }
		request = requests.post(Twitter.URL["update"], params = params, auth = self.OAuth)
		requestJson = json.loads(request.text)

		if request.status_code == 200 :
			self.log.add("投稿に成功しました")
			self.log.add("・id:%s" % requestJson["id"])
			self.log.add("・投稿内容:%s" % requestJson["text"])
			return True;
		else :
			errorMsg = requestJson["errors"][0]
			self.log.add("投稿に失敗しました")
			self.log.add("・HTTPステータスコード\t:%s" % (request.status_code))
			self.log.add("・エラーコード\t:%s" % (errorMsg["code"]))
			self.log.add("・エラーメッセージ\t:%s" % (errorMsg["message"]))
			return False;


class CreateImage :
	def __init__(self, size, log) :
		self.imgSize = size;
		self.img = Image.new("RGB", size, (255, 255, 255))
		self.log = log
		self.text = { "font" : None, "size" : None, "rgba" : None }

	def addImage(self, pos = (0, 0) , size = None) :
		size = size if size else self.imgSize
		self.img.paste(self.nextImg, pos, self.nextImg)
		
	def addNext(self, r, g, b) :
		self.nextImg = Image.new("RGBA", self.imgSize, (r, g, b, 0))
		return self

	def nextImage(self, src, pos = (0, 0) , size = None, alpha = None) :
		size = size if size else self.imgSize
		img = Image.open(src).resize(size)
		alphaImg = Image.open(alpha).resize(size).convert("RGBA") if alpha else img
		self.nextImg.paste(img, pos, alphaImg)
		del img, alphaImg
		return self

	def nextText(self, text, pos, rgba = None, font = None, size = None) : 
		rgbaSet = rgba if rgba else self.text["rgba"]
		color = (rgbaSet[0], rgbaSet[1], rgbaSet[2], round(rgbaSet[3] * 255))
		fontSet = ImageFont.truetype(font = (font if font else self.text["font"]), size = (size if size else self.text["size"]), encoding = "unic")
		
		draw = ImageDraw.Draw(self.nextImg)
		draw.text(pos, text = text, font = fontSet, fill = color)
		del draw
		return self

	def nextDateText(self, text, pos, rgba = None, font = None, size = None) :
		GothamThinWidth = [115, 33, 96, 97, 112, 97, 101, 92, 104, 100]
		rgbaSet = rgba if rgba else self.text["rgba"]
		color = (rgbaSet[0], rgbaSet[1], rgbaSet[2], round(rgbaSet[3] * 255))
		fontSet = ImageFont.truetype(font = (font if font else self.text["font"]), size = (size if size else self.text["size"]), encoding = "unic")

		draw = ImageDraw.Draw(self.nextImg)
		i = len(text) - 1
		totalWidth = 0
		while i >= 0 :
			char = text[i]
			totalWidth += GothamThinWidth[int(char)] + size / 10
			posSet = (pos[0] - totalWidth, pos[1])
			draw.text(posSet, text = char, font = fontSet, fill = color)
			i -= 1;
		del draw
		return self

	def getImageSize(self, src, size, angle) :
		img = Image.open(src)
		if angle == "width" :
			return (size , round(img.height / img.width * size));
		else :
			return (round(img.width / img.height * size), size);

	def nextBlur(self, blur) :
		self.nextImg = self.nextImg.filter(ImageFilter.GaussianBlur(blur))
		return self

	def nextGamma(self, gamma) :
		lookup = [min(round((i / 255) ** (1 / gamma) * 255), 255) for i in range(256)]
		imgArray = np.array(self.nextImg)
		toGamma = np.vectorize(lambda x : lookup[x])
		imgArray = toGamma(imgArray)
		self.nextImg = Image.fromarray(np.uint8(imgArray))
		del imgArray
		return self

	def nextBrightness(self, brightness) :
		enhancer = ImageEnhance.Brightness(self.nextImg)
		self.nextImg = enhancer.enhance(brightness)
		del enhancer
		return self

	def newExpansion(self, count) :
		for i in range(count) :
			self.nextImg = self.nextImg.filter(ImageFilter.MaxFilter())
		return self

	def coverBackground(self, rgba) :
		rgbaValue = (rgba[0], rgba[1], rgba[2], round(rgba[3] * 255))
		img = Image.new("RGBA", imgSize, rgbaValue)
		self.img.paste(img, img.split()[3])
		del img

	def save(self, path, type) :
		self.img.save(path, type)
		self.log.add("画像を生成しました")
		self.log.add("・保存先:" + path)


class Log :
	def __init__(self) :
		self.text = ""

	def add(self, text) :
		self.text += text + "\r\n"
		print(text)

	def save(self, path) :
		with open(path, "a", newline="") as file :
			file.write(self.text + "\r\n")


def random(path, max) :
	if os.path.exists(path) :
		with open(path, "r", newline="") as file :
			lastRand = rand = file.read()
			while rand == lastRand:
				rand = str(round(np.random.rand() * max))
	else :
		rand = str(round(np.random.rand() * max))

	with open(path, "w", newline="") as file :
		file.write(rand)

	return rand


if __name__ == "__main__" :

	#ログ生成
	log = Log()
	
	#日時取得
	weekday = ("月", "火", "水", "木" ,"金", "土", "日")
	time = { "datatime" : datetime.datetime.now()}
	time["year"] = time["datatime"].year
	time["month"] = time["datatime"].month
	time["day"] = time["datatime"].day
	time["time"] = time["datatime"].strftime("%H:%M:%S:%f")
	time["date"] = "%d.%d/%d" % (time["year"], time["month"], time["day"])
	time["day-of-week"] = weekday[time["datatime"].weekday()]

	date = {
		"today" : datetime.date.today(),
		"graduation" : datetime.date(2018, 3, 9),
		"new-year" : datetime.date(2018, 4, 1),
		"new-semester" : datetime.date(2018, 4, 6)
	}
	datedelta = {
		"graduation" : "d" % (date["today"] - date["graduation"]).days,
		"new-year" : "d" % max((date["new-year"] - date["today"]).days, 0),
		"new-semester" : "d" % max((date["new-semester"] - date["today"]).days, 0)
	}

	log.add("実行日時:" + time["date"] + "(" + time["day-of-week"] + ")" + time["time"])
	
	#パラメータ設定
	imgSize = (1000, 500)
	textColor = (255, 255, 255, 1.0)
	shadowColor = (0, 0, 0, 1.0)
	dateShadowColor = (133, 67, 105, 1.0)
	dateShadowRgb = (dateShadowColor[0], dateShadowColor[1], dateShadowColor[2])
	blur = 6.0
	expansion = 2
	imgRand = {
		"minamigawara" : random("new_data/minamigawara.rand", 20),
		"spring" : random("new_data/spring.rand", 9)
	}
	imgSrc = {
		"minamigawara" : "img/minamigawara/" + imgRand["minamigawara"] + ".png",
		"spring" : "img/spring/" + imgRand["spring"] + ".png",
		"cherry-blossom" : "img/cherry-blossom_blur.png",
		"template" : "img/template.png",
		"template-date" : "img/template-date.png",
		"template-date_blur" : "img/template-date_blur.png",
		"cherry-bg-alpha" : "img/cherry-bg-alpha.png"
	}
	fontSrc = {
		"ShinGo-EL" : フォントファイルのパス
		"Gotham-Thin" : フォントファイルのパス
	}

	#画像生成
	img = CreateImage(imgSize, log)
	cherryImgSize = img.getImageSize(imgSrc["cherry-blossom"], 470, "height")
	img.addNext(255, 255, 255).nextImage(imgSrc["minamigawara"])
	img.nextImage(imgSrc["spring"], alpha = imgSrc["cherry-bg-alpha"]).nextGamma(0.6).addImage()
	img.addNext(255, 255, 255).nextImage(imgSrc["cherry-blossom"], (-50, imgSize[1] - cherryImgSize[1] + 20), cherryImgSize).addImage()
	img.addNext(0, 0, 0).nextImage(imgSrc["template"]).nextBrightness(0).newExpansion(expansion).nextBlur(blur).addImage()
	img.addNext(255, 255, 255).nextImage(imgSrc["template"]).addImage()
	img.addNext(*dateShadowRgb).nextImage(imgSrc["template-date_blur"]).newExpansion(expansion + 2).nextBlur(blur).addImage()
	img.addNext(255, 255, 255).nextImage(imgSrc["template-date"]).addImage()
	
	#日付	
	img.text["font"] = fontSrc["Gotham-Thin"]
	img.text["rgba"] = dateShadowColor
	
	img.addNext(*dateShadowRgb).nextText(str(time["month"]), (62,110), size = 120)
	img.nextDateText(str(time["day"]), (420,170), size = 200)
	img.nextText(time["day-of-week"], (311,380), font = fontSrc["ShinGo-EL"], size = 70).newExpansion(expansion + 2).nextBlur(blur).addImage()
	
	img.text["rgba"] = textColor
	img.addNext(255, 255, 255).nextText(str(time["month"]), (62,110), size = 120)
	img.nextDateText(str(time["day"]), (420,170), size = 200)
	img.nextText(time["day-of-week"], (311,380), font = fontSrc["ShinGo-EL"], size = 70).addImage()
	
	#カウントダウン:影
	x = 704
	y = 194
	img.text["font"] = fontSrc["ShinGo-EL"]
	img.text["size"] = 120
	img.text["rgba"] = shadowColor	
	img.addNext(0, 0, 0).nextText(datedelta["graduation"], (x, y - 150))
	img.nextText(datedelta["new-year"], (x, y))
	img.nextText(datedelta["new-semester"], (x, y + 150)).newExpansion(expansion).nextBlur(blur).addImage()
	
	#カウントダウン
	img.text["rgba"] = textColor
	img.addNext(255, 255, 255).nextText(datedelta["graduation"], (x, y - 150))		
	img.nextText(datedelta["new-year"], (x, y))
	img.nextText(datedelta["new-semester"], (x, y + 150)).addImage()
	
	#保存
	mediaPath = "new_img/" + time["datetime"].strftime("%Y-%m-%d.%H-%M-%S") + ".png"
	img.save(mediaPath, "PNG")

	#Twitter投稿
	text = u"殺伐としたタイムラインに春休み終焉を告げる足音が!!"
	media = twitter.mediaUpload(mediaPath)
	if media["state"] :
		twitter.update(text, media["media-id"])

	log.save("new_data/data.log")

最後までご覧頂きありがとうございました。それでは良いTwitterライフを!