本稿の趣旨
カレン氏は激怒した。必ず,かのAPIを除かなければならぬと決意した。カレンにはTwitterがわからぬ。カレンは,SpaceXのCEOである。ほらを吹き,ドローンと遊んで暮して来た。けれども金儲けに対しては,人一倍に敏感であった。
なぜTwitter APIを使わないか
Twitter API利用のためには,Botの製作意図などを書面でTwitter Devへ申請する必要があります。この作業が不透明で,私など過去に申請したところ「価値なし!」と一蹴され,アク禁まで食らって何年も放置されました。このような事例は多数あるらしく,Seleniumを用いてWebブラウザアクセスを装い,Twitterを操作する方法が試されてきました。この度,スペース・カレン氏がTwitterを買収したタイミングで私のアク禁は解除されたものの,APIアクセスの申請などしようものなら「NOT "GOOD" CONTENT!」と一蹴され,今度はアカウントごと凍結されてしまうことでしょう。
ということで,APIを使わずTwitterへwriteしてしまおうと思いました。
本稿は2023年2月上旬時点での調査結果をまとめたものですが,特に以下の点が先行事例に付け加えたところです。
- ログイン情報を保存しておき,毎回ログインしないようにするため,
user-data-dir
オプションを使う。 - ブラウザウィンドウを開かない headless 運用とする。
- XPath は簡潔に書く。
- 複数画像投稿に対応する。
準備
著者はマカーです。M2 Maxな16インチがほしい。
$ python3 -V
Python 3.10.9
$ cat requirements.txt
selenium==4.8.0
urllib3==1.26.14
$ pip3 install -r requirements.txt
手順の説明
1. モジュールの import
selenium
: Chromeを生成していろいろさせます。
os
: パスを加工します。
time
: 読み込みが終わるまで待ちます。
urllib
: 投稿するテキストを加工します。
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
import os
import time
import urllib
2. credentials の設定
ここでは平文を堂々と書きます。実運用では別ファイルに書いた方が良いです。headless 運用をするためには user-agent を明示しないといけません。ここでは適当なマカーを装います。
email = 'asshole@twitter.social' # メールアドレスを指定する
password = 'MAGA2023!' # パスワードを平文で指定する
username = 'elonmusk' # ユーザ名を指定する
user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15'
3. main()
-
profile
ディレクトリが存在する場合isprofile
をTrue
にし,存在しない場合False
にする。 - Twitter 操作用クラスを生成する。
-
profile
ディレクトリが存在しない場合だけ,ログインを試みる。 - 投稿する。
def main():
# attempt login unless the profile directory is present
isprofile = os.path.isdir(os.path.join(os.getcwd(), 'profile'))
# instanciate
bot = Tweetbot(email, password, username)
if not isprofile:
bot.login()
# write access 1: text-only
bot.update_status('これはテスト投稿です。')
# wait for space-karen
time.sleep(10)
# write access 2: text with media
bot.update_status_with_media('これは画像付きテスト投稿です。', ['image1.png', 'image2.png'])
4. class Tweetbot
コンストラクタ
引数はログインに使用するアカウント情報です。
headless で profile 利用するため,Options()
にいろいろ設定します。Chrome 生成後,set_window_size
でパソコンっぽい大きさにしておきます。小さいとメニューなどがハンバーガーされてしまって,期待するボタンが現れないとかあるようです。
Selenium では目的の HTML 要素を探し出すのに find_element()
関数を使うのですが,何やらあって探索対象が見つからない場合にも,一定時間探索を続けてくれます。その時間を指定するのが implicitly_wait()
で,ここでは雰囲気で10秒にしました。
def __init__(self, email, password, username):
self.email = email
self.password = password
self.username = username
self.user_agent = user_agent
# set options for headless accessing
chrome_options = webdriver.chrome.options.Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--user-agent=%s' % user_agent)
chrome_options.add_argument('--user-data-dir=%s' % os.path.join(os.getcwd(), 'profile'))
# instanciate
self.driver = webdriver.Chrome(options = chrome_options)
self.driver.set_window_size('1920', '1200')
# wait for finding an element
self.driver.implicitly_wait(10)
ログイン
def login(self):
driver = self.driver
ログインページ https://twitter.com/login
を開きます。
# open login page
driver.get('https://twitter.com/login')
ログインの手順は以下の通りです:
- email を入力する。RETURN キーを押す。
- Twitter に怪しいことをしているらしいと疑われると,ユーザ名を入力するよう促される。この画面が出てしまった場合は,username を入力し,RETURN キーを押す。
- password を入力する。RETURN キーを押す。
各手順では雰囲気で5秒待っています。
find_element
で指定する XPath は,Google Chrome の View | Developer | Inspect Elements
を使って,例えばメールアドレスを入力する input
に対し Copy XPath
すると:
//*[@id="layers"]/div/div/div/div/div/div/div[2]/div[2]/div/div/div[2]/div[2]/div/div/div/div[5]/label/div/div[2]/div/input
などとなるんですが,ログインページの動的生成の過程でいろいろ変わることがあります。そこで,もっと簡潔に指定できないか考えてみます。
対象となる input
タグの属性は以下のようになっています。
<input autocapitalize="sentences" autocomplete="username" autocorrect="on" name="text" spellcheck="true" type="text" dir="auto" class="r-30o5oe r-1niwhzg r-17gur6a r-1yadl64 r-deolkf r-homxoj r-poiln3 r-7cikom r-1ny4l3l r-t60dpp r-1dz5y72 r-fdjqy7 r-13qz1uu" value="">
この中で使えそうな属性を探したところ,name="text"
を利用するのが安定してそうでした。ということで,XPath は以下のように指定します:
//input[@name='text']
他には,autocomplete="username"
も良いかもしれません。
一度ログインに成功すると,その情報が profile
ディレクトリに保存されるので,以後はログイン操作は不要です。なんだかログインされてないぞ? となったら profile
ディレクトリを消して再ログインしてください。
# login attempt using email
try:
elm = driver.find_element(By.XPATH, "//input[@name='text']")
elm.send_keys(self.email)
elm.send_keys(Keys.RETURN)
time.sleep(5)
except:
driver.save_screenshot('debug-login.png')
pass
# sometimes twitter warns unusual access
# in that case need to send the username
try:
elm = driver.find_element(By.XPATH, "//input[@name='text']")
elm.send_keys(self.username)
elm.send_keys(Keys.RETURN)
time.sleep(5)
except:
driver.save_screenshot('debug-username.png')
pass
# send password
try:
elm = driver.find_element(By.XPATH, "//input[@name='password']")
elm.send_keys(self.password)
elm.send_keys(Keys.RETURN)
time.sleep(5)
except:
driver.save_screenshot('debug-password.png')
pass
投稿
テキストのみ
投稿用ページ https://twitter.com/intent/tweet
に GET で text
属性つけて開きます。text
属性には投稿したい(URLエンコードした)テキストを指定します。
# post text-only
def update_status(self, status):
driver = self.driver
# open post page
text = urllib.parse.quote(status, safe='')
url = "https://twitter.com/intent/tweet?text=%s" % text
driver.get(url)
time.sleep(5)
Tweet ボタンをクリックします。
# click the tweet button
try:
elm = driver.find_element(By.XPATH, "//div[@data-testid='tweetButton']")
elm.click()
time.sleep(5)
except:
driver.save_screenshot('debug-tweet.png')
pass
画像付き
画像ファイルは input
タグで type
属性が file
になっているものに,アップロードしたいファイルへのフルパスを指定します。複数の画像ファイルを指定したい場合は LF (\n
)で区切ります。それ以外はテキストのみと同じです。
# prepare path for media files
# all path must be full-path, and separated by LF
media = [os.path.join(os.getcwd(), 'media', s) for s in media]
media_files = "\n".join(media)
# attach media files
try:
elm = driver.find_element(By.XPATH, "//input[@data-testid='fileInput']")
elm.send_keys(media_files)
time.sleep(5)
except:
driver.save_screenshot('debug-media-files.png')
pass
まとめ
M2 Maxな16インチがほしい。
初めて書いた Qiita 記事なので残念なところが多々あると思います。@rino@mstdn.jp までコメントお願いします!
付録: フルコード
./media
ディレクトリに投稿したい画像(png)を置いておきます。
#!/usr/bin/env python3
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.common.by import By
import os
import time
import urllib
email = '' # メールアドレスを指定する
password = '' # パスワードを平文で指定する
username = '' # ユーザ名を指定する
user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 13_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15'
def main():
# attempt login unless the profile directory is present
isprofile = os.path.isdir(os.path.join(os.getcwd(), 'profile'))
# instanciate
bot = Tweetbot(email, password, username)
if not isprofile:
bot.login()
# write access 1: text-only
bot.update_status('これはテスト投稿です。')
# wait for space-karen
time.sleep(10)
# write access 2: text with media
bot.update_status_with_media('これは画像付きテスト投稿です。', ['image1.png', 'image2.png'])
class Tweetbot:
def __init__(self, email, password, username):
self.email = email
self.password = password
self.username = username
self.user_agent = user_agent
# set options for headless accessing
chrome_options = webdriver.chrome.options.Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--user-agent=%s' % user_agent)
chrome_options.add_argument('--user-data-dir=%s' % os.path.join(os.getcwd(), 'profile'))
# instanciate
self.driver = webdriver.Chrome(options = chrome_options)
self.driver.set_window_size('1920', '1200')
# wait for finding an element
self.driver.implicitly_wait(10)
def login(self):
driver = self.driver
# open login page
driver.get('https://twitter.com/login')
# login attempt using email
try:
elm = driver.find_element(By.XPATH, "//input[@name='text']")
elm.send_keys(self.email)
elm.send_keys(Keys.RETURN)
time.sleep(5)
except:
driver.save_screenshot('debug-login.png')
pass
# sometimes twitter warns unusual access
# in that case need to send the username
try:
elm = driver.find_element(By.XPATH, "//input[@name='text']")
elm.send_keys(self.username)
elm.send_keys(Keys.RETURN)
time.sleep(5)
except:
driver.save_screenshot('debug-username.png')
pass
# send password
try:
elm = driver.find_element(By.XPATH, "//input[@name='password']")
elm.send_keys(self.password)
elm.send_keys(Keys.RETURN)
time.sleep(5)
except:
driver.save_screenshot('debug-password.png')
pass
# post text-only
def update_status(self, status):
driver = self.driver
# open post page
text = urllib.parse.quote(status, safe='')
url = "https://twitter.com/intent/tweet?text=%s" % text
driver.get(url)
time.sleep(5)
# click the tweet button
try:
elm = driver.find_element(By.XPATH, "//div[@data-testid='tweetButton']")
elm.click()
time.sleep(5)
except:
driver.save_screenshot('debug-tweet.png')
pass
# post with media
def update_status_with_media(self, status, media):
driver = self.driver
# open post page
text = urllib.parse.quote(status, safe='')
url = "https://twitter.com/intent/tweet?text=%s" % text
driver.get(url)
time.sleep(5)
# prepare path for media files
# all path must be full-path, and separated by LF
media = [os.path.join(os.getcwd(), 'media', s) for s in media]
media_files = "\n".join(media)
# attach media files
try:
elm = driver.find_element(By.XPATH, "//input[@data-testid='fileInput']")
elm.send_keys(media_files)
time.sleep(5)
except:
driver.save_screenshot('debug-media-files.png')
pass
# click the tweet button
try:
elm = driver.find_element(By.XPATH, "//div[@data-testid='tweetButton']")
elm.click()
time.sleep(5)
except:
driver.save_screenshot('debug-tweet-media.png')
pass
if __name__ == "__main__":
main()
2023-02-06: [v1.0] 初版
2023-02-07: 誤字修正