46
42

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Seleniumを用いたTwitterへのログイン及び投稿

Last updated at Posted at 2023-02-06

本稿の趣旨

space-karen.jpg

カレン氏は激怒した。必ず,かの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()

  1. profile ディレクトリが存在する場合 isprofileTrue にし,存在しない場合 False にする。
  2. Twitter 操作用クラスを生成する。
  3. profile ディレクトリが存在しない場合だけ,ログインを試みる。
  4. 投稿する。
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')

ログインの手順は以下の通りです:

  1. email を入力する。RETURN キーを押す。
  2. Twitter に怪しいことをしているらしいと疑われると,ユーザ名を入力するよう促される。この画面が出てしまった場合は,username を入力し,RETURN キーを押す。
  3. 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: 誤字修正

46
42
3

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
46
42

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?