WebClassの更新を通知してくれるプログラムを作った話

:::note warn
注意
WebスクレイピングはWebサイトによっては規約等で禁止されていることもあります。実施する際はあらかじめよく確認した上で、テスト運用であってもサーバに高負荷をかけないよう注意して行いましょう。
:::

**【2024.04.01追記】**

&emsp;ソースコードとディレクトリ構成の例を新たに加えました。コメントくださった @Tgwebmaster さんに御礼申し上げます。

## はじめに

&emsp;皆さんこんにちは、Hagianです。現在、山形大学大学院の博士前期課程2年に在籍しています。本稿では学部生時代に、個人使用を目的として開発したプログラムについて解説します。

## 開発に至った経緯

&emsp;2020年春。新型コロナウイルスの感染が急拡大していたことを受け、大学では前期開始が延期された後、授業形式が対面からオンラインに変更・実施されました。当時学部3年生に進級した筆者は、研究室配属に向けて専門の科目を多く履修する予定でした。

&emsp;山形大学では、LMS（Learning Management System;学習管理システム）の一種であるWebClassが導入されていて、出題された課題や授業での連絡、さらには大学からの連絡などあらゆる情報が集約されていました。

&emsp;ところが、**教材が更新された際などの通知が全くなく**、WebClassにログインしてアクセスするまで気づくことができませんでした。大学からは「1日1回以上アクセスして情報収集に努めてください」とのアナウンスはあったものの、気づけずに未提出してしまうなどの事例が多発していました。

&emsp;そこで手動でチェックするのではなく、プログラムで自動チェックし、さらに更新があった場合には自分のスマホに通知が来る仕組みを作ろうと思い立ちました。

## 補足：LMSについて

<details><summary>※ちょっと解説するつもりが、思いの外長くなってしまったので折りたたんでいます…</summary><div>

&emsp;LMSは上述のとおり、学習管理システムのことで、コロナ禍を経た現在では、多くの大学等で導入されているのではないでしょうか。以下にいくつか有名なものを列挙します。

* Moodle

https://moodle.org/?lang=ja

おそらく日本の高等教育機関で1番導入されているLMS。コロナ前の2017年度時点での国内シェア[^1]は41.9%。オーストラリア発祥で、全世界でのユーザー数は約4億にものぼる。

[^1]:稲葉ほか(2019), 大学ICT推進協議会 2019年度年次大会論文集, 511–513. https://axies.jp/_files/report/publications/papers/papers2019/TP-27.pdf

導入校一例：九州大学、東京理科大学、沼津高専など

* WebClass

https://www.datapacific.co.jp/webclass/

日本発祥で、筆者の所属する大学でも導入されているLMS。コロナ前の2017年度時点での国内シェア[^1]は13.1%。

導入校一例：山形大学、学習院大学、舞鶴高専など

* manaba

https://manaba.jp/

日本発祥のLMS。コロナ前の2017年度時点での国内シェア[^1]は9.9%。

導入校一例：筑波大学、中央大学、阿南高専など


* Universal Passport

https://www.jast-gakuen.com/products/unipa/

日本発祥のLMSで、近年シェアを急速に拡大させている。コロナ前の2017年度時点での国内シェア[^1]は18.8%で、Moodleに次ぐ。教務システムを含めた統合的なシステムとなっていることが特徴にあげられる。

導入校一例：大阪市立大学、近畿大学、杏林大学など

* 独自開発

旧帝大を中心に、比較的規模の大きな大学でみられる傾向にあるが、近年はMoodleなどへの移行例もみられる（東京理科大学、早稲田大学など）。

一例：
* 東京大学（ITC-LMS）

https://itc-lms.ecc.u-tokyo.ac.jp/

* 京都大学（PandA）

https://panda.ecs.kyoto-u.ac.jp/portal/

</div></details>

## プログラム構成

![qiita.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1021972/191dd0e8-b7a1-5b1b-8479-d7340855cc5f.png)

&emsp;構成は上図のようになっています。PythonプログラムからWebClassのページを読み込み、教材一覧を取得し、テキストファイルに保存しました。このプログラムを定期的に動かし、テキストファイルに差分が発生した際にはSlackbotを通じて筆者の端末（PCおよびスマホ）に通知が飛ぶ仕組みです。

