Classroomから時間割を取得してLINEへ送信するBot

# 作ろうと思ったきっかけ
クラスルームを開かずに、時間割を確認したい...とふらっと思って作りました。
# イメージ
LINEで問い合わせると、その時点でのストリームの内容を送ってくれる。そんなBotをイメージしています。
イメージとしてはこのスライドみたいな感じ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/513ec735-5afd-5d50-f14e-8feab6bdde8e.png)
雑...　ですがまあこんな感じです
目標はホームルーム教室のクラスルームから、時間割を取得することにしたいと思います。
# ちょっとクラスルームを覗いてみる
複雑すぎる。天下のGoogle様の技術力に絶望
# 工程1.seleniumでのスクレイピング
まずはpipでseleniumを入れます。
~~~ shell:linux
pip install selenium
~~~
### 1.まずはログイン
このサイトを参考にがんばります。

https://note.com/okonomiyaki011/n/n62eb490a6f6d

Googleへのログインはメールアドレスを入力しその後パスワードを入力するというふうになっています。よって.send_keys()メソッドを利用してkeyをテキストボックスに入れ、送信ボタンをクリックするという処理をすればいいだけなのですが、結構ここで時間を取られました。
まずは、F12を押して出てくる検証画面を開いてテキストボックスの部分のコードを探します
![Screenshot_20231203_182515.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/ac0ef7b3-67dd-86ad-b253-cbcc7538d3da.png)
メールアドレスを入力するところはnameを検索するBy.NAMEを使いました。パスワードも同じようにすることを考えて、最初はBy.NAME(name="")を使いました。この部分のnameであるPasswdを指定して
~~~Python
    password_name = "Passwd"
    password_next_xpath = '//*[@id="passwordNext"]'

    WebDriverWait(driver, wait_time).until(
        EC.presence_of_element_located((By.NAME, password_name))
    )
    driver.find_element(By.NAME, password_name).send_keys(password)
    driver.find_element(By.XPATH, password_next_xpath).click()
    time.sleep(10)
~~~
ところがいざ実行してみると...
selenium.common.exceptions.ElementNotInteractableException: Message: element not interactableとエラーがでます
検索してみると、どうやらnameで指定した"Passwd"がグーグルのログイン画面で頻出しているのが原因のようです。

https://qiita.com/Sho1981/items/fc07d83b33d11a54bfcd

よってXPATHを利用して書くことにします。
XPATHは、検証モード中でコードを右クリックした後に出てくるメニューの中のコピーを選択すると、XPATHをコピーが選べるようになるのでそれを使いコピーします。
![Screenshot_20231203_205412.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/d08127a3-2bf2-181c-4052-21945d5ebb6e.png)
これを利用してログイン部分のコードを書きます。
~~~Python:scraping.py
#seleniumをimport
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


def start_chrome():
    # selenium v4.15のためChromeDriverのパスを指定する必要はない
    driver = webdriver.Chrome()
    driver.maximize_window()  # 画面サイズの最大化
    # Google Classroomの目的のところへ直接アクセスする（するとログイン画面へリダイレクトされる）
    url = "クラスルームのURL"
    driver.get(url)
    return driver


def login_google(driver):
    login_id = "メールアドレス"
    password = "パスワード"
    wait_time = 40
    #メールアドレスの入力の部分
    login_id_name = "identifier"
    login_id_next_xpath = '//*[@id="identifierNext"]'

    WebDriverWait(driver, wait_time).until(
        EC.presence_of_element_located((By.NAME, login_id_name))
    )
    # メールアドレスを入力
    driver.find_element(By.NAME, login_id_name).send_keys(login_id)
    driver.find_element(By.XPATH, login_id_next_xpath).click()
    #パスワードの部分
    #コピーしたXPATHを変数に代入する
    password_xpath = '//*[@id="password"]/div[1]/div/div[1]/input'
    password_next_xpath = '//*[@id="passwordNext"]'

    WebDriverWait(driver, wait_time).until(
        EC.presence_of_element_located((By.XPATH, password_xpath))
    )
    # パスワードを入力
    driver.find_element(By.XPATH, password_xpath).send_keys(password)
    driver.find_element(By.XPATH, password_next_xpath).click()
    time.sleep(10)

