logo
::
2024-07-22

OITシラバスアプリを支える技術

#技術解説

はじめに

今更ですが、シラバスアプリに関する解説記事書こうと思って書いてないなぁってなったんでこの時期に書いてます。

そもそも何でこんなアプリを作ろうと思ったかを書くと、公式の検索システムが恐ろしいほど使いにくいからですね😓。
レスポンシブUI非対応は当たり前で、戻るボタン押したらエラー、条件指定が不十分で遅い検索、検索一覧には表示されている曜日と時限が詳細ページには非表示などなど…不満をあげたらきりが無いんですが、まあこれを4年間使い続けるのはストレスフルなのでどうにかしたいと思ったしだいです。

ちなみにこれを作り出したのが1年の冬で、次の履修登録までには間に合わせたかったので開発期間は3ヶ月です。
ただこのときはPython未経験でJavaScriptも触りたてという構想したはいいもののどこから手をつけて良いのか全くわからんって感じでした。
その時にないよなぁ~って想いながらGitHubで検索したらまさかのリポジトリがヒットして大喜びですよほんと。
しかも作ってる人がTwitterやってたんでDMに凸ったらやり方どころかコードまで提供してくれて感謝感激雨あられって感じで、正直これがなかったら頓挫してたと思うのでほんとありがたかったですね。

とここまで前置きが長くなりましたが、ここからは技術的な解説です。
このアプリケーションはスクレイピング対象のURLや曜日、時限を検索ページから抽出するextract、抽出したURLからスクレイピングを行うscraping、そしてスクレイピングした結果を表示するfrontendの3つの要素から成り立っています。

extract

https://github.com/oit-tools/syllabus-extract

個々のシラバスをスクレイピングするためにはまず講義コード(10AB01A0みたいなやつ)が必要なんですが、その一覧が公開されていない[1]ので、検索結果に表示されている部分から抽出しています。
また同時になぜか詳細ページには記載されていない曜日と時限の情報も取得しています。

検索結果
検索結果

抽出した情報は学部学科、URL、曜日、時限で構成されたCSVで保存しています。

情報科学部 情報知能学科,https://www.portal.oit.ac.jp/CAMJWEB/slbssbdr.do?value(risyunen)=2024&value(semekikn)=1&value(kougicd)=1EAN03A0&value(crclumcd)=10201200,水曜日 水曜日,1時限 2時限

残念ながら検索結果のページは静的URLではないので、Playwrightを使用して動的に取得しています。
ただ、タイムアウトのせいなのかエラーが起こることも多くて微妙に使いづらいんですが、これを動かすのは年度が変わった時、つまり1年に1回だけなので、まあいいかなって感じで放置しています。

scraping

https://github.com/oit-tools/syllabus-scraping

表からは一切見えないけど、一番力を入れて作った部分です。

例えばの混在、全角アルファベットや数字の混在、不必要な空白やタブ文字の存在、jsonデータにする際に邪魔となる"の存在、学部によってあったりなかったりするCSコースとスパイラル型教育用の欄の存在、科目によって記入されているかかどうかが変わる教科書と参考書用の欄の存在…などなど考慮することが多くてこの部分は苦労しました。

半角と全角の混合や不要な文字、空白の除去等。

def normalize(enter: str) -> str:
    """
    文字列を正規化
    """
    return new_line(
        unicodedata.normalize("NFKC", enter)
        .replace(",", "、")
        .replace("□", "")
        .replace("\t", "")
        .replace('"', "")
    )


def new_line(enter: str) -> str:
    """
    <BR>を\\nに変換
    """
    while enter.find("<BR><BR>") != -1:
        enter = enter.replace("<BR><BR>", "<BR>")
    return enter.replace("<BR>", "\\n")

CSコースとスパイラル型教育は全文からgrepしてワードが存在するかどうかで判定しています。

def cs_spiral(self, search_word):
    """
    CSコース、スパイラル型教育用の処理
    """
    text = self.text.replace("\\n", "")
    if search_word in text:
        word = re.search(rf"{search_word},(.*)", text).group(1)
        if len(word) > 0:
            self.values.append(word)
            self.correction += 1
        else:
            self.values.append("記載なし")
    else:
        self.values.append("記載なし")

