0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-14

前の記事 1 2 では,Pythonが動くWebサイトの作り方を紹介しました.
この記事では,Pythonプログラミング問題サイトの作り方を紹介します.
MATLAB Codypaizaのスキルチェック をイメージしてサイトを作成しました.

完成したサイトは,以下になります.(画像クリックでWebサイトにアクセスできます)

image.png

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

HTMLとCSS

WebサイトのHTMLとCSSは,以下の通りです.

problem.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>
style.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;
}

problem.htmlは,loader.jspyodide.jsmain.jsstyle.css の4つを読み込みます.
前の記事と同じく,Monaco Editor + Pyodide を利用します.
今回は,HTML,CSS,JavaScript をファイルでしっかり分離します.

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

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

JavaScript

WebサイトのJavaScriptは,以下の通りです.

main.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";
}

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

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

46~47行目ではPyodide ,49行目ではMonaco Editorの利用準備をします.
52~63行目は,Monaco Editorの設定部分です.

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

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

特にPyodideMonaco Editorに関するプログラムは,前の記事 1 2 と同じ記述も多いです.
必要に応じて,前の記事も参照ください.

Github Actions

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

update-settings.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

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

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

終わりに

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

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


関連記事・リポジトリ:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?