GPA計算機 for UTAS を Python & Selenium in docker で書いた

# 1. 概要

UTASとはUTokyo Academic affair System、すなわち東大の学務システムです。

履修登録などができるごく一般的なWebサイトで、成績も表示されます。

...が、なぜかGPAが計算されません。

GPAとか平均成績順位率の欄はちゃんと用意されているのに、そこは永遠に0のまま。これはバグなのでは。それとも他の学部の人だと正しく表示されてたりするのか...?

![2021.10.10-01.52 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1553521/10fcfadc-1d8d-f459-3ffe-08b09ca55fab.png)


ということで、DIYの精神を発揮してGPA計算機を作りました。ちなみに初投稿です。

# 2. 使用した技術

- Python : 書きやすい。
- Selenium : Webブラウザの操作を自動で行ってくれるフレームワーク。UTASは当然APIなど提供していないのでクリックを多用して愚直にいじります。
- docker : 上記二つをローカル環境に依存せずに走らせたいのでコンテナを作ることにします。

# 3. 成果物

結論から言うとできたものがこれで、使うための情報は多分readmeに揃っています。
ローカル環境に必要なのはdockerとdocker-composeだけで、操作用のブラウザとPython実行環境はコンテナ内に置かれます。

現在の仕様としては、まず前期課程・後期課程などに関わらず全ての単位をひっくるめて計算します。

設定ファイルで以下を変更できます。

- 各成績の点数重みづけ デフォルトで(優上:4.3, 優:4.0, 良:3.0, 可:2.0, 不可:0.0)
- 「科目GP」欄にチェックが入っている科目を計算に含めるかどうか

https://github.com/U-Ar/gcu

# 4. 構成・実装

## ディレクトリ構成

```
gcu/
   ├app/
   │   ├Dockerfile
   │   ├gcu.py
   │   ├requirements.txt
   │   └settings.txt
   ├docker-compose.yml
   └readme.md
```

## docker-compose.yml

docker-composeを用いて2つのコンテナを立てます。

- seleniumコンテナ
    - SeleniumHQ/docker-seleniumイメージからコンテナを立てます。ポートは4444、ブラウザはFirefox。
- appコンテナ
    - ./appをマウントしつつ、Dockerfileを読み込んでPython実行環境を作ります。

```yml
version: "3"
services:
  selenium:
    image: selenium/standalone-firefox-debug:3.141.59
    ports:
      - 4444:4444
  app:
    build: ./app
    volumes:
      - ./app:/app
    environment:
      SELENIUM_URL: http://selenium:4444/wd/hub
    tty: true
    volumes:
      - /dev/shm:/dev/shm
```

## Dockerfile

```dockerfile
FROM python:3.6

ENV PYTHONIOENCODING utf-8

WORKDIR /app

COPY . .

RUN pip install -r requirements.txt
```

## requirements.txt

```
###### Requirements without Version Specifiers ######
selenium

###### Requirements with Version Specifiers ######
```

## settings.txt

GPA計算用の設定を記述します。

```
# This is the setting file of GCU. Don't edit except for the values of the parameters.
4.3   # point of "優上"
4.0   # point of "優"   
3.0   # point of "良"
2.0   # point of "可"
0.0   # point of "不可"
1     # 1 if you include courses whose "科目GP" is "*". If you don't want to include, set 0.
```

## gcu.py

main部のみ抜粋。

```python
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

if __name__ == "__main__":
    print("--- GPA Calculator for UTAS (GCU) v1.0 powered by Python & selenium ---")
    
    if len(sys.argv) > 1:
        print_desc()
        sys.exit(0)

    student_id = input("student ID: ")
    student_pass = getpass.getpass("password: ")

    # set options to start Firefox
    options = webdriver.FirefoxOptions()
    options.headless = True 

    # open the new window of the browser
    print("connecting to the remote browser...")
    driver = webdriver.Remote(
        command_executor=os.environ["SELENIUM_URL"],
        desired_capabilities=options.to_capabilities(),
        options=options
    )

    try:
        calc(driver,student_id,student_pass)
    except Exception as e:
        print(e)
    finally:
        driver.quit()
```

## 出力例

```
--- GPA Calculator for UTAS (GCU) v1.0 powered by Python & selenium ---
student ID: HogeHoge
password: 
connecting to the remote browser...

Reading setting.txt ...
Successfully read setting.txt.

Successfully accessed to UTAS and got score tables.

Courses taken into account              | credits | score
ダミー工学                                   2.0      4.0
ダミー入門                                   2.0      3.0
ダミー実験                                   1.0      2.0

--- Result ---------------------
Number of credits: 5.00
Sum of scores    : 16.00
GPA              : 3.20
--------------------------------
```



# 5. 参考資料

https://qiita.com/Chanmoro/items/9a3c86bb465c1cce738a

Seleniumをすぐに使えるようになるチュートリアル。

https://qiita.com/ryoheiszk/items/93b2d52eec370c09a22e

dockerの構成を参考にさせていただきました。

# 6. 困ったこと
Seleniumは割と繊細らしく、途中でいくらかハマったので記録を残しておきます。

- Chromeだとiframe内の要素のクリックに失敗しやすい
    - input is not clickable なるエラーが出る。
    - 解決できなかったので最終的にFirefoxに切り替えた。その後は安定した。
- 画面が切り替わった直後はcheckboxのクリックなどが認識されない   
    - body要素を一度クリックするなどして画面にフォーカスを当てる必要がある。
- よく分からないけどなぜかエラーが出る
    - ページ読み込みなどを待つ時間を設けると解決する場合が意外に多い。
        - 明示的な待機(WebDriverWait)、暗黙的な待機(implicitly_wait)、Pythonによる強制スリープ(time.sleep)の3つを活用する。


ローカル環境にできるだけ依存しないように作ったとはいえ、それでもdockerが必要なので一般の使用にはハードルが高いのは反省点です。

最も手軽に利用可能な形はブラウザ拡張機能だと思われます。

それをしなかったのはひとえに拡張機能の開発経験が無かったからなので、気が向けばそちらも調べたいところ。