if __name__ == "__main__":
    driver = start_chrome()
    login_google(driver)
~~~
修正したあとでも何故かわかりませんが2~3回に一回,
~~~
password_xpath = '//*[@id="password"]/div[1]/div/div[1]/input'
~~~
の部分で同様のselenium.common.exceptions.ElementNotInteractableException: Message: element not interactableのエラーが出ます。そのため、実際にスクレイピングするときは例外処理を加えて、根気強く頑張らせるしかなさそうです。

### 2.クラスルームの最新の投稿を取得する。
ここで初めて気がついたんですけど、実はselenium(正確にはchrome driver)を使って開いたChromeから取得できるXpathと人の手で開いたChromeなどって別物になることがあるらしいんですね。そのためXpathを開いたあと数十秒time.sleep()を入れてコピーしました。
まずはログイン時と同様にXpathを利用してテキストデータをコピーしてみます。
最初はChromeのCopy Xpathの機能を使っていましたが、この機能でコピーすると何故か値が毎回微妙に異なり、きれいに取得できないことが判明したため、Copy full XpathでXpathのフルパスをコピーしてスクレイピングに使っています。クラスルームの文章はspanのタグに入っているのでここをコピーします。
![Screenshot_20231210_002416.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/624e1c2d-a398-d7d9-eb12-f314ad6e3538.png)
~~~Python:
def scraping_classroom(driver):
    waiting_time_a = 40
    topstream_xpath = "ここはご自分のパスにしてください"
    topstream = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_xpath))
    )
    topstream_text = topstream.text
    print(topstream_text)
~~~
.textを使ってテキスト部分を抜き出しました。
ところで私の通っている高校はPDF形式で時間割を掲載しています。そのためテキストだけではいちいちクラスルームに確認しに行かなければなりません。というか無駄です。そのため.getattribute()を使ってリンクを入手しようと思います。具体的には、まずリンクを含む部分のhtmlをXpathを用いて抜き出し、その部分でリンクを意味する"href"を含む部分のみを変数に格納するという方式で行きます。
以下に2.で制作したコードの全体像を載せます。
~~~Python:

def scraping_classroom(driver):
    waiting_time_a = 40
    topstream_xpath = "フルXpathをコピー　おそらく最後は/spanのはず"
    topstream = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_xpath))
    )
    topstream_text = topstream.text
    print(topstream_text)
    #ここからがリンク取得のターン
    topstream_a_href_xpath = "フルXpathをコピー　おそらく最後は/aのはず"
    topstream_a_href = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_a_href_xpath))
    )
    topstream_a_href_link = topstream_a_href.get_attribute("href")
    print(topstream_a_href_link)