一番の鬼門が教科書と参考書用の部分でした。
というものCSコースとスパイラル型教育と違い、全てのシラバスに行は存在するが中身があるかどうかはわからないので、単にテーブルのパースをかけてしまうとエラーになってしまうという状況でした。

中身がある例
中身がある例

中身がない例
中身がない例

そのため生のHTMLから参考書の中に出版社名という文字列があるかどうかで判定しています。教科書も同様です。
これに行き着くまでにHTMLを人力でパースしてどうにかできないかと探していたので、結構苦労しました。

# 教科書と参考書が記載されているか判定
is_textbook, is_reference_book = False, False
if text.find("出版社名") < text.find("参考書") and text.find("出版社名") > 0:
    is_textbook = True
if text.rfind("出版社名") > text.find("参考書"):
    is_reference_book = True

またもう一つ苦労したのがスクレイピングの非同期処理化です。
実はこのスクレイピングのプログラムはバージョン1と2があり、バージョン1の頃はスクレイピング対象の数も数百程度だったので30分ぐらいで終わってたんですが、バージョン2からは全学部を対象にしたので4500まで膨れ上がり、それに伴いスクレイピングの時間も、手元では12時間かかるという状況でした。
また、週次でスクレイピングしているのですが、手動でやるのもめんどくさいし忘れるのでGitHub Actionsで自動化していたんですが、制限時間が8時間だったために時間超過でエラーが発生して、どうにかしないといけないといった状況になりました。
ということで頑張って非同期処理化したところ、GitHub Actions上でも20分程度になり非同期処理の偉大さを実感しました笑[2]
ただ無配慮に非同期処理でスクレイピングしてしまう流石にサーバーに負荷がかかってしまいDoSみたいになってしまうのでSemaphoreにリミットを設けて過剰にリクエストされないようにしています。
Pythonの非同期処理もthreadingやasyncioだったり、asyncioでも書き方が複数あったりしてどれが一番良いのかだいぶと試行錯誤して作りました。
この頃はGitHub CopilotはあってもChatGPTとかはなかったんでひたすらWebの文献を参考に実験を繰り返してなんとか作り上げた記憶があります。

async def _get(self, client, data, semaphore):
    async with semaphore:
        department, url, dow, period = data.split(",")
        try:
            res = await client.get(url, timeout=self._timeout)
            print(department, url, res.status_code)
        except Exception as e:
            print(f"Error: {e}, {url}")
            res = await client.get(url, timeout=self._timeout)
        finally:
            text = normalize(res.text)

            # 教科書と参考書が記載されているか判定 (同上のため割愛)

            csv = converter(text)
            if len(csv) < self._invalid_data:
                return
            self._scraped_data.update(
                Parser().main(
                    csv,
                    department,
                    url,
                    dow,
                    period,
                    is_textbook,
                    is_reference_book,
                )
            )

async def _request(self):
    semaphore = asyncio.Semaphore(self._limit)
    client = httpx.AsyncClient()

    tasks = [self._get(client, data, semaphore) for data in self._data]
    await asyncio.gather(*tasks)
    await client.aclose()

これで取得したデータは以下のようになります。

