前の記事 1 2 では,Pythonが動くWebサイトの作り方を紹介しました.
この記事では,Pythonプログラミング問題サイトの作り方を紹介します.
MATLAB Cody と paizaのスキルチェック をイメージしてサイトを作成しました.
完成したサイトは,以下になります.(画像クリックでWebサイトにアクセスできます)
プログラムは,以下のリポジトリにあります.
HTMLとCSS
WebサイトのHTMLとCSSは,以下の通りです.
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
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.js
,pyodide.js
,main.js
,style.css
の4つを読み込みます.
前の記事と同じく,Monaco Editor + Pyodide を利用します.
今回は,HTML,CSS,JavaScript をファイルでしっかり分離します.
画面の一番上は,h1
とselect
を利用してす.
そこから下は,div
とCSSで5領域に分割します.
各領域では,見出しにh2
,プログラムとその入出力にpre
,文章にp
を利用します.
「正解 or 不正解」と「プログラム実行中...」の表示 or 非表示領域は,span
を利用します.
プログラム実行ボタン,全削除ボタン,解答例を出すボタンは,button
を利用します.
style.css
は,主にサイズの調整と色の指定に利用します.
JavaScript
WebサイトのJavaScriptは,以下の通りです.
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つでも不一致の出力がある の条件分岐で表示を変えます.
特にPyodideとMonaco Editorに関するプログラムは,前の記事 1 2 と同じ記述も多いです.
必要に応じて,前の記事も参照ください.
Github Actions
GitHub Pagesを利用するため,Github Actionsを利用しています.
この内容は,前の記事で説明しました.
また,settings.json
の自動更新のため,Github Actionsを利用しています.
JSONの自動更新プログラムは,以下の通りです.
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つの数値が異なる場合,jq
とmv
を利用して,settings.json
のproblem_count
を更新します.
そして,その内容をgit add
,git commit
,git push
します.
このプログラムによって,新しい問題(JSON)が追加された時,それが自動で反映されます.
settings.json
に数値を書き込んでおくことで,HTMLやJavaScriptでファイル数を数える処理を省略できます.
終わりに
Pythonプログラミング問題サイトの作り方を紹介しました.
やりたかったことが実現できたので,自分としては満足です.
後は,JSONを追加するだけでプログラミング問題をどんどん増やしていける筈です.
ただ,問題をたくさん追加するためには,簡単に問題を作る仕組みも考える必要があると思いました.
とりあえず,最初の10問はJSONファイルを直接編集して作成しましたが,結構大変でした.
Github Pagesだけでも色々なこと実現可能だと理解できたことが,個人的に一番の収穫でした.
是非,作成したWebサイトで遊んでみてください.
関連記事・リポジトリ: