Pythonプログラミング問題サイトを作ってみた

前の記事 [1](https://qiita.com/tomtkg/items/79b3166ef0a590be79c8) [2](https://qiita.com/tomtkg/items/84a041a3761096450349) では，Pythonが動くWebサイトの作り方を紹介しました．
この記事では，Pythonプログラミング問題サイトの作り方を紹介します．
[MATLAB Cody](https://jp.mathworks.com/matlabcentral/cody) と [paizaのスキルチェック](https://paiza.jp/challenges) をイメージしてサイトを作成しました．

完成したサイトは，以下になります．（画像クリックでWebサイトにアクセスできます）

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308259/7e9988df-432e-0dae-e1cb-8475b721e5d3.png)](https://tomtkg.github.io/Python-Web-Test/problem.html)

プログラムは，以下のリポジトリにあります．

https://github.com/tomtkg/Python-Web-Test

# HTMLとCSS
WebサイトのHTMLとCSSは，以下の通りです．

<details><summary>problem.html</summary>

```html:problem.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Pythonプログラミング問題</title>
    <script src="https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js"></script>
    <link rel="stylesheet" type="text/css" href="src/style.css">
</head>
<body>
    <h1>
        Pythonプログラミング問題　問題番号：
        <select id="select"></select>
    </h1>

    <div class="container">
        <div class="left area">
            <h2>入力フォーマットと問題文</h2>
            <pre id="format"></pre>
            <p id="main"></p>
        </div>
        <div class="right area">
            <h2>解答プログラム</h2>
            <div id="editor"></div>
            <button id="exe" onclick="main()">実行 (Ctrl + Enter)</button>
            <span id="loading"></span>
            <button id="answer" onclick="answer()">解答例を出す</button>
            <button id="remove" onclick="remove()">全削除</button>
        </div>
    </div>

    <div class="container">
        <div class="other area">
            <h2>与えられる入力</h2>
            <div id="input"></div>
        </div>
        <div class="other area">
            <h2>期待される出力</h2>
            <div id="expect"></div>
        </div>
        <div class="other area" id="resultBlock">
            <h2>解答プログラムの出力　<span id="result"></span></h2>
            <div id="output"></div>
        </div>
    </div>

    <script src="src/main.js"></script>
</body>
</html>
```
</details>
<details><summary>style.css</summary>

```css:style.css
body {
    margin: auto;
    width: 1800px;
}

button {
    font-size: inherit;
    margin-top: 10px;
}

pre {
    background-color: #fff;
    border: 1px solid #000;
    padding: 5px;
    white-space: pre-wrap;
    word-wrap: break-word;
    overflow-wrap: break-word;
}

select {
    font-size: 25px;
}

.container {
    display: flex;
    gap: 15px;
    margin-bottom: 15px;
}

.left {
    width: 40%;
}

.right {
    width: 60%;
}

.other {
    width: 33%;
}

.area {
    padding: 0px 10px 10px 10px;
    background-color: #f0f0f0;
    border: 1px solid #ccc;
}

#editor {
    height: 360px;
    border: 1px solid #000;
}

#answer,
#remove {
    float: right;
}
```
</details>

`problem.html`は，`loader.js`，`pyodide.js`，`main.js`，`style.css` の4つを読み込みます．
[前の記事](https://qiita.com/tomtkg/items/84a041a3761096450349)と同じく，[Monaco Editor](https://github.com/microsoft/monaco-editor) + [Pyodide](https://github.com/pyodide/pyodide) を利用します．
今回は，HTML，CSS，JavaScript をファイルでしっかり分離します．

画面の一番上は，`h1`と`select`を利用してす．
そこから下は，`div`とCSSで5領域に分割します．
各領域では，見出しに`h2`，プログラムとその入出力に`pre`，文章に`p`を利用します．
「正解 or 不正解」と「プログラム実行中...」の表示 or 非表示領域は，`span`を利用します．
プログラム実行ボタン，全削除ボタン，解答例を出すボタンは，`button`を利用します．

`style.css`は，主にサイズの調整と色の指定に利用します．

# JavaScript
WebサイトのJavaScriptは，以下の通りです．

<details><summary>main.js</summary>

```js: main.js
document.addEventListener("DOMContentLoaded", function () {
    fetch("src/settings.json")
        .then(response => response.json())
        .then(data => {
            const select = document.getElementById("select");

            select.innerHTML = Array.from({ length: data.problem_count }, (_, i) =>
                `<option value="${i + 1}">${i + 1}</option>`).join("");

            select.addEventListener("change", () => {
                loadProblem(select.value)
                document.getElementById("result").textContent = "";
                document.getElementById("resultBlock").style.backgroundColor = "#f0f0f0";
            });

            loadProblem(select.value);
        })
        .catch(error => console.error("Error loading settings.json:", error));
});

function loadProblem(number) {
    fetch(`problem/problem${number.toString().padStart(4, '0')}.json`)
        .then(response => response.json())
        .then(data => {
            document.getElementById("format").textContent = data.format;
            document.getElementById("main").innerHTML = data.main.replace(/\n/g, "<br>");

            const input = document.getElementById("input");
            const expect = document.getElementById("expect");
            const output = document.getElementById("output");
            input.innerHTML = expect.innerHTML = output.innerHTML = "";

            const h = parseFloat(getComputedStyle(document.querySelector("pre")).lineHeight) || 13;

            data.example.forEach((x, i) => {
                const n = Math.max(x.input.split("\n").length, x.output.split("\n").length);
                input.innerHTML += `<p>入力例${i + 1}</p><pre style="height: ${h * n}px;">${x.input}</pre>`;
                expect.innerHTML += `<p>出力例${i + 1}</p><pre style="height: ${h * n}px;">${x.output}</pre>`;
                output.innerHTML += `<p>解答${i + 1}</p><pre style="min-height: ${h * n}px; height: auto;"></pre>`;
            });
            window.data = data;
        })
        .catch(error => console.error(`Error loading problem${number.toString().padStart(4, '0')}.json:`, error));
}

const pyodideReady = loadPyodide().then(async pyodide => (
    await pyodide.loadPackage(["numpy", "pandas", "scikit-learn", "scipy"]), pyodide));

require.config({ paths: { 'vs': 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs' } });

require(['vs/editor/editor.main'], async function () {
    const editor = monaco.editor.create(document.getElementById("editor"), {
        value: 'a = input()\nprint(a)',
        language: 'python',
        fontSize: 18,
        wordWrap: 'on',
        lineNumbersMinChars: 3,
        minimap: { enabled: false },
    });

    editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
        if (!document.getElementById("exe").disabled) window.main();
    });

    window.main = async function () {
        document.getElementById("exe").disabled = true;
        document.getElementById("loading").textContent = "プログラム実行中...";
        document.querySelectorAll("#output pre").forEach(pre => pre.innerText = "");

        const code = editor.getValue();
        let pyodide = await pyodideReady;
        let outputs = document.querySelectorAll("#output pre");
        let isCorrect = true;

        window.data.example.forEach((x, i) => {
            let output = outputs[i];
            pyodide.setStdin({
                lines: x.input.trim().split("\n"),
                stdin() { return this.lines.shift() || undefined }
            });
            pyodide.setStdout({
                batched: (msg) => { output.innerText += msg + "\n" }
            });

            try { pyodide.runPython(code) }
            catch (error) { output.innerText = error }

            if (isCorrect) {
                if (isFailed(x.output, output.innerText)) {
                    isCorrect = false;
                }
            }
        });

        updateResult(isCorrect);
        document.getElementById("loading").textContent = "";
        document.getElementById("exe").disabled = false;
    };

    window.answer = () => window.data &&
        editor.setValue(editor.getValue() + `# 解答例\n${window.data.answer}\n`);

    window.remove = () => {
        editor.setValue("");
        document.getElementById("result").textContent = "";
        document.getElementById("resultBlock").style.backgroundColor = "#f0f0f0";
        document.querySelectorAll("#output pre").forEach(pre => pre.innerText = "");
    };
});