&emsp;定期的に動かすために、筆者は自分のMacbookからCrontabで3時間おきに実行するタイマーを設定していました。（※個人利用とはいえ、スクレイピングなのでサーバに負荷をかけないよう注意）

## 工夫した点など

&emsp;履修している科目/登録しているコースごとにページが分かれていた（URLが異なっていた）ため、これに対応するように構成を考えるのに少し苦労しました。また、私の知識不足をなんとか~~ごまかす~~補うため、科目ごとにPythonコードを記述したファイルを作り、それを同ディレクトリ内においたメインファイルから呼び出していました。

&emsp;このような処理となった背景には、**ログインが必要なページだった**ということが挙げられますが、いまの自分ならもっと効率的に組めたかもしれません…。

## 【追記】 ディレクトリ構成

```
~/
├　hogehoge/
    ├ WebClassUpdateChecker/
        ├ logs/ # ログ等格納ディレクトリ
            ├ TS.txt # 実行時のタイムスタンプを保存、ファイル名に利用。
            ├ status/
                ├ {yyyymmddhhmm}_status.txt # HTTPステータスコードと実行時間を記録。
                ︙
                （実行回数分蓄積）
            ├ {yyyymmddhhmm}.txt　# スクレイピングによって取得したコードを記録。
                ︙
                （実行回数分蓄積）
        ├ refs/
            ├ MaterialList_{12345}.txt # 教材一覧を記録するファイル。5桁の科目コードなどを末尾につけて識別。
                ︙
                （登録コースの数だけ同様のファイルがある）
        ├ C_{12345}.py # 登録コースをスクレイピングするコード
            ︙
            （登録コースの数だけ同様のファイルを作成）
        ├ PostToSlack.py　　# Slackbot経由で通知するためのコード
        └ UpdateChecker.py # 全てを司るコード
```

## 【追記】 ソースコード例

&emsp;コメントにてご要望いただきましたので、一部情報をぼかしたうえで公開します。

<details><summary>科目/コースをスクレイピングするコード</summary><div>

```python:C_30000.py
import requests
import bs4
import urllib.parse
import codecs
import time
import re
import sys
import postToSlack as pts

# targetUrlにはログインページを指定
targetUrl = "https://ecsylms1.kj.yamagata-u.ac.jp/webclass/login.php?rnd=25c76&language=JAPANESE"
# targetLink~には科目/コースの個別ページを指定
targetLink_30000 = "https://ecsylms1.kj.yamagata-u.ac.jp/webclass/course.php/2030000/"
login_data = {"username":"ここにユーザのIDを入力","val":"ここにパスワードを入力"}

# 別ファイルから呼び出すためにも関数にまとめる
def requestWebsiteCheck():
    s = requests.session()
    # ログイン処理
    p = s.post(targetUrl,data=login_data)
    # ステータスコードを保存することによって、エラー時の原因判断に用いることができる
    print("login status:"+str(p.status_code),file=codecs.open("log.txt","a","utf-8"))
    
    p = s.get(targetLink_30000)
    
    print(p.text,file=codecs.open("log.txt","a","utf-8"))

    ld = open("log.txt")
    lines = ld.readlines()
    ld.close()
    
    # WebClassではログイン後、以下のように末尾に"acs_="に続く8桁16進IDが付与される
    # "course.php/2030000/?acs_=5943702c"
    # このIDを生かして個別ページに入るための処理
    for line in lines:
        if line.find("href") >= 0:
            ldline = line[:-1]

    link = ldline[24:67]

    targetLink_p = urllib.parse.urljoin(targetLink_30000,link)
    
    p = s.get(targetLink_p)
    print(p.text,file=codecs.open("log.txt","a","utf-8"))

    soup = bs4.BeautifulSoup(p.text,"html.parser")
    elems = soup.find_all(href=re.compile("webclass/do"))
    str_elems = str(elems)

    # 教材一覧の差分比較を行うtry-except
    try:
        rfile = open("refs/MaterialList_30000.txt")
        old_elems = rfile.read()
    except:
        old_elems = " "

    # 差分比較の結果、内容が一致すればpass
    if str_elems == old_elems:
        pass
    # 不一致なら新たな教材一覧を記録、Slackbot経由で通知送信
    else:
        rfile = open("refs/MaterialList_30000.txt","w")
        rfile.writelines(str_elems)
        rfile.close()
        pts.postToSlack2_35000()

    # ログアウト処理。先述のとおり、"acs_="に続く8桁16進IDを結合。
    ldl = open("log.txt")
    lines_ldl = ldl.readlines()
    ldl.close()

    for line in lines_ldl:
        if line.find("logout.") >= 0:
            ldlines_ldl = line[:-1]
    link_logout = ldlines_ldl[69:102]

    targetLink_out = urllib.parse.urljoin(targetUrl,link_logout)

    p = s.post(targetLink_out)
    print("logout status:"+str(p.status_code),file=codecs.open("log.txt","a","utf-8"))

    
# デバッグ用に単独ファイルでも実行可能にした
if __name__=="__main__":
    
    start = time.time()
    requestWebsiteCheck()
    end = time.time()
    print("Elapsed Time:"+str(end-start)+"s")
```

