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

# 本稿の趣旨

<img width="270" alt="space-karen.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/3957fd78-47dc-2355-176b-957630887268.jpeg">

カレン氏は激怒した。必ず，かの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インチがほしい。

```zsh
$ 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`: 投稿するテキストを加工します。

```python
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 を明示しないといけません。ここでは適当なマカーを装います。

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

```python
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秒にしました。


```python
  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)
```

### ログイン

```python
  def login(self):
    driver = self.driver
```

ログインページ `https://twitter.com/login` を開きます。

```python
    # open login page
    driver.get('https://twitter.com/login')
```

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

1. email を入力する。RETURN キーを押す。
1. Twitter に怪しいことをしているらしいと疑われると，ユーザ名を入力するよう促される。この画面が出てしまった場合は，username を入力し，RETURN キーを押す。
1. 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` タグの属性は以下のようになっています。

```html
<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` ディレクトリを消して再ログインしてください。

```python
    # 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エンコードした）テキストを指定します。

```python
  # 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 ボタンをクリックします。

```python
    # 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`)で区切ります。それ以外はテキストのみと同じです。

```python
    # 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](https://mstdn.jp/@rino) までコメントお願いします！

# 付録: フルコード

`./media` ディレクトリに投稿したい画像(png)を置いておきます。

```python
#!/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: 誤字修正