Selene : Python にも Selenide ライクな Selenium ラッパーあります

## はじめに

仕事では Java を主に使っている関係で、 e2e テストのために [Selenide] を使っている喫煙者です。
[Selenide] 便利です。好きです。タバコと同じくらい。

そんな私が、 [Ploom TECH] を購入しようとしたのですが、

- ネット抽選当たらない
- 店舗購入でもネットで予約が必要
- ちょいちょい見ても予約の受付が開始されない

というような状態でした。[^1]

そこで、お目当の店舗で予約の受付を開始したら、予約受け付けてるよというのを Slack で通知するというのを自動化することにしました。[^2]

ただし、自宅では Java を使いたくないというのがあったので、 [Selenide] ライクな Selenium ラッパーが JavaScript か Python あたりでないかなと探して見たら、 Python 用の [Selene] というのを発見し、使ってみたので、それの簡単なご紹介です。

## Selene の特徴

### [Selenide] と同等の機能

[Selenide] では要素選択が jQuery のようにできたり、 Ajax でいちいち wait を書いたりする必要がないという素晴らしい特徴があるのですが、 `Selenide port in Python` と謳っているようにほぼ同じことができます。

### WebDriver の管理不要

ブラウザを動かすための WebDriver をダウンロードしたりだとか、パスを通したりとかが不要になっています。
[Selenide] ではその辺のサポートは行なっていません。

## [Selene] のインストール方法

[Selene] は pip でインストールできますが、この記事を書いた時点では過渡期のため、以下のインストール方法が推奨されています。

```bash
pip install selene --pre
```

## [Selene] の使い方

