大学の学習支援システムを拡張して課題の締切を定期的にメールするシステムを構築した話

# きっかけ
僕の大学では授業の管理はすべてBlackBoardベースの独自学習支援システムで行われており、授業動画やテスト、レジュメの配布、課題の提出などもほぼすべてこのシステム上のWebサイトを通して行われている。
このサイトには課題の締切一覧がTODOリストという形で表示されているが、締切が迫っているからと言ってなにか通知をしてくれるわけでもなく、ログインしなければ課題の確認すらすることができない。一言で言えば、使いにくかった。
そこで、課題の締切を定期的に自分宛にメールを送ることで常に締切を確認できるようなシステムを構築することにした。

# システム図
こういう流れで設計しました。
![Untitled Diagram.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017430/8c4c1322-de3d-87cb-cb5c-a7afc329bfef.png)

# 1. BlackBoardにログイン
まず、課題一覧の取得には[公式のSwagger](https://developer.blackboard.com/portal/displayApi)にある
```
/learn/api/public/v1/calendars/items
```
というAPIを利用することにした。そのためには当然、ログイン処理が必要となってくる。
最初の案としては同じくSwagger上にある
```
/learn/api/public/v1/oauth2/token
```
にPOSTしてログインできないかと考えたが、これをするにはそもそもBlackBoardの管理者？じゃないと行けないらしく断念。


次にうちの大学のSSO認証でログインする案にした。具体的には、seleniumで自動的にChromeを起動してID,パスワードを入力してログインまで行い、APIを叩くまで自動で行うプログラムを組むという案だった。
しかし、これも断念することになった。
うちの大学は２段階認証を導入しており、ID,パスワード以外にもキーの入力が必要だった。普段使っている分には、１ヶ月に１回しかキーの入力を求められないのだが、プログラムで自動入力しようとすると毎回必ずこの２段階認証に引っかかってしまう。このキーを取得する手段が見つけられず、諦めることとなった。


結局、ログインまではユーザにやってもらうのが１番楽な方法だということになり、問題はひとまず解決（？）した。

# 2. Chromeの拡張機能でAPIを叩く & WebアプリにPOST
1.でログインが終わったので、次はどうやってAPIを叩くかを考えた。
素直に/learn/api/public/v1/calendars/itemsをURL欄に入力すればJSONが帰ってきて、そこから課題の締切を手打ちする…なんてのは無駄なので、なんとか自動で取れる方法を模索したところ、Chromeの拡張機能の自作に行き着いた。
Chrome Extensionの作成に関しては説明を省くので、ざっくりソースコードのみ載せる。

やってることとしては、バックグラウンドで常に拡張機能を起動させておき、
BlackBoardのサイトにアクセスした時に自動的にAPIをたたき、その結果を整形してGASで作ったWebアプリにPOSTするだけである。

```javascript:background.js(抜粋)

const homePageUrl = "BlackBoardのURL";

// 課題一覧のJSONを取得する
function getAssignmentList() {
    const apiUrl = "{ドメイン名}/learn/api/public/v1/calendars/items";
    fetch(apiUrl).then(
        (r) => {
            if (!r.ok) {
                console.log(`HTTP Response Error Status:${r.status}`);
            }
            else {
                r.json().then((res) => {
                    const results = res["results"];
                    const options = {
                        method: 'POST',
                        mode: 'no-cors',
                        headers: {
                            Accept: 'application/json',
                            'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',

                        },
                        body: JSON.stringify(res)
                    }
                    const url = "GASで作ったWebアプリのURL";
                    fetch(url, options);
                    );

                })
            }
        }
    )
}

// BlackBoardのホームページを訪れたor更新したときのみgetAssignmentListを発火する
chrome.tabs.onUpdated.addListener(
    (tabId, changeInfo, tab) => {

        if (changeInfo.status === 'complete' && tab.url.startsWith(homePageUrl)) {
            chrome.scripting.executeScript({
                target: { tabId: tabId },
                function: getAssignmentList
            });
        }
    }
)


```
ここでのミソは
```javascript
method: 'POST',
mode: 'no-cors',
headers: {
           Accept: 'application/json',
           'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
```
の部分で、GASにPOSTするときはなぜかapplication/x-www-form-urlencodedを選ばないといけない。
また、普通にAPIを叩こうとするとCORSに引っかかってしまうので、mode:no-corsを選ぶ。
最後に、このままでは日本語が文字化けしてしまうため、charset=utf-8を指定した。


# 3.GASでWebアプリを実装
次はGASでWebアプリの作成に移った。GASを選んだ理由はChromeの拡張機能と相性が良さそうという偏見と、Gmailで送信するには都合がいいだろうとの判断だった。
ここも詳細な説明は省き、ソースコードのみ載せる。
```javascript:コード.gs
const sheetid = "スプレッドシートのID";
const sheet_name = 'シート1'
const sheet = SpreadsheetApp.openById(sheetid).getSheetByName(sheet_name);
function doPost(e) {
  Logger.log("POST");
  const params = JSON.parse(e.postData.getDataAsString());
  const results = params["results"];
  sheet.clear();
  sheet.appendRow(["calendarName","title","end"]);

  results.forEach((elem) => {
// JSON上の日時はただのstringになってしまうのでDateに戻す
    let endDate = new Date();
    endDate.setTime(Date.parse(elem.end));
    sheet.appendRow([elem.calendarName,elem.title,endDate]);
    Logger.log(elem.calendarName);
    Logger.log(elem.title);
    Logger.log(endDate);
  })
}

function sendMail() {
  const startRows = 2;  // 開始行数
  const  sheetData = sheet.getSheetValues(startRows, 1, sheet.getLastRow() - 1, sheet.getLastColumn());  // シートのデータを取得（2次元配列）
  // シートの各行ごとにデータを取り出す
  sheetData.forEach(function(value, index) {
    Logger.log(value);
   const recipient = "自分宛てのメアド";
   const diffTime =new Date().getTime() - new Date(value[2]).getTime() ;
   const diffHour = Math.floor(diffTime / (1000 * 60 * 60));
   const subject = `【自動送信】${value[0]} ${value[1]}が締め切り${-diffHour}時間前となりました`;
   const body = `このメールは自動送信です。 すでに課題を提出している場合でも、このメールは送信されます。 ${value[0]} ${value[1]}が締め切り${-diffHour}時間前となりました。`
   const options = {name:"送信者"};
   GmailApp.sendEmail(recipient,subject,body,options);
  });
}
```

doPost()関数はPOSTリクエストに対して行われる動作で、ここでは課題名、課題の授業名、締切を取り出してスプレッドシートに出力している。
sendMail()関数はメール送信を行う関数で、これを時間トリガーで定期的に実行するようにしている。

ここでのハマりポイントがログの出力だった。最初、Logger.log()を書いてもdoPost()のログが出力されずPOSTされた内容を確認できないで困っていた。
実はGASでのログの確認にはGoogle Cloud Platformの登録が必要だった。
設定をゴニョゴニョやって、以下のような画面までたどり着いた。
![screenshot1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017430/02c0d5b9-0be7-8f64-dc3b-ccbf9d29d543.png)

# 4. トリガー設定
あとはメールを定期的に送信するトリガーの設定だ。と言っても、GASの設定でトリガーを設定して、sendMail()を走らせるだけなので割愛。

# 成果
![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017430/68604050-242d-8492-c8b6-ff2d22026ce8.png)

こんな感じで締切をメールで２時間おきに伝えてくれるようになった。

# 改善点
このシステムを思いついてから大体7,8時間で完成まで持っていったのでコーディングのガバさとセキュリティ面のひどさが際立つ。そのへんを解決して自分用から配布できるレベルまで持っていけるようになるといい。
また、LINE@との連携や課題の締切の1時間、２時間、３時間、６時間、１２時間前に通知するなどバラバラの間隔でメール送信ができるようになるともっと便利になると思う。

# まとめ
こうやって自分の力で役に立つものを作り上げるのは楽しい。

# 参考文献
https://qiita.com/sakaimo/items/129a785146e5873c42a6#browser-action-%E3%81%AE%E3%83%87%E3%83%90%E3%83%83%E3%82%B0

https://zenn.dev/noraworld/articles/chrome-extensions-cors

https://qiita.com/sakaimo/items/f1b2b8353da1e7de6e3d#chrome%E6%8B%A1%E5%BC%B5%E3%81%AE%E3%82%B3%E3%83%BC%E3%83%89

https://qiita.com/shirakiya/items/db22de49f00710478cfc

https://ryjkmr.com/google-apps-script-console-log/#Google_Cloud_Platform_GCP_Standard_Project

https://qiita.com/legokichi/items/801e88462eb5c84af97d

https://qiita.com/nassy20/items/adc59c4b7abd202dda26

https://www.astina.co/media/system/post-1137