</div></details>

<details><summary>Slackbot経由で通知するためのコード</summary><div>

```python:PostToSlack.py
import slackweb

# 全ての科目/コースで用いる通知送信用のコードを記述

# 主にデバッグ用だが、更新がなかった場合
def postToSlack1():
    slack = slackweb.Slack(url="ここにSlack WebhookのURLを入力")
    slack.notify(text="更新はありません.")
    return

# 更新があった場合。科目/コースごとに異なるメッセージを送出するため、登録した科目/コースの数だけ記述。
def postToSlack2_30000():
    slack = slackweb.Slack(url="ここにSlack WebhookのURLを入力")
    slack.notify(text="プログラミングの資料に更新があります.WebClassを確認してください.")
    return

```
</div></details>

<details><summary>全てを支配するコード</summary><div>

```python:UpdateChecker.py
# 科目/コースの数だけ作ったスクレイピング用コードを読み込み
import C_30000 as c1
import C_30123 as c2
import C_guni as c3
import C_gsci as c4
import C_stdc as c5
import codecs
import datetime
import time
import sys


# 実行時刻のタイムスタンプ生成
def timeStamp():
    dt_now = datetime.datetime.now()
    return int(dt_now.strftime("%Y%m%d%H%M"))



if __name__=="__main__":

    # 実行時間計測
    start = time.time()
    logTStamp = timeStamp()
    # ログファイル生成
    with open(f"./logs/{logTStamp}.txt", "w"):
        pass
    # タイムスタンプ記録
    print(logTStamp, file=codecs.open(f"./logs/TS.txt","w","utf-8"))
    # 個別ファイルを順次実行
    c1.requestWebsiteCheck()
    c2.requestWebsiteCheck()
    c3.requestWebsiteCheck()
    c4.requestWebsiteCheck()
    c5.requestWebsiteCheck()
    end = time.time()
    # ステータス記録ファイルに実行時間を記録
    print("\nElapsed Time:"+str(end-start)+"s",file=codecs.open(f"./logs/status/{logTStamp}_status.txt","a","utf-8"))
```

</div></details>

## 実行例

&emsp;次の画像は実際にSlackbotから筆者宛に飛んできた通知のスクリーンショットです。

![スクリーンショット 2023-12-18 5.27.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1021972/7899247a-3dc6-5a46-c871-b9b10813ed6c.png)

&emsp;科目/コース名とともに、確認を促す一文を追加することで、見落としを可能な限り防げるようにしました。登録していたコースは最大時で20弱ありましたが、これらの巡回を1分未満で済ませられるようになり、かなり満足できました。

## 開発後記

&emsp;ログを確認したところ、2020年4月27日から本格運用に入り、学部を卒業する2022年春頃まで使っていたようです。学期ごとの授業開始前に、履修予定の科目等を確認し、読み込み先URLや通知文などを変更したファイルと旧ファイルの入れ替え作業を行っていました。

&emsp;以上で本記事を終わります。 ~~ソースコードは現時点での掲載予定はありませんが、いつか（？）公開するかもしれません。~~ （&rarr;公開しました！）
疑問点等ありましたらご教示いただけますと幸いです。