以下、 [Ploom TECH] の予約可能な店舗を通知するのに利用したコードを使って、説明します。
なお、このコードは [Gist](https://gist.github.com/kobanyan/888f1ca407290d9a4db37b57139ef4ea) にも置いてあります。

```python
import os
import traceback

from selene import config
from selene.browsers import BrowserName
from selene.api import *

from selenium import webdriver

from slackclient import SlackClient

SLACK_TOKEN = os.environ['SLACK_TOKEN']
SLACK_CHANNEL_LOG = os.environ['SLACK_CHANNEL_LOG']
SLACK_CHANNEL_FOUND = os.environ['SLACK_CHANNEL_FOUND']
SLACK_MENTION_ID = os.environ['SLACK_MENTION_ID']
PLOOM_ACCOUNTUID = os.environ['PLOOM_ACCOUNTUID']
PLOOM_PASSWORD = os.environ['PLOOM_PASSWORD']
PLOOM_PREFECTURE = os.environ['PLOOM_PREFECTURE']
PLOOM_CITY = os.getenv('PLOOM_CITY', 'すべて選択')
PLOOM_SHOP = os.getenv('PLOOM_SHOP', None)

SLACK_METHOD = 'chat.postMessage'
PLOOM_URL = 'https://www.ploom.jp/tech/reserve/'

def main():
    slack_client = SlackClient(SLACK_TOKEN)
    slack_client.api_call(
        SLACK_METHOD,
        channel=SLACK_CHANNEL_LOG,
        text='Ploom の予約を受け付けている店舗を検索しています...\n都道府県 `{0}` 市区町村 `{1}` 店舗名 `{2}`'
        .format(PLOOM_PREFECTURE, PLOOM_CITY, PLOOM_SHOP)
    )
    try:
        # 利用するブラウザを設定します。 
        # config は Selenide の Configuration にあたります。
        config.browser_name = BrowserName.PHANTOMJS
        # PhantomJSの場合にこの設定が必要になっています。
        # Chrome では勝手に設定されるので不要です。
        config.desired_capabilities = webdriver.DesiredCapabilities.PHANTOMJS
        # 指定のURLのページを開きます。
        # ここでは、絶対パスを渡していますが、 config.base_url にURLを設定していると、
        # 相対パスを指定することができます。
        browser.open_url(PLOOM_URL)
        # s というのが操作したい要素を選択するための関数です。 
        # Selenide の $ に該当します。
        # s に文字列を渡すと CSS セレクタとして扱われます。
        # set_value で input に値をセットします。
        # 暗黙的にクリアされた後に入力が行われます。
        s('input[name="ACCOUNTUID"]').set_value(PLOOM_ACCOUNTUID)
        s('input[name="PASSWORD"]').set_value(PLOOM_PASSWORD)
        # click() で要素をクリックします。
        s('input[type="SUBMIT"]').click()
        s('input[type="checkbox"]').click()
        s('input[type="submit"]').click()
        # セレクタに xpath を利用したい場合には by.xpath を指定します。
        s(by.xpath('//label[text()="上記内容を確認しました"]')).click()
        s(by.xpath('//label[text()="{0}"]'.format(PLOOM_PREFECTURE))).click()
        s(by.xpath('//a[text()="予約を申し込む"]')).click()
        s(by.xpath('//label[text()="{0}"]'.format(PLOOM_CITY))).click()
        s(by.xpath('//a[text()="店舗選択へ"]')).click()
        message = None
        if PLOOM_SHOP is None:
            # ss で要素のコレクションを取得できます。 Selenide の $$ に該当します。
            # s と同様にセレクタを渡します。
            shops = ss(by.xpath('//a[contains(@class,"is-gold")]/../../..'))
            if shops.size() > 0:
                message = u'<{0}> 以下の店舗が予約を受け付けています。 :smile:\n'.format(SLACK_MENTION_ID)
                for shop in shops:
                    # 要素内の要素を検索する場合には取得した要素の s を呼びます。
                    # 要素の text プロパティからテキストが取得できます。
                    shop_name = shop.s('h2.shop-name').text
                    shop_address = shop.s('p.address').text
                    message += u'店舗名: `{0}`, 住所: `{1}`\n'.format(shop_name, shop_address)
        else:
            xpath = '//h2[@class="shop-name"][text()="{0}"]/..//p[@class="submit"]/a[contains(@class,"is-gold")]'
            # Selenide であれば以下のような書き方ができるのですが、
            # 現状 Selene だと is_XXXX 系のメソッドでも TimeoutException が出てしまうため、
            # コレクションを利用しています。 
            # shop = s(by.xpath(xpath.format(PLOOM_SHOP)))
            # if shop.is_displayed() is True:
            shop = ss(by.xpath(xpath.format(PLOOM_SHOP)))
            if shop.size() > 0:
                message = '<{0}> '.format(SLACK_MENTION_ID)
                message += '`{0}` が予約を受け付けています。 :smile:\n'.format(PLOOM_SHOP)
        if message is None:
            slack_client.api_call(
                SLACK_METHOD,
                channel=SLACK_CHANNEL_LOG,
                text='検索条件に該当する店舗は予約を受け付けていませんでした。 :cry:'
            )
        else:
            message += PLOOM_URL
            slack_client.api_call(
                SLACK_METHOD,
                channel=SLACK_CHANNEL_FOUND,
                text=message
            )
            slack_client.api_call(
                SLACK_METHOD,
                channel=SLACK_CHANNEL_LOG,
                text='検索条件に該当する店舗が予約を受け付けています。 :smile:'
            )
    except:
        slack_client.api_call(
            SLACK_METHOD,
            channel=SLACK_CHANNEL_LOG,
            text='<{0}> 店舗検索に失敗しました。 :angry:\n```{1}```'
            .format(SLACK_MENTION_ID, traceback.format_exc())
        )
        raise
if __name__ == '__main__':
    main()
```

上記のサンプルでは用いませんでしたが、 アサート用のメソッド `should` など [Selenide] と同等のメソッドが色々用意されています。

## 最後に

開発途上で完全互換という訳ではないですが、 Python でも [Selenide] ライクなブラウザ操作ができるようになっており、 [Selenide] を使っていて Python でも同じことやりたかった人だったり、 Python で Selenium のラッパー探していた人にとっては要注意なライブラリだと思います。


[Selenide]:http://selenide.org/
[Selene]:https://github.com/yashaka/selene
[Ploom TECH]:https://www.ploom.jp/
[^1]: 今は購入済みです。月曜0時過ぎに一気に解放されるっぽいです。一度予約すると確認できなくなるので詳細は不明。
[^2]: 予約完了までテストできそうになかったので、ここで止めてます。