"1BAN03A0": {
        "last_update_date": "2024/07/21",
        "lecture_title": "C演習I",
        "lecture_title_en": "C Programming Exercise I",
        "year": "1年次",
        "credit": "3単位",
        "term": "後期",
        "person": "水谷 泰治, 平岡 一剛, 山内 建二, 平嶋 洋一, 小谷 直樹, 尾花 将輝, 杉川 智, 平 博順, 中西 知嘉子, 大井 翔, 樫原 茂, 坂平 文博",
        "numbering": "1BAN03A0",
        "department": "情報科学部 情報システム学科",
        "url": "https://www.portal.oit.ac.jp/CAMJWEB/slbssbdr.do?value(risyunen)=2024&value(semekikn)=1&value(kougicd)=1BAN03A0&value(crclumcd)=10201200",
        "dow": "水曜日 水曜日",
        "period": "1時限 2時限",
        "aim": "プログラミングはコンピュータサイエンス、データサイエンス、およびそれらの理論や応用技術を修得するための基礎となります。プログラミングを理解することは、他の専門科目への理解を深めることにもなります。この演習では、1) 基本的なCプログラムの書き方と、2) 計算処理、条件判断処理、繰り返し処理、配列、関数などのC言語の基礎を学習し、3) 様々な課題をプログラムとして実現する方法を修得します。",
        "cs": "本授業科目はCSコース「学習・教育到達目標達成度判定基準と科目の対応」で(C)、(D1)に当たる。",
        "spiral": "本授業科目はスパイラル型教育のデザイン能力に対応する。",
        "themes": [
            "プログラムの作成と実行方法",
            "変数の型と演算",
            "if文による条件分岐(1)",
            "if文による条件分岐(2)",
            "for文を使った繰り返し",
            "while文を使った繰り返し",
            "多重構造の繰り返し",
            "配列の概念と利用",
            "配列を使用したプログラム",
            "関数の概念とその作成方法",
            "配列を引数にした関数",
            "配列と関数を使用したプログラム",
            "総合演習(1)",
            "総合演習(2)"
        ],
        "contents": [
            "演習の進め方について解説する。そして、C言語における基本的な変数の使用方法について解説する。そして、int型変数とキーボードからのデータ入力方法等について演習を行う。",
            "int型やdouble型の変数および型変換についての実習を行う。",
            "if文の基本的な記述方法やブロックif文・入れ子になったif文・等価演算子・関係演算子・論理演算子について演習を行う。",
            "前回の続きを行う。",
            "for文の基本的な記述方法とその使い方について実習を行う。同時に、インクリメント演算子とデクリメント演算子についても解説する。演習中に小テストを実施する。",
            "while文の基本的な記述方法とその使い方について実習を行う。また、レポート課題を出題する。",
            "for文やwhile文を組み合わせた多重ループについての実習を行う。",
            "配列の概念とその利用方法について演習を行う。演習中に小テストを実施する。",
            "配列を使ったより複雑なプログラムを作成する。",
            "関数の概念とすでに用意されている関数の利用方法について演習を行う。また、関数の作成方法についても演習を行う。",
            "関数の引数で配列を使う方法について実習を行う。また、レポート課題を出題する。",
            "配列と関数およびこれまでに学んだ全ての内容を用いたプログラムを作成する。演習中に小テストを実施する。",
            "これまでに学習してきた事項全般に関する総合的な演習を行う。演習中に確認テストとその解説を行う。",
            "これまでに学習してきた事項全般に関する総合的な演習を行う。演習中に2回目の確認テストとその解説を行う。"
        ],
        "preparations": [
            "教科書の第1章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第2章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第3、4章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第3、4章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第5章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第6章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第7章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第8章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:180分、復習:180分",
            "教科書の第9章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第10章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:120分、復習:180分",
            "教科書の第11章を読み、サンプルのプログラムを作成しておくこと/課題の作成\\n予習:180分、復習:180分",
            "配付資料を読み、課題の背景を理解しておくこと/課題の作成\\n予習:180分、復習:180分",
            "教科書の全ての範囲とこれまでに作成した課題プログラムを確認しておくこと/演習問題を元に復習すること\\n予習:180分、復習:180分",
            "教科書の全ての範囲とこれまでに作成した課題プログラムを確認しておくこと/試験結果を元に復習すること\\n予習:180分、復習:180分"
        ],
        "target": "(a) 問題を解決するためのプログラムを作成することができる。\\n(b) 変数および条件分岐を使用したプログラムを作成することができる。\\n(c) 繰り返しを使用したプログラムを作成することができる。\\n(d) 配列および関数を使用したプログラムを作成することができる。",
        "method": "レポートと授業中に実施するテスト(小テスト、確認テスト)によって評価する。遅刻や欠席は減点の対象となる。\\n到達目標(a)はレポートにより達成しているかを判定する。(a)を達成できない場合、本科目の単位を取得できない。全てのレポートが受理されることが(a)を達成するための必須条件である。到達目標(a)を達成している場合に限り、到達目標(b)-(d)の達成度をレポート(50%)、テスト(50%)の配分で判定する。",
        "basis": "A: 到達目標(a)を達成し、到達目標(b)-(d)を総合的に90%以上達成している。\\nB: 到達目標(a)を達成し、到達目標(b)-(d)を総合的に80%以上90%未満達成している。\\nC: 到達目標(a)を達成し、到達目標(b)-(d)を総合的に70%以上80%未満達成している。\\nD: 到達目標(a)を達成し、到達目標(b)-(d)を総合的に60%以上70%未満達成している。\\nF: 上記以外",
        "textbook": [
            [
                "Cプログラミングへの第一歩",
                "椎原正次、井上雄紀、水谷泰治、",
                "ムイスリ出版"
            ],
            [
                "ノートPC必携",
                "記載なし",
                "記載なし"
            ]
        ],
        "reference_book": "記載なし",
        "knowledge": "プログラミングはすべての情報処理技術の基礎である。プログラミングを理解することは、他の専門科目への理解を深めることになる。この演習では、1) 基本的なCプログラムの書き方と、2) 計算処理、条件判断処理、繰り返し処理、関数、配列などのC言語の基礎を学習し、3) 様々な課題をプログラムとして実現する方法を修得する。\\n毎回の課題として教科書に記載の演習課題に取り組む。これらに加え、教科書を用いた予習、授業時間外における演習課題やレポートの取り組みを考慮すると、毎回の授業に対して平均的に5~6時間程度の自習が必要になる。\\n毎回の授業で取り組む課題は翌週には解答例を公開するので、各自で確認して理解を深めること。テストについては実施後に解説を行う。レポートについては内容が不十分なレポートについては再提出してもらうことがある。\\n学習のために生成AIを用いることは構わないが、生成AIが出力したプログラムや文章を課題等の成果物として提出することは不正行為と判断する。詳細については必要に応じて授業中に説明する。",
        "office_hour": "水谷 木曜3限 614研究室\\n尾花 水曜3限 404研究室\\n樫原 木曜お昼休み 409研究室\\n小谷 水曜3限 402研究室\\n平 月曜5限 606研究室\\n中西 木曜3限 503研究室\\n平岡 水曜3限 情報センター教員室\\n山内 水曜3限 情報センター教員室\\n杉川 水曜5限 426研究室\\n坂平 水曜3限 415研究室\\n大井 月曜3限 263研究室\\n平嶋 金曜3限 242研究室\\n久保田 水曜3限 610研究室",
        "practice": "【実践的教育】\\n(中西 知嘉子)CPUの設計の経験を持つ教員がその経験を生かしてC言語でのプログラム作成について講義する\\n(平)自然言語処理や機械学習に関する研究開発の経験を持つ教員が、その経験を活かしてC言語でのプログラム作成について指導を行う\\n(坂平)情報システムの設計開発の経験を持つ教員がその経験を生かしてC言語でのプログラム作成について講義する\\n(樫原)企業・研究機関での研究開発等の経験を持つ教員が、その経験を活かしてC言語でのプログラム作成について指導を行う"
    },

