高専の単位を数えるためのWebページを作る

この記事は [木更津高専 Advent Calender 2021](https://qiita.com/advent-calendar/2021/nit_kisarazu) 16 日目の記事です。

前の記事は @Yuu-taremayu さんの [秘密分散を拡大体上で作ってみよう！](https://qiita.com/Yuu-taremayu/items/659a2659a7fa5349a677)
次の記事は @rime_math さんの [複素数を利用した三角関数の積分](https://qiita.com/rime_math/items/e96d41ad93bed40ae56a)
です。

# 動機
5年生になって

`あと何単位必要なんだ！！`
`わからん！！学生便覧とにらめっこだ！！`
`見づらい！！多い！！計算ミス！！`

ということが多発したので、作っていく🔪

# とりあえず成果
https://imaimai17468.github.io/Products/CreditsCounterforKNCT/

### 使い方
* 学科を選ぶ
* 取った科目にチェック
* `合計単位数を計算する`ボタンを押す
* 合計単位数が出てくる！！

# 制作の手順
1. 教科名・一般or専門・必修or選択 などをまとめたデータを用意
2. データを表としてHTMLに展開するためのプログラムを作成
3. 各チェックボックスを実装
4. 合計単位数計算を実装
5. CSSでデザインをいい感じに

# 処理環境
OS ： Windows10
(ただし実行環境はWSL2 / Ubuntu-20.04)
エディタ : VScode
Python : 3.8.10
beautifulsoup4 : 4.10.0
requests : 2.22.0

またサーバーなどを自前で用意するわけではなく、GitHub Pages上に実装しました。

[GitHubのリポジトリ](https://github.com/imaimai17468/imaimai17468.github.io/tree/master/Products/CreditsCounterforKNCT)

# データを用意する
今までこういうサイトがなかったのは、これが面倒くさいからかなと思うぐらい面倒でした。
データさえ用意できればあとはHTML/CSSとJSでゴリゴリするだけなので…

## 神器 Webスクレイピング
全て手打ちというのはさすがに死ぬので、Webスクレイピングでゴリゴリすることにします。
対象URLは[弊高専のWebシラバス](https://syllabus.kosen-k.go.jp/Pages/PublicDepartments?school_id=14&lang=ja)です。

ひとつ注意なのが、__Webスクレイピングはやり方を誤れば犯罪になります。__
こわいね

私がWebシラバスをWebスクレイピングする上で留意した点をまとめます。

|留意点|私の考え方|
|:--:|:--|
|著作権|「教科名」「科目区分」「単位数」等の収集するウェブ情報は、__著作権法上保護される「著作物」に該当しない__<br>「著作物」とは、「思想又は感情を創造的に表現したものであって、文学、学術、美術又は音楽の範囲に属するものをいう。」(著作権法第2条第1項第1号)|
|利用規約等による法的拘束力|Webシラバスは利用者に制限をかけずにサービス提供を行っているため、当事者間での意思の合致があったとは言えず、__合意が成立してるとは言い難く拘束力が働くとまではいえない__<br>（会員登録やログインページがあるサイトは、利用規約等への合意を条件としてサービスを提供しているため合意したとみなされ、法的な拘束力が有効となる）|
|アクセスの頻度による業務妨害|本スクレイピングはデータを取得したいときに一回だけ行うものであり、サイト側のサーバーに過度の負荷をかけ、程度を超えてアクセスするものではない。<br>（これが程度を超えると刑法上の偽計業務妨害罪に該当する可能性がある）|

[Webスクレイピングの注意事項一覧](https://qiita.com/nezuq/items/c5e827e1827e7cb29011) という記事があるので、これを参考にしてみてください。

では実際にWebスクレイピングをしていきます。
Webシラバスの表からデータを持ってきたいのですが、表はHTML上で以下のような構成が連なっています。

```html
<tr class="" data-course-value="">
  <td class="{c1 or c2}">{一般or専門}</td>
  <td>{必修or選択}</td>
  <td>
    <div class="subject-item" id="subject_0250">
      <a class="mcc-show" href="hogehoge">{教科名}</a>
      <span class="mcc-hide">{教科名}</span>
    </div>
  </td>
  <td>0250</td>
  <td>{履修単位or履修単位}</td>
  <td>{単位数}</td>
  <td class="c1m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c1m" style="display:none;"></td>
  <td class="c1m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c1m" style="display:none;"></td>
  <td class="c2m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c2m" style="display:none;"></td>
  <td class="c2m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c2m" style="display:none;"></td>
  <td class="c3m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c3m" style="display:none;"></td>
  <td class="c3m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c3m" style="display:none;"></td>
  <td class="c4m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c4m" style="display:none;"></td>
  <td class="c4m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c4m" style="display:none;"></td>
  <td class="c5m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c5m" style="display:none;"></td>
  <td class="c5m" colspan="2">{数字があったりなかったりする}</td>
  <td class="c5m" style="display:none;"></td>
  <td width="122">{教員名}</td>
  <td style="">{備考}</td>
</tr>
```

ほしい情報は以下の通りです。

* 教科名
* 一般科目か専門科目か
* 必修科目か選択科目か
* 単位数
* 学年

これらを取得するために、classや書かれている位置からスクレイピングしていきます。
先に行っておくと、全体のコードは章末に置いておくので、まずは部分部分の説明をします。

まずWebシラバスのURLは
`https://syllabus.kosen-k.go.jp/Pages/PublicSubjects?school_id=' + 学校のID + '&department_id=' + 学科のID + '&year= ' + 西暦 + ' &lang=ja`
みたいな構成になっています。
木更津高専のIDは`14`、学科はそれぞれ `11~15`に割り振られているので、これらのURLをスクレイピングの対象にします。
これを踏まえてプログラムの土台を書いていきます。

```python
import requests
from bs4 import BeautifulSoup

school_id = '14'
department_id = ['11', '12', '13', '14', '15']
year = '2021'

for department in department_id:
    url = 'https://syllabus.kosen-k.go.jp/Pages/PublicSubjects?school_id=' + school_id + '&department_id=' + department + '&year= ' + year + ' &lang=ja'
    html = requests.get(url)
    soup = BeautifulSoup(html.text, 'html.parser')

    # データを取得するhogehoge

    # データをCSV出力するhogehoge
```

### 教科名を取得する
教科名は`mcc-show`と`mcc-hide`の両方で取得できそうですが、`mcc-hide`のほうが全体的にすっきりしているので私はそっちにしました。

```python
subjects = []
for element in soup.find_all(class_="mcc-hide"):
    name = element.get_text()
    subjects.append(name)
```

### 必修or選択・一般or専門・単位数を取得する
この三項目はclass指定が特にないので、`tdタグ`でまとめて取得します。
必修or選択・一般or専門はそのまま絞り込めばいいですが、単位数に関しては数字で絞り込むと、上のHTMLの構成で示した`{数字があったりなかったりする}`の数字も取得してしまいます。
なので、`学習単位or履修単位`の次にくる数字を単位数として取得することにしました。

```python
ippan_senmon = []
hissyu_sentaku = []
tanni = []
tanni_flag = False
for element in soup.find_all('td'):
    data = element.get_text()
    if data == '一般' or data == '専門':
        ippan_senmon.append(data)
        
    if '必修' in data or '選択' in data:
        hissyu_sentaku.append(data)
        
    if '単位' in data:
        tanni_flag = True
        continue

    if tanni_flag and len(data) == 1 and data != '前' and data != '後':
        tanni.append(data)
        tanni_flag = False
```

### 学年を取得する
学年は"c1m"やら"c2m"というクラスに数字が入っていればその学年だとわかります。
例えば

```python
<td class="c1m" colspan="2"></td>
<td class="c1m" style="display:none;"></td>
<td class="c1m" colspan="2"></td>
<td class="c1m" style="display:none;"></td>
<td class="c2m" colspan="2"></td>
<td class="c2m" style="display:none;"></td>
<td class="c2m" colspan="2"></td>
<td class="c2m" style="display:none;"></td>
<td class="c3m" colspan="2"></td>
<td class="c3m" style="display:none;"></td>
<td class="c3m" colspan="2"></td>
<td class="c3m" style="display:none;"></td>
<td class="c4m" colspan="2"></td>
<td class="c4m" style="display:none;"></td>
<td class="c4m" colspan="2"></td>
<td class="c4m" style="display:none;"></td>
<td class="c5m" colspan="2"></td>
<td class="c5m" style="display:none;"></td>
<td class="c5m" colspan="2">2</td>
<td class="c5m" style="display:none;"></td>
```

とかだと、`c5m`に数字があるので5年生の科目だとわかります。
ちなみにWebシラバスは学年順で教科が並んでるので、`ここまでが1年生`、`ここまでが2年生`という境界がわかればそれ以下の教科の学年も全てわかります。
なので以下の手順で取得できます。

* class `c1m` ~ `C5m`があるテキストをすべて格納する
* テキストは`''(何もなし)`か`数字1文字`なので、4つの`c{学年}m`のうち一つでも数字を含んでいればその学年、全て`''`のときは次の学年

```python
cnm = [[] for i in range(5)]
for i in range(1, 6):
    class_name = 'c' + str(i) + 'm'
    for element in soup.find_all(class_=class_name):
        name = element.get_text()
        cnm[i-1].append(name)

grade = []
grade_count = 0
cnt = 0
for i in range((int)(len(cnm[0]) / 4)):
    if cnm[grade_count][cnt] != '' or cnm[grade_count][cnt+1] != '' or cnm[grade_count][cnt+2] != '' or cnm[grade_count][cnt+3] != '':
        grade.append(grade_count + 1)
    else:
        grade_count += 1
        grade.append(grade_count + 1)
    
    cnt += 4
```

## CSVに出力する
各項目が得られたので、あとはCSV形式で出力すればいいだけです。
ただ、教科によっては人数が多くて複数クラスあるものがあるので（英語演習とか）、それは一つに統一します。

```python
eigo1a = False
eigo1b = False
f = open('./' + department + '.csv', 'w')
f.write('教科名,学年,科目,区分,単位数\n')
for i in range(len(subjects)):
    if '日本語' in subjects[i]:
        hissyu_sentaku[i] = '必修（留学生）'
    if eigo1a and subjects[i] == '英語演習ⅠＡ':
        continue
    if eigo1b and subjects[i] == '英語演習ⅠＢ':
        continue
    if subjects[i] == '英語演習ⅠＡ':
        eigo1a = True
    if subjects[i] == '英語演習ⅠＢ':
        eigo1b = True
    f.write(subjects[i] + ',' + str(grade[i]) + ',' + ippan_senmon[i] + ',' + hissyu_sentaku[i] + ',' + tanni[i] + '\n')
```

これでWebスクレイピングのプログラムは完成です。以下に全体像を貼っておきます。

```python
import requests
from bs4 import BeautifulSoup

school_id = '14'

department_id = ['11', '12', '13', '14', '15']
# department_id = ['11']

year = '2021'

for department in department_id:
    url = 'https://syllabus.kosen-k.go.jp/Pages/PublicSubjects?school_id=' + school_id + '&department_id=' + department + '&year= ' + year + ' &lang=ja'
    html = requests.get(url)
    soup = BeautifulSoup(html.text, 'html.parser')

    subjects = []
    for element in soup.find_all(class_="mcc-hide"):
        name = element.get_text()
        subjects.append(name)

    ippan_senmon = []
    hissyu_sentaku = []
    tanni = []
    tanni_flag = False
    for element in soup.find_all('td'):
        data = element.get_text()
        if data == '一般' or data == '専門':
            ippan_senmon.append(data)
        
        if '必修' in data or '選択' in data:
            hissyu_sentaku.append(data)
        
        if '単位' in data:
            tanni_flag = True
            continue

        if tanni_flag and len(data) == 1 and data != '前' and data != '後':
            tanni.append(data)
            tanni_flag = False

    cnm = [[] for i in range(5)]
    for i in range(1, 6):
        class_name = 'c' + str(i) + 'm'
        for element in soup.find_all(class_=class_name):
            name = element.get_text()
            cnm[i-1].append(name)

    grade = []
    grade_count = 0
    cnt = 0
    for i in range((int)(len(cnm[0]) / 4)):
        if cnm[grade_count][cnt] != '' or cnm[grade_count][cnt+1] != '' or cnm[grade_count][cnt+2] != '' or cnm[grade_count][cnt+3] != '':
            grade.append(grade_count + 1)
        else:
            grade_count += 1
            grade.append(grade_count + 1)
        
        cnt += 4

    eigo1a = False
    eigo1b = False
    f = open('./' + department + '.csv', 'w')
    f.write('教科名,学年,科目,区分,単位数\n')
    for i in range(len(subjects)):
        if '日本語' in subjects[i]:
            hissyu_sentaku[i] = '必修（留学生）'
        if eigo1a and subjects[i] == '英語演習ⅠＡ':
            continue
        if eigo1b and subjects[i] == '英語演習ⅠＢ':
            continue
        if subjects[i] == '英語演習ⅠＡ':
            eigo1a = True
        if subjects[i] == '英語演習ⅠＢ':
            eigo1b = True
        f.write(subjects[i] + ',' + str(grade[i]) + ',' + ippan_senmon[i] + ',' + hissyu_sentaku[i] + ',' + tanni[i] + '\n')
```

# データを表としてHTMLに展開する
HTMLの全体像はChromeの検証やらで見てください。
CSV形式で保存したデータをHTMLに展開します。
まず表を展開したい箇所で
`<div class="output_csv_{学科名(m, e, ...)}"></div>`
みたいな感じで識別するためのclassを持ったdivタグを作ります。
そのclassをjsで読み取って、そのinnerHTMLを編集することでデータを表として展開します。

```javascript
const outputElement_m = document.getElementById('output_csv_m');
const outputElement_e = document.getElementById('output_csv_e');
const outputElement_d = document.getElementById('output_csv_d');
const outputElement_j = document.getElementById('output_csv_j');
const outputElement_c = document.getElementById('output_csv_c');

const department = ['m ', 'e ', 'd ', 'j ', 'c '];
const department_ = ['m_', 'e_', 'd_', 'j_', 'c_'];
let department_count = 0;

function getCsvData(dataPath, outputElement) {
    const request = new XMLHttpRequest();
    request.addEventListener('load', (event) => {
        const response = event.target.responseText;
        convertArray(response, outputElement);
    });
    request.open('GET', dataPath, true);
    request.send();
}

function convertArray(data, outputElement) {
    const dataArray = [];
    const dataString = data.split('\n');
    for (let i = 0; i < dataString.length; i++) {
        dataArray[i] = dataString[i].split(',');
    }
    dataArray.pop();
    let insertElement = '';
    let rowcnt = 0;
    let colcnt = 0;
    dataArray.forEach((element) => {
        insertElement += '<tr>';
        element.forEach((childElement, index, array) => {
            if(rowcnt === 0) {
                insertElement += `<th>${childElement}</th>`
            }else if(colcnt === 0) {
                let add_class = department[department_count];
                if(array[index + 2] === '一般') add_class += 'normal ';
                else add_class += 'special ';
                if(array[index + 3] === '必修') add_class += 'required';
                else if(array[index + 3] === '選択') add_class += 'elective';
                else if(array[index + 3] === '必修（留学生）') add_class += 'international';
                else add_class += 'other';
                insertElement += `<td><label><input type="checkbox" class="${add_class}">${childElement}</label></td>`
            }else if(colcnt === 4) {
                insertElement += `<td class="${department_[department_count]}credit">${childElement}</td>`
            }else{
                insertElement += `<td>${childElement}</td>`
            }
            colcnt++;
        });
        insertElement += '</tr>';
        rowcnt++;
        colcnt = 0;
    });
    department_count++;
    outputElement.innerHTML = insertElement;
}

getCsvData('./js/11.csv', outputElement_m);
getCsvData('./js/12.csv', outputElement_e);
getCsvData('./js/13.csv', outputElement_d);
getCsvData('./js/14.csv', outputElement_j);
getCsvData('./js/15.csv', outputElement_c);
```

めちゃくちゃ煩雑としてますが、手順を追うと

* `getElementById`を使うことで、指定したclassの情報を読み取る
* [getCsvData 関数] CSVファイルを開いて`convertArray`にデータを渡す
* [convertArray 関数] 各要素を配列に格納した後、trタグやたtdタグを使って表を作っていく

の三手順です。
なぜconvertArray 関数がこんなことになっているのかというと、後々チェックボックスを実装するとき、まとめてチェックを入れる際などに、項目ごとにclassを設定しておくと楽だからです。
また、それと同時に各教科のチェックボックスも
`<td><label><input type="checkbox" class="${add_class}">${childElement}</label></td>`
の箇所で実装しています。

# 一斉チェックボックスを実装する
これもHTML上に
`<div class="option"></div>`
のように書いて、そこに各項目を一斉チェックできるチェックボックス群を展開します。

```javascript
const outputElement = document.querySelectorAll('.options');

for(let i = 0; i < outputElement.length; i++) {
    let insertElement = '';
    insertElement += `<p>必修科目のチェックを <input type="button" value="入れる" onclick="required_checked(true, ${i});"> <input type="button" value="外す" onclick="required_checked(false, ${i});"></p>`;
    insertElement += `<p>選択科目のチェックを <input type="button" value="入れる" onclick="elective_checked(true, ${i});"> <input type="button" value="外す" onclick="elective_checked(false, ${i});"></p>`;
    insertElement += `<p>一般科目(必修のみ)のチェックを <input type="button" value="入れる" onclick="normal_checked(true, ${i});"> <input type="button" value="外す" onclick="normal_checked(false, ${i});"></p>`;
    insertElement += `<p>専門科目(必修のみ)のチェックを <input type="button" value="入れる" onclick="special_checked(true, ${i});"> <input type="button" value="外す" onclick="special_checked(false, ${i});"></p>`;
    insertElement += `<p>全ての授業のチェックを <input type="button" value="入れる" onclick="all_checked(true, ${i});"> <input type="button" value="外す" onclick="all_checked(false, ${i});"></button></p>`;

    console.log(insertElement);

    outputElement[i].innerHTML = insertElement;
}
```
やってることを説明すると

* optionクラスの情報をまとめて格納
* それぞれに対してチェックボックス群を実装

みたいな感じです。

また、上のコードにあるのチェックするためのプログラム(required_checked()とかall_checked())はほぼ同じ形なので、一つ例を載せます。

```javascript
function normal_checked(condition, department){
    let department_list = [".m", ".e", ".d", ".j", ".c"];
    let all_list = document.querySelectorAll(".normal.required" + department_list[department]);
    for(let i in all_list){
        all_list[i].checked = condition;
    }
}
```

これは一般科目（必修のみ）の教科を一斉にチェックする関数です。

まず、表に展開する段階で、各教科のチェックボックスに各項目をclass(一般科目ならnormal、必修ならrequired)として実装しているので、チェックしたいチェックボックスをquerySelectorAll()で取得します。
それぞれに対して、指定した`condition(true or false)`を代入することで、一斉にチェックボックスを入れる/外すという動作を実装しています。

# 合計単位数計算を実装
指定した学科でチェックが入ってるボックスの単位を合計して出力すればいいです。

```javascript
function credit_click(department){
    let department_list = [".m", ".e", ".d", ".j", ".c"];
    let department_list_ = ["m", "e", "d", "j", "c"];
    let all_list = document.querySelectorAll(department_list[department]);
    let credit_list = document.getElementsByClassName(department_list_[department] + "_credit");
    let all_credit = document.getElementById("credit_count_" + department_list_[department]);

    let credit_count = 0;
    for(let i = 0; i < all_list.length; i++){
        if(all_list[i].checked){
            let credit_value = parseInt(credit_list[i].innerHTML);
            credit_count += credit_value;
        }
    }

    all_credit.innerText = String(credit_count);
    console.log(credit_count);
}
```

# CSSでデザインをいい感じに
CSSは雰囲気コーディングしか知らないので、`見出し　デザイン`とか`タブ分け　デザイン`みたいにググっていい感じのを持ってきました。
ただスマホなどでも見えるようにレスポンシブにするのは自分でごにょごにょしました。
ここで初めて`vw`という単位を知ります。すごいですねこれ。

# 完成＆まとめ
ちゃんと計算できた時は
`うおーーー！！すげーー！！`
って製作者なのになってました

単位計算するときに、担任から
`計算してくれるような便利なものはありません！！自分で計算しろ！！`
みたいに言われてたので、今後後輩くんたちがそれで苦労しないことを祈るばかりです。

あと、勘のいいひとはわかるかもしれませんが、

* スクレイピングの学校IDを別の高専のIDに変える
* jsやHTMLの学科をその高専の学科に対応させる

ことで、__Webシラバスさえあればこのサイトは別の高専でも流用可能です。__
やる気のある人はゴリゴリやってみてください！！