function isFailed(a, b) {
    a, b = a.trim(), b.trim()
    if (!isNaN(a) && !isNaN(b)) {
        if (Math.abs(a - b) > 1e-6) return true;
    } else {
        if (a != b) return true;
    }
    return false
}

function updateResult(isCorrect) {
    let result = document.getElementById('result');
    let bg = document.getElementById("resultBlock");

    result.textContent = isCorrect ? '正解' : '不正解';
    result.style.color = isCorrect ? "crimson" : "mediumblue";
    bg.style.backgroundColor = isCorrect ? "lavenderblush" : "azure";
}
```
</details>

１～19行目では，`settings.json`を読み込み，問題番号の選択肢を動的に生成します．
また，問題番号（の選択）が変更された時の動作も，ここで登録します．
最後に，関数`loadProblem`を呼び出し，問題番号`1`を画面表示します．

21～44行目の関数`loadProblem`は，指定された番号の問題を読み込み，表示する関数です．
まず，`fetch`でJSONを読み込みます．
左上の「入力フォーマットと問題文」では，JSONの内容を指定箇所に表示します．
「与えられる入力」，「期待される出力」，「解答プログラムの出力」では，JSONに書かれた入出力例をすべて表示します．
そのため，JSONの内容に応じて，表示する例と解答，その枠の数と大きさが動的に変化します．
見やすさを考えて，「与えられる入力」の高さ，「期待される出力」の高さ，「解答プログラムの出力」の最小高さ，3つを一致させています．
最後に，他からアクセスできるように，JSONの内容を`window.data`に格納します．

46～47行目では[Pyodide](https://github.com/pyodide/pyodide) ，49行目では[Monaco Editor](https://github.com/microsoft/monaco-editor)の利用準備をします．
52～63行目は，Monaco Editorの設定部分です．

65～98行目は，プログラム実行ボタンを押したときの動作，
100～101行目は，解答例を出すボタンを押したときの動作，
103～108行目は，全削除ボタンを押したときの動作が記載されています．

11～119行目の関数`isFailed`と121～128行目の関数`updateResult`は，プログラム実行ボタンを押したとき，内部で呼び出されます．
`isFailed`は，1つの入力例に対する期待出力と解答プログラムの出力の一致判定をします．
`updateResult`は，正解 or 不正解の表示をします．
すべての入力例に対する期待出力と解答プログラムの出力が一致する or 1つでも不一致の出力がある の条件分岐で表示を変えます．

特に[Pyodide](https://github.com/pyodide/pyodide)と[Monaco Editor](https://github.com/microsoft/monaco-editor)に関するプログラムは，前の記事 [1](https://qiita.com/tomtkg/items/79b3166ef0a590be79c8) [2](https://qiita.com/tomtkg/items/84a041a3761096450349) と同じ記述も多いです．
必要に応じて，前の記事も参照ください．

# Github Actions
GitHub Pagesを利用するため，Github Actionsを利用しています．
この内容は，前の記事で説明しました．
また，`settings.json`の自動更新のため，Github Actionsを利用しています．
JSONの自動更新プログラムは，以下の通りです．

<details><summary>update-settings.yml</summary>

```yml: update-settings.yml
name: Update Settings