これでようやく欲しいデータになったので後はWebサイトをゴリゴリ作るだけです。

frontend

https://github.com/oit-tools/syllabus-frontend

フロントはNext.jsで作ってCloudflare Pagesにデプロイしています。
検索画面のところはほぼMaterial React Tableで構成されているし、あとは素朴なNext.jsの使い方ぐらいしかしてないんで、実はほとんどライブラリ頼りですね。
検索条件をURLのクエリパラメータで制御したり、もうちょいスマホでも見やすいUIだったり、スマートな検索画面だったり実装したいこともあるんですが、そうなると作り直しかなぁってなってやる気が出ずこのままです。
やる気がある人がいれば是非連絡してください😂。

おわりに

これのお陰でスクレイピングについては完全に理解したのですが、やっぱりエラー対処だったり頑張ってHTMLをパースする必要があったり、動的なページとかは特にめんどくさかったりするので、スクレイピングはあんまりやりたくないなと痛感した次第です笑。
シラバスデータとかは最悪Excelで良いのでまとまったデータが配布されていたらもっと気楽に作れると思うし、いろんなアプリケーションが出てくると思うので、もっとオープンデータの姿勢が広まってくれたらなぁと思いました。
ちなみにスクレイピングで取得したデータはGitHub(ただし1ファイル40MBとかあるので注意)で公開しているので使用したい方はご自由にどうぞ。
あとGitHubのリポジトリにStarつけて頂くと私が嬉しくなるので是非!😊
それでは!

脚注
  1. 厳密には時間割のPDFで公開されているけどそれを機械的に抽出するのは結構面倒だし、曜日と時限が取得できないので検索ページから抽出しています。 ↩︎

  2. 特に今回はスクレイピングというネットワーク通信が頻繁に発生するタスクだったので効果はてきめんでした。 ↩︎