~~~
# 工程2.LINE Botを作る準備
### アカウント開設
まずは[LINE Developers](https://developers.line.biz/ja/)にアクセスして、ご自身のLINEのアカウントでログインしてください。おそらく最初はアカウントを制作するために、情報を登録する必要があると思います。その場合、画面の指示に従って入力してください。その後、![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/6a5740e6-68a0-2e14-a386-4862d5048f7f.png)
トップから、プロバイダーを作成し、名前を適当に入れてください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/b4559819-1924-34ac-727e-3e05de882ffa.png)
続いてチャンネルを作成します。Messaging APIを選択してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/b70c96c9-69ad-20d2-b85d-825390fa0415.png)
画面の指示どおりに入力して、最後に利用規約に同意して作ってください。
作成したチャンネルを開くとこのような画面になっているはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/207d33d9-af9a-8347-8a6d-34bda7b90ce2.png)
チャンネル基本設定の下の方にある、**チャンネルシークレット**と、Messaging API設定の下の方にある、**チャンネルアクセストークン**をメモしておいてください。また、Messaging API設定から応答メッセージや挨拶メッセージを無効化するなど、好みに応じて設定してください。
### ngrok導入
今回は楽なのでngrokでローカルで動かします。まず、[公式サイト](https://ngrok.com/)にアクセスし、Sign up freeと押して、画面の指示に従って入力項目を打ち込みます。その後、ダッシュボードに画面が飛ぶと思うので
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/631f1afb-c37c-d229-d701-da8b00392383.png)
ここでAgentsからOSを選択して、画面の指示に従ってコマンドを入力したり、ファイルをダウンロードしたりしてください。インストールが終わったら基本的に以下のコマンドで実行できます。
コンソールを開いて、
~~~
ngrok http 8000
~~~
と打つだけです。(今回は8000番ポートを使うので、ngrokで8000にアクセスできるようにゴニョゴニョしてくれ！っていう命令です。）打ち込むとこんな画面が出てくるので
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/8a607c5c-b59e-d5b9-8d13-2833ffbbde8e.png)
赤枠の部分をコピーし、先程のLINEのチャンネルの設定の画面から、Messaging APIの設定、Webhookの設定と進み、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/10fb16a7-c939-be58-671d-72f4ab1cea46.png)
Webhook URLとして指定します。その時貼り付けたあとに/callbackとつけます。
以降ngrokを再実行するたびにURLが変わるのでその都度この項目を更新し続ける必要があります。
用がなくなったらCtrl+Cで止めてください。
### 必要ライブラリの導入
~~~
pip install flask
~~~
~~~
pip install line-bot-sdk
~~~
Pythonは3.8以上らしいです
# ２つを合わせてBOTを作る
### 全体的な構成のお話
まずはどんな感じになるのかを最初のスライドを見ておさらいしておきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/5f95926c-189a-2831-2a48-37f0f2bb4d6e.png)
スクレイピングは工程１でLINEBotの準備は工程２で行いました。
LINEボットで要求を受け取って、そこからClassroomをスクレイピングし、その結果を返す。といった仕組みにしておきたいと思います。
### app.pyとconfig.pyの作成
まず、config.pyを作成します。
~~~Python:config.py
LINE_CHANNEL_SECRET = "メモしておいたチャンネルシークレット" 
LINE_CHANNEL_ACCESS_TOKEN = "メモしておいたチャンネルアクセストークン"  
~~~
つづいて app.pyというファイル名でファイルを作成します。
そして、特定のメッセージ　例:「クラスルームの更新を教えて」を受信したら、変数の中身を返す。といったボットを作ろうと思います。
### スクレイピングデータをLINEbotに乗せる下準備とエラー対策
まず、Classroomをスクレイピングするときに発生するエラーを修正します。この段階でエラーが発生する可能性のあるポイントは二箇所あります。
1.URLを取得する部分
これまでは、クラスルームのテキストのスクレイピングとURLのスクレイピングを同時に行っていましたが、関数を別にして、エラーが発生した場合、リンクがありませんと出力できるようにします。
これまでのコード
~~~Python:
def scraping_classroom(driver):
    waiting_time_a = 40
    topstream_xpath = "フルXpathをコピーおそらく最後は/spanのはず"
    topstream = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_xpath))
    )
    topstream_text = topstream.text
    print(topstream_text)
    #ここからがリンク取得のターン
    topstream_a_href_xpath = "フルXpathをコピー　おそらく最後は/aのはず"
    topstream_a_href = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_a_href_xpath))
    )
    topstream_a_href_link = topstream_a_href.get_attribute("href")
    print(topstream_a_href_link)