on:
  push:
    branches: ["main"]
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Update problem_count if needed
        run: |
          count1=$(ls -1 problem | wc -l)
          count2=$(jq -r '.problem_count' src/settings.json)
          if [ "$count1" -eq "$count2" ]; then
            echo "No updates: problem_count is $count1"
          else
            jq ".problem_count = $count1" src/settings.json > tmp.json
            mv tmp.json src/settings.json
            git config --global user.name "github-actions"
            git config --global user.email "github-actions@github.com"
            git add src/settings.json
            git commit -m "Update problem_count in settings.json"
            git push
          fi
```
</details>

`$(ls -1 problem | wc -l)`で，`problem`ディレクトリのファイル数を数えます．
`$(jq -r '.problem_count' src/settings.json)`で，JSONファイルに書き込まれている`problem_count`を取得します．
2つが数値が一致する場合，その旨を`echo`で出力し，操作を終了します．
2つの数値が異なる場合，`jq`と`mv`を利用して，`settings.json`の`problem_count`を更新します．
そして，その内容を`git add`，`git commit`，`git push`します．

このプログラムによって，新しい問題（JSON）が追加された時，それが自動で反映されます．
`settings.json`に数値を書き込んでおくことで，HTMLやJavaScriptでファイル数を数える処理を省略できます．

# 終わりに
Pythonプログラミング問題サイトの作り方を紹介しました．
やりたかったことが実現できたので，自分としては満足です．
後は，JSONを追加するだけでプログラミング問題をどんどん増やしていける筈です．
ただ，問題をたくさん追加するためには，簡単に問題を作る仕組みも考える必要があると思いました．
とりあえず，最初の10問はJSONファイルを直接編集して作成しましたが，結構大変でした．

Github Pagesだけでも色々なこと実現可能だと理解できたことが，個人的に一番の収穫でした．
是非，作成したWebサイトで遊んでみてください．

https://tomtkg.github.io/Python-Web-Test/problem.html

<br>

関連記事・リポジトリ：

https://qiita.com/tomtkg/items/79b3166ef0a590be79c8

https://qiita.com/tomtkg/items/84a041a3761096450349

https://github.com/tomtkg/Python-Web-Test