漢字パズルのプリントを、答え付きで何枚でも作れるページを公開しました。サーバーはなく、GitHub Pages の静的なページだけで動いています。
(このページは入口で、実際にプリントを作る画面は mazeru.html?mode=print です。)
作れるのは2種類です。真ん中の□に1字入れて4つの熟語を作る「和同開珎(わどうかいちん)」と、7つの漢字で熟語をいくつ作れるかを競う「7字でつくる」。1枚目が問題、2枚目が答えで、A4 で印刷できます。
この記事では、作り方を順に書きます。
- 電子辞書 JMdict を「答えに使える熟語」の表にする(Python)
- 和同開珎の答えが1つに決まることを、索引の積集合で確かめる(JavaScript)
- 同じプリントを、アドレスだけでもう一度出せるようにする
- 作っている途中で見つけた、辞書の分類の不具合と直し方
実装と検証の大半は、AIエージェント(Claude Code)に任せました。最後の節に、人が決めたことと任せたことの分け方を書いています。
全体の形
JMdict_e.gz, kanjidic2.xml.gz(EDRDG, CC BY-SA 4.0)
│ scripts/kanji/build_dict.py(手元で1回)
▼
data/kanji/words.json 熟語 12,048語 [語, 読み, 学年, 質]
data/kanji/kanji.json 常用漢字 2,136字 → 学年
│ ブラウザで読み込む
▼
assets/kanji-core.js 問題を作る・確かめる(画面に依存しない関数だけ)
mazeru.html 選ぶ画面と、印刷用の紙
辞書から表を作るのは手元の Python で1回だけ。問題は、ブラウザがその場で作ります。問題を前もって作り置きしないので、何枚刷っても尽きません。
1. 辞書を「答えに使える熟語」の表にする
JMdict には漢字だけの見出しが大量にあり、そのまま答えにすると、ふつうの人が知らない語ばかりの問題になります。そこで、次の条件をすべて満たす語だけを「答え」にしました。
- 漢字だけで2〜4字、全部の字が常用漢字
- 頻度の印(news1, ichi1, spec1/2, gai1, nf01〜24)が付いている
- 下品・差別・性的・配慮が必要の印(vulgar など)が付いていない。むごい印象の字を含まない
PRI = re.compile(r"^(news1|ichi1|spec1|spec2|gai1|nf(0[1-9]|1\d|2[0-4]))$")
# 頻度の印があり、まれな表記(BAD_KEINF: rarely used kanji form など)でない見出しだけ
is_common = any(PRI.match(p) for p in pri) and not any(t in x for x in kinf for t in BAD_KEINF)
下品・差別などの印(BAD_MISC: vulgar, derogatory など)は、これとは別に、見出しごと除外しています。
この条件を通った「よく使う熟語」が12,048語、「辞書にはあるが答えにしない語」が85,536語、除外した語が919語です。
よく使う熟語には、もう1段階の印(質)を付けています。0 は新聞の上位や基本語彙の印がある語(7,225語)、1 はそれ以外の頻度の印だけの語(4,394語)、2 は古語・略語・固有名詞らしい語など、問題の答えには出さない語(429語)です。プリントの小学校・中学の設定は、質0の語だけで問題を作ります。除外語のリストは公開リポジトリに平文で置きたくなかったので、SHA-256 のハッシュでだけ持っています。
自作の集計は、知っている答えで必ず確かめるようにしています。
assert idx["火山"][1:3] == ["かざん", 1]
assert idx["図書館"][1:3] == ["としょかん", 3] # 館は小3
assert idx["一石二鳥"][1:3] == ["いっせきにちょう", 2]
2. 和同開珎の答えが1つに決まることを確かめる
和同開珎は、上と左の字が□の前に、右と下の字が□の後ろにつきます。上が「真」、左が「今」、右が「至」、下が「季」なら、□は「夏」(真夏・今夏・夏至・夏季)。
困るのは、答えが2つ以上ある問題です。上「東」、左「南」、右「風」、下「部」だと、「北」でも「西」でも4つの熟語がそろいます。印刷して配ったあとで「西でも合ってるよね?」となると、答えのページの意味がなくなります。
答えが1つかを確かめずに作ると、どれくらい起きるかを測りました。よく使う2字の熟語(質0〜1)から、中央の字と、それにつながる4字をでたらめに選んで2万問作ると、答えが2つ以上になったのは138問、0.7%でした。少なく見えますが、9問を互いに無関係とみなすと、1枚9問のうちどれかに別解が混ざる紙は約6%です。週に1枚配るなら、4か月に1回ほど当たります。
そこで、2字の熟語から「この字の後ろにつく字」「この字の前につく字」の索引を作り、4方向の集合の積を取ります。
function makeTwoIndex(rows, filter) {
var after = new Map(), before = new Map();
for (var i = 0; i < rows.length; i++) {
var r = rows[i], w = r[0];
if (w.length !== 2 || w[0] === w[1]) continue;
if (filter && !filter(r)) continue;
if (!after.has(w[0])) after.set(w[0], new Set());
after.get(w[0]).add(w[1]);
if (!before.has(w[1])) before.set(w[1], new Set());
before.get(w[1]).add(w[0]);
}
return { after: after, before: before };
}
/** 上・左の字(□の前)と右・下の字(□の後ろ)から、□に入る字の候補をすべて返す。 */
function wadouCandidates(two, up, left, right, down) {
var A = two.after.get(up), B = two.after.get(left), C = two.before.get(right), D = two.before.get(down);
if (!A || !B || !C || !D) return [];
var out = [];
A.forEach(function (x) { if (B.has(x) && C.has(x) && D.has(x)) out.push(x); });
return out.sort();
}
/** 問題が1字だけに決まるか(common 全体で確かめる)。 */
function wadouUnique(twoAll, p) {
var c = wadouCandidates(twoAll, p.up, p.left, p.right, p.down);
return c.length === 1 && c[0] === p.x;
}
ポイントは、索引を2つ使い分けることです。問題を作るときは、学年に合う字だけの索引(小学低学年なら小1・2の字の語だけ)から4字を選びます。答えが1つかを確かめるときは、学年でも質でも絞らない、よく使う熟語12,048語全体の索引を使います。絞った索引で確かめると、「小2の字では1つだけど、中学の字まで入れると別解がある」問題をすり抜けさせてしまうからです。
「辞書にはあるが答えにしない」85,536語までは入れていません。ほとんど誰も知らない語で別解が成り立つたびに問題を捨てると、良い問題まで減ってしまうからです。その分、別解が残る余地があります(最後の節に書きました)。
関数そのものも、別の書き方で検算しています。テストでは、4つの難しさで作った144問(難しさごとに3つの種×12問)について、□に常用漢字2,136字を1字ずつ全部当てはめ、4つの熟語がそろう字が本当に1つだけかを数えています。索引を使った版と、総当たりの版の答えが一致するかを見るためです。
3. 同じプリントを、アドレスだけでもう一度出す
「先週の問題で復習したい」に応えるため、問題は種(seed)から決まる乱数で作っています。Math.random は種を渡せないので、文字列の種を FNV-1a でハッシュして、mulberry32 という小さな乱数に渡しています。種はアドレスに入るので、辞書を作り直さない限り、ブックマークすれば同じ紙がもう一度出ます。
function rng(seed) {
var a = (typeof seed === 'number' ? seed : hashSeed(seed)) >>> 0;
return function () {
a = (a + 0x6D2B79F5) >>> 0;
var t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 例: mazeru.html?mode=print&fmt=a&lv=high&n=9&size=l&seed=k3x9q2
印刷は CSS だけで切り替えています。画面には操作パネルと縮小した紙を出し、印刷のときは紙だけを A4 で1枚ずつ出します。
@page{size:A4 portrait;margin:0}
@media print{
body>*:not(#print-root){display:none!important}
#print-root{display:block!important}
#print-root .sheet{transform:none!important;break-after:page;page-break-after:always}
#print-root .sheet:last-child{break-after:auto;page-break-after:auto}
}
4. 途中で見つけた不具合:「社会」「先生」が答えにならない
同じ辞書で毎日1問の漢字パズルも出しています。その作業中、「社会」「先生」「大学」「電話」のような語が、「ふだん使わない言葉」に分類されていることに気づきました。
原因は、古語や略語の印の見方でした。JMdict の印は意味(sense)ごとに付きます。「社会」には「社会科の略」という意味があり、そこに略語の印があります。最初のコードは、語のどれか1つの意味に印があれば、その語全体を外していました。
# 前: 語のどこかに古語・略語の印があれば外す
obsc = any(any(t in m for t in OBSCURE_MISC) for m in misc)
# 後: 全部の意味がそうなときだけ外す(1つだけなら出す。例: 社会・先生・大学)
obsc = bool(senses) and all(any(any(t in (m.text or "") for t in OBSCURE_MISC)
for m in sn.findall("misc")) for sn in senses)
直すと、389語が答えに戻りました。電話・警察・普通・時代・野菜・郵便・大丈夫など、外れていたのがおかしい語ばかりです。逆に、地名や社名、子ども向けのパズルに出したくない語も十数語戻ってきたので、それらは個別に外しました。
もう1つ考えたのは、すでに公開している毎日の問題の扱いです。辞書を作り直すと、問題の盤そのものが変わりかねません。そこで、盤(字の並び)はそのままにして、次の日以降の答えだけを作り直す処理を足しました。作り直すと、数字や日付の字だけでできた語(二十日・三人など)が答えの25%をこえてしまう日が2日あったので、その2日だけは答えを据え置いています。今後同じ失敗をしないように、確かめる語の中に「社会」「先生」「大学」も加えました。
for w in ("社会", "先生", "大学"): # 略語などの印が一部の意味だけにある語は答えにする
assert idx[w][3] < 2, (w, idx[w])
人が決めたこと、AIエージェントに任せたこと
コードを書き、テストを回し、画面を撮って確かめる作業の大半は Claude Code が進めました。人が決めたのは次のことです。
- 何のためのページか(介護レク・授業・家庭学習でそのまま配れるプリント)
- 答えにしてよい語の線引き(子どもや高齢の方が使う前提で、何を外すか)
- 公開してよいか
任せるときに効いたのは、「知っている答えで assert する」を最初に決めておいたことです。上の「社会」の不具合も、確かめる語を増やしていく途中で見つかりました。
assert だけでは拾えないものは、出てきた答えを人が読んで見つけています。たとえば「火口」は、同じ字の見出しが2つあり、ふだんの読み「かこう」でない方の読みが採られていました。これは直す表(READ_FIX)を足して直しています。逆に、エージェントの数字で人の仕様を変えたこともあります。毎日の問題を「中央の字を必ず使う」決まりで作ろうとしたところ、エージェントが辞書で総当たりして「12語以上の盤が作れる中央の字は6字しかない」と示したので、中央の字は必須にしないことにしました。
残っていること
答えが1つに決まるかを確かめているのは、辞書のよく使う熟語の範囲です。古い言葉まで入れれば、別解のある問題が残っているかもしれません。プリントのページには「作者にひとこと」の欄を付けたので、おかしな問題が届いたら、辞書の線引きを見直す材料にします。
同じ辞書を使って、画面で遊ぶ漢字のゲーム「役札」も作りました。手札の漢字で熟語を作り、点×倍で目標をこえるローグライトです。
熟語のデータ(data/kanji/)は、元の辞書と同じ CC BY-SA 4.0 で配っています。