~~~
リンク取得のターンを別の関数に分離します。
~~~Python:
def scraping_classroom(driver):
    waiting_time_a = 40
    # フルパス指定で取得する
    topstream_xpath = "/html/body/c-wiz/div[2]/div/div[7]/div[2]/main/section/div/div[2]/div[1]/div[2]/div[1]/span"
    topstream = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_xpath))
    )
    topstream_text = topstream.text
    # print(topstream_text)
    return topstream_text


def scraping_classroom_link(driver):
    waiting_time_a = 40
    topstream_a_href_xpath = "/html/body/c-wiz/div[2]/div/div[7]/div[2]/main/section/div/div[2]/div[1]/div[2]/div[2]/div[1]/div[2]/div/a"
    topstream_a_href = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_a_href_xpath))
    )
    topstream_a_href_link = topstream_a_href.get_attribute("href")
    # print(topstream_a_href_link)
    return topstream_a_href_link
~~~
このあと、エラー対策のために、一つの関数にまとめて実行できるようにします。そのため、それぞれでprint()するのではなく、取得したデータを戻り値として設定して、返すようにします。

2.ログイン部分
ログイン部分のエラーは、エラーが発生した場合、再ログインを行うようにします。そのために、tryとexceptを使います。
1.のエラーの対応を含めてmainという関数を作ります。
~~~Python:def_main
def main():
    driver = start_chrome()
    try:
        login_google(driver)
        return_text = scraping_classroom(driver)
        try:
            return_url = scraping_classroom_link(driver)
        except:
            return_url = "リンクがありません"
        return return_text, return_url
    except:
        main()
~~~
最初のログインでエラーが発生した場合は自動的に関数を実行し直します。また、URLた見つからず、エラーになった場合は、そのままURLがありませんと返します。

続いて、このmain関数をLINEbotから読み出して実行します。
### LINEボットのコードを作成。
ソースコードはこちらの記事を参考にさせていただきました。

https://www.tomotaku.com/line-repeat-bot/

~~~Python:app.py
import scraping
#〜〜〜〜
#省略
#〜〜〜〜
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    if event.reply_token == "00000000000000000000000000000000":
        return
    if event.message.text == "Hello":
        return_message = scraping.main()
        line_bot_api.reply_message(
            event.reply_token, TextSendMessage(text=return_message)
        )
        return
~~~
スクレイピングをするコードを格納したファイルをimportして、実行します。
### 最終的なコード...
~~~Python: scraping.py
import time
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def start_chrome():
    driver = webdriver.Chrome()
    driver.maximize_window() 
    # Google Classroom(ホームルーム)へ直接Go!
    url = "自分のクラスルームのurl"
    driver.get(url)
    return driver
    
def login_google(driver):
    login_id = "メールアドレス"
    password = "パスワード"
    wait_time = 40
    # IDを入力
    login_id_name = "identifier"
    login_id_next_xpath = '//*[@id="identifierNext"]'

    WebDriverWait(driver, wait_time).until(
        EC.presence_of_element_located((By.NAME, login_id_name))
    )
    driver.find_element(By.NAME, login_id_name).send_keys(login_id)
    driver.find_element(By.XPATH, login_id_next_xpath).click()
    # パスワードを入力
    password_xpath = '//*[@id="password"]/div[1]/div/div[1]/input'
    password_next_xpath = '//*[@id="passwordNext"]'

    WebDriverWait(driver, wait_time).until(
        EC.presence_of_element_located((By.XPATH, password_xpath))
    )
    driver.find_element(By.XPATH, password_xpath).send_keys(password)
    driver.find_element(By.XPATH, password_next_xpath).click()
    time.sleep(10)


def scraping_classroom(driver):
    waiting_time_a = 40
    topstream_xpath = "/html/body/c-wiz/div[2]/div/div[7]/div[2]/main/section/div/div[2]/div[1]/div[2]/div[1]/span"
    topstream = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_xpath))
    )
    topstream_text = topstream.text
    # print(topstream_text)
    return topstream_text


def scraping_classroom_link(driver):
    waiting_time_a = 40
    topstream_a_href_xpath = "/html/body/c-wiz/div[2]/div/div[7]/div[2]/main/section/div/div[2]/div[1]/div[2]/div[2]/div[1]/div[2]/div/a"
    topstream_a_href = WebDriverWait(driver, waiting_time_a).until(
        EC.presence_of_element_located((By.XPATH, topstream_a_href_xpath))
    )
    topstream_a_href_link = topstream_a_href.get_attribute("href")
    # print(topstream_a_href_link)
    return topstream_a_href_link


def main():
    driver = start_chrome()
    try:
        login_google(driver)
        return_text = scraping_classroom(driver)
        try:
            return_url = scraping_classroom_link(driver)
        except:
            return_url = "リンクがありません"
        return return_text, return_url
    except:
        main()
#直接実行する用
if __name__ == "__main__":
    x= main()
    print(x[0])
    print(x[1])
~~~
こちらのmain関数でスクレイピングを行い,
~~~Python: app.py
import config 
import scraping
from flask import Flask, request, abort

from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import (
    MessageEvent,
    TextMessage,
    TextSendMessage,
)

app = Flask(__name__)

line_bot_api = LineBotApi(
    config.LINE_CHANNEL_ACCESS_TOKEN
)  # config.pyで設定したチャネルアクセストークン
handler = WebhookHandler(config.LINE_CHANNEL_SECRET)  # config.pyで設定したチャネルシークレット


@app.route("/callback", methods=["POST"])
def callback():
    # get X-Line-Signature header value
    signature = request.headers["X-Line-Signature"]

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        print(
            "Invalid signature. Please check your channel access token/channel secret."
        )
        abort(400)

    return "OK"


@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    if event.reply_token == "00000000000000000000000000000000":
        return
    if event.message.text == "時間割":
        return_message = scraping.main()
        line_bot_api.reply_message(
            event.reply_token, TextSendMessage(text=return_message)
        )
        return


if __name__ == "__main__":
    app.run(host="localhost", port=8000)  # ポート番号を8000に指定
~~~
このPythonファイルでボットとして動かします。
最終的には、フォルダの中に,config.py,app.py,scraping.pyの３つが入っていると思います。
# まとめ
準備の際に一度起動したngrokを使って、起動してみようと思います。
~~~
python3 app.py
~~~
pythonを実行して、ngrokで外部からアクセスできるようにします。
~~~
ngrok http 8000
~~~
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/d795bddc-8642-08fb-503f-1207bb5e95a0.png)


Forwarding のところをコピーして、LINEデベロッパーのwebhookのところに設定してください
LINEからアクセスしてみると...
![mosaic_20231223235240.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1861549/3ca13d20-e57d-bcba-b9d7-d43cf904024f.png)

*諸事情によりモザイクをかけさせていただいております...
# 最後に
今回の反省点:
1.botを使うためにパソコンをつけっぱなしにしとかないといけない
2.できることが限定されている
3.たまに再実行をし続けすぎるせいで（おそらく時間がかかりすぎて）エラーがでる

今後の展望:
RenderとかのPaaS使って24時間365日できるようにしたいです。また、機能面では、複数のクラスルームを回ったり、更新を把握してそのデータだけ送ってくれたり自動で決まった時間にできたりすると便利になると思うので、色々改造していきたいと思います。
ここまで読んでくださりありがとうございました。

作成時に参考にさせて頂いたウェブサイト
スクレイピングに関して:

https://zenn.dev/cyber_hacnosuke/articles/70b8945783e135

https://ai-inter1.com/beautifulsoup_1/

https://note.com/okonomiyaki011/n/n62eb490a6f6d

LINEボットに関して:

https://zenn.dev/protoout/articles/16-line-bot-setup

https://www.tomotaku.com/line-repeat-bot/

https://qiita.com/mintak21/items/fe9234d4b6a98bfc841a

https://github.com/line/line-bot-sdk-python#synopsis