子供の YouTube 見すぎ問題を解決するために Python で嫌がらせをする（漢字テスト）

きっかけ
--------

「 [定期的にテストを出題して答えられなかったら画面を見せてやらない嫌がらせをするスクリーンセーバー](https://qiita.com/tadashi9e/items/046cd681be61bc52111e) 」を作ったのですが、二年生の子供から「簡単すぎる！もっと難しくして！三年生の漢字のテストがいい」と言われたので漢字テストバージョンを作ることにしました。

手段
----

前回作ったプログラムとまるきり同じです。

- 全画面を隠す
- 小学生に解ける問題をいくつか出す
- 問題に全問正解したら画面を隠すのを止めて普通に見られるようにする

出題する問題が漢字テストになるだけです。
とはいえ、書き取りを出題するのは文字認識が必要になってくるので難しくなります。かといって「漢字を入力せよ」だとかな漢字変換を使うだけになってしまいます。書き取りは諦めて、漢字を表示してその読みをひらがなで入力させる、というだけのものにしました。

問題を作る
---------

準備としてまず、小学三年生までに習う漢字の一覧が必要です。文部科学省が Web 上で公開しているものをコピーペーストして、空白区切りの許容漢字一覧ファイル kanji.txt を作りました。

~~~
一 右 雨 円 王 音 下 火 花 貝 学 気 九 休 玉 金 空 月 犬 見 五 口 校 左 三 山 子 四 糸 字 耳 七 車 手 十 出 女 小 上 森 人 水 正 生 青 夕 石 赤 千 川 先 早 草 足 村 大 男 竹 中 虫 町 天 田 土 二 日 入 年 白 八 百 文 木 本 名 目 立 力 林 六
引 羽 雲 園 遠 何 科 夏 家 歌 画 回 会 海 絵 外 角 楽 活 間 丸 岩 顔 汽 記 帰 弓 牛 魚 京 強 教 近 兄 形 計 元 言 原 戸 古 午 後 語 工 公 広 交 光 考 行 高 黄 合 谷 国 黒 今 才 細 作 算 止 市 矢 姉 思 紙 寺 自 時 室 社 弱 首 秋 週 春 書 少 場 色 食 心 新 親 図 数 西 声 星 晴 切 雪 船 線 前 組 走 多 太 体 台 地 池 知 茶 昼 長 鳥 朝 直 通 弟 店 点 電 刀 冬 当 東 答 頭 同 道 読 内 南 肉 馬 売 買 麦 半 番 父 風 分 聞 米 歩 母 方 北 毎 妹 万 明 鳴 毛 門 夜 野 友 用 曜 来 里 理 話
悪 安 暗 医 委 意 育 員 院 飲 運 泳 駅 央 横 屋 温 化 荷 界 開 階 寒 感 漢 館 岸 起 期 客 究 急 級 宮 球 去 橋 業 曲 局 銀 区 苦 具 君 係 軽 血 決 研 県 庫 湖 向 幸 港 号 根 祭 皿 仕 死 使 始 指 歯 詩 次 事 持 式 実 写 者 主 守 取 酒 受 州 拾 終 習 集 住 重 宿 所 暑 助 昭 消 商 章 勝 乗 植 申 身 神 真 深 進 世 整 昔 全 相 送 想 息 速 族 他 打 対 待 代 第 題 炭 短 談 着 注 柱 丁 帳 調 追 定 庭 笛 鉄 転 都 度 投 豆 島 湯 登 等 動 童 農 波 配 倍 箱 畑 発 反 坂 板 皮 悲 美 鼻 筆 氷 表 秒 病 品 負 部 服 福 物 平 返 勉 放 味 命 面 問 役 薬 由 油 有 遊 予 羊 洋 葉 陽 様 落 流 旅 両 緑 礼 列 練 路 和
~~~

これを見て、ここに含まれる漢字だけを含む単語と、その読みの一覧を作れば問題は完成です。
でも、この一覧を見て単語を考えるのは大変です。

1. 漢字一文字を一覧から選ぶ。
2. 選んだ漢字を含む単語を想起する。
3. 想起した単語に含まれる漢字全てが一覧に含まれるか、再び確認する。含まれないなら 2 からやりなおし
4. 想起した単語とその読みをメモする。

という方法の繰り返しで問題を作ろうと試みたのですが、若干面倒です。

どこかに適当な単語辞書があれば、それをもとに問題文を作れそうです。
そこで pykakasi の漢和辞書を元に試みると大量の単語が得られましたが、人名など問題には適さない単語、子供向けでない単語（買春宿など）を多く含んでいます。

~~~python:generate_words.py
# -*- coding:utf-8;mode:python -*-
u'''
pykakasi の辞書に含まれる単語のうち、
kanji.txt に含まれる文字のみで構成される単語とその読みを抽出する。
'''

import pykakasi

def load_kanji_file():
    u'''kanji.txt ファイルから許容する文字を取得する
    '''
    ks = []
    with open('kanji.txt', 'r') as f:
        for line in f:
            ks += [k.strip() for k in line.split(' ')]
    return ks
def hira_list():
    u'''ひらがなの一覧
    '''
    return [chr(i) for i in range(ord('ぁ'), ord('ゔ'))]
def load_kakasi_kanwa():
    kanji_hira_map = {}
    kanwa = pykakasi.kanji.Kanwa()
    for entry in kanwa._jisyo_table.values():
        kanji_hira_map = dict(
            kanji_hira_map, **entry)
    return kanji_hira_map
def is_valid_word(k, ks):
    for c in k:
        if c not in ks:
            return False
    return True

# 漢字一覧ファイルを読み込む
ks = load_kanji_file()
# ひらがなも許容する
ks += hira_list()
# 単語をチェックする
kanwa = pykakasi.kanji.Kanwa()
for entry in kanwa._jisyo_table.values():
    for k, h in entry.items():
        if not is_valid_word(k, ks):
            # kanji.txt にない文字を含む場合は除外
            continue
        if len(h) != 1:
            # 複数の読みがあるものは除外
            continue
        print('{0}, {1}'.format(k, h[0]))
~~~

完全自動生成は諦めて
「想起した単語に含まれる漢字全てが kanji.txt に含まれるかチェックし、OK ならその単語を記録する」
「単語の読みはあとから pykakasi で自動的に付与」
ことにしました。これで上記手順は

1. 適当に思いついた単語を投入してみる

というだけに簡略化できます。

単語入力受付・チェック・words.txt ファイルへの単語登録を行うプログラムは以下です。

~~~python:check_word.py
# -*- coding:utf-8;mode:python -*-
import sys
def load_words():
    u'''単語ファイルを読み込む
    '''
    words = []
    with open('words.txt', 'r') as f:
        for line in f:
            words.append(line.strip())
    return words
def hira_list():
    u'''ひらがなの一覧
    '''
    return [chr(i) for i in range(ord('ぁ'), ord('ゔ'))]
def register(word):
    u'''単語ファイルに単語を登録する
    '''
    words = load_words()
    if word in words:
        print('already registered')
        return
    words.append(word)
    words = sorted(words)
    with open('words.txt', 'w') as f:
        f.write('\n'.join(words))
# 漢字一覧ファイルを読み込む
ks = []
with open('kanji.txt', 'r') as f:
    for line in f:
        ks += [k.strip() for k in line.split(' ')]
# ひらがなも許容する
ks += hira_list()
# 想起した単語の入力を受け付けて、チェックする
for word in sys.stdin:
    ng = False
    for w in word.strip():
        if w.strip() in ks:
            print('ok: {0}'.format(w))
        else:
            print('ng: {0}'.format(w))
            ng = True
    if ng:
        continue
    # 全ての文字が受理可能なら単語登録
    register(word.strip())
~~~

このように生成した単語ファイル words.txt には単語のみ含まれており、その読みは含みません。読みを付与して問題文を作成するプログラムを作りました。

~~~python:words_to_qa.py
# -*- coding:utf-8; mode:python -*-
u'''
単語一覧ファイルを読み込んで、単語とその読みを引数とする
QA インスタンスを表示する。
読みは pykakasi で自動的に付与する。
'''
import pykakasi

def load_words():
    u'''単語ファイルを読み込む
    '''
    words = []
    with open('words.txt', 'r') as f:
        for line in f:
            words.append(line.strip())
    return words
kks = pykakasi.kakasi()
words = load_words()
for word in words:
    result = kks.convert(word)
    print('        QA("{0}", "{1}"),'.format(
        word, ''.join([item['hira'] for item in result])))
~~~

これでようやく問題を作成できました。作成した問題を以下のプログラムの問題文リスト部分に設定すれば完成です。

~~~python
# -*- mode:python;coding:utf-8 -*-
import random
import sys
import tkinter
from typing import Any, List, Type

DEBUG = False

def abort_process(event: tkinter.Event) -> None:
    u'''強制終了
    '''
    sys.exit(1)

class QA(object):
    def __init__(self, q: str, a: str) -> None:
        u'''
        q ... 漢字を含む問題文
        a ... 答え（問題文のよみ）
        '''
        self.q = q
        self.a = a

# 漢字の読みの問題：小学３年向け
QAs = [
        QA("万有引力", "ばんゆういんりょく"),
        QA("世界", "せかい"),
        QA("中世", "ちゅうせい"),
        QA("中央", "ちゅうおう"),
        QA("主人", "しゅじん"),
        QA("乗用車", "じょうようしゃ"),
        QA("予定", "よてい"),
        QA("事業", "じぎょう"),
        QA("京都", "きょうと"),
        QA("仕事", "しごと"),
        QA("仕様", "しよう"),
        QA("他人", "たにん"),
        QA("他力", "たりき"),
        QA("会合", "かいごう"),
        QA("会談", "かいだん"),
        QA("作動", "さどう"),
        QA("使用", "しよう"),
        QA("使用人", "しようにん"),
        QA("使者", "ししゃ"),
        QA("係員", "かかりいん"),
        QA("兄弟", "きょうだい"),
        QA("光子", "こうし"),
        QA("全体", "ぜんたい"),
        QA("写真", "しゃしん"),
        QA("分科会", "ぶんかかい"),
        QA("列車", "れっしゃ"),
        QA("力学", "りきがく"),
        QA("助言", "じょげん"),
        QA("動作", "どうさ"),
        QA("動力", "どうりょく"),
        QA("動力学", "どうりきがく"),
        QA("勝負", "しょうぶ"),
        QA("化学", "かがく"),
        QA("区役所", "くやくしょ"),
        QA("医者", "いしゃ"),
        QA("医薬", "いやく"),
        QA("医薬品", "いやくひん"),
        QA("反発", "はんぱつ"),
        QA("取水口", "しゅすいこう"),
        QA("受注", "じゅちゅう"),
        QA("受発注", "じゅはっちゅう"),
        QA("合金", "ごうきん"),
        QA("向上", "こうじょう"),
        QA("君子", "くんし"),
        QA("商売", "しょうばい"),
        QA("商売人", "しょうばいにん"),
        QA("問題", "もんだい"),
        QA("回答", "かいとう"),
        QA("回転", "かいてん"),
        QA("回転木馬", "かいてんもくば"),
        QA("土地", "とち"),
        QA("地区", "ちく"),
        QA("地球", "ちきゅう"),
        QA("外遊", "がいゆう"),
        QA("大根", "だいこん"),
        QA("大黒柱", "だいこくばしら"),
        QA("天王山", "てんのうざん"),
        QA("姉妹", "しまい"),
        QA("始業式", "しぎょうしき"),
        QA("委員", "いいん"),
        QA("委員会", "いいんかい"),
        QA("安い", "やすい"),
        QA("安全", "あんぜん"),
        QA("安心", "あんしん"),
        QA("実写", "じっしゃ"),
        QA("客人", "きゃくじん"),
        QA("家庭", "かてい"),
        QA("家族", "かぞく"),
        QA("宿場", "しゅくば"),
        QA("宿場町", "しゅくばまち"),
        QA("宿屋", "やどや"),
        QA("小豆", "あずき"),
        QA("局所", "きょくしょ"),
        QA("屋号", "やごう"),
        QA("島国", "しまぐに"),
        QA("川岸", "かわぎし"),
        QA("工場", "こうじょう"),
        QA("平和", "へいわ"),
        QA("平地", "へいち"),
        QA("平等", "びょうどう"),
        QA("幸福", "こうふく"),
        QA("引力", "いんりょく"),
        QA("弱体化", "じゃくたいか"),
        QA("弱者", "じゃくしゃ"),
        QA("強者", "つわもの"),
        QA("役人", "やくにん"),
        QA("役者", "やくしゃ"),
        QA("悪い", "わるい"),
        QA("悪人", "あくにん"),
        QA("悲鳴", "ひめい"),
        QA("感動", "かんどう"),
        QA("感想", "かんそう"),
        QA("所業", "しょぎょう"),
        QA("手帳", "てちょう"),
        QA("手配", "てはい"),
        QA("打者", "だしゃ"),
        QA("指南", "しなん"),
        QA("放送", "ほうそう"),
        QA("教室", "きょうしつ"),
        QA("教育", "きょういく"),
        QA("数学", "すうがく"),
        QA("整地", "せいち"),
        QA("整流", "せいりゅう"),
        QA("新世界", "しんせかい"),
        QA("方式", "ほうしき"),
        QA("旅行", "りょこう"),
        QA("旅館", "りょかん"),
        QA("日本海", "にほんかい"),
        QA("春雨", "はるさめ"),
        QA("時代", "じだい"),
        QA("時期", "じき"),
        QA("暗い", "くらい"),
        QA("暗室", "あんしつ"),
        QA("暗箱", "あんばこ"),
        QA("有線", "ゆうせん"),
        QA("有線放送", "ゆうせんほうそう"),
        QA("期待", "きたい"),
        QA("木馬", "もくば"),
        QA("東京都", "とうきょうと"),
        QA("東洋", "とうよう"),
        QA("根号", "こんごう"),
        QA("植林", "しょくりん"),
        QA("業物", "わざもの"),
        QA("様子", "ようす"),
        QA("横丁", "よこちょう"),
        QA("正数", "せいすう"),
        QA("正負", "せいふ"),
        QA("死者", "ししゃ"),
        QA("水屋", "みずや"),
        QA("水泳", "すいえい"),
        QA("水流", "すいりゅう"),
        QA("水球", "すいきゅう"),
        QA("氷雪", "ひょうせつ"),
        QA("決起", "けっき"),
        QA("油田", "ゆでん"),
        QA("波数", "はすう"),
        QA("波長", "はちょう"),
        QA("注油", "ちゅうゆ"),
        QA("注記", "ちゅうき"),
        QA("流水", "りゅうすい"),
        QA("流氷", "りゅうひょう"),
        QA("海洋", "かいよう"),
        QA("消化", "しょうか"),
        QA("消去", "しょうきょ"),
        QA("消火", "しょうか"),
        QA("温水", "おんすい"),
        QA("湯水", "ゆみず"),
        QA("漢字", "かんじ"),
        QA("火事", "かじ"),
        QA("火薬", "かやく"),
        QA("炭火", "すみび"),
        QA("物理", "ぶつり"),
        QA("犬笛", "いぬぶえ"),
        QA("球場", "きゅうじょう"),
        QA("理学", "りがく"),
        QA("理由", "りゆう"),
        QA("理科", "りか"),
        QA("生命", "せいめい"),
        QA("生物", "せいぶつ"),
        QA("画家", "がか"),
        QA("病気", "びょうき"),
        QA("発注", "はっちゅう"),
        QA("発表", "はっぴょう"),
        QA("登場", "とうじょう"),
        QA("白湯", "さゆ"),
        QA("皮相", "ひそう"),
        QA("皮肉", "ひにく"),
        QA("知人", "ちじん"),
        QA("短気", "たんき"),
        QA("石炭", "せきたん"),
        QA("研究", "けんきゅう"),
        QA("研究者", "けんきゅうしゃ"),
        QA("礼服", "れいふく"),
        QA("秋波", "しゅうは"),
        QA("科学", "かがく"),
        QA("科学者", "かがくしゃ"),
        QA("筆箱", "ふでばこ"),
        QA("等級", "とうきゅう"),
        QA("級数", "きゅうすう"),
        QA("細工", "さいく"),
        QA("終始", "しゅうし"),
        QA("終業式", "しゅうぎょうしき"),
        QA("絵画", "かいが"),
        QA("緑豆", "りょくとう"),
        QA("練習", "れんしゅう"),
        QA("羊毛", "ようもう"),
        QA("習作", "しゅうさく"),
        QA("習字", "しゅうじ"),
        QA("耳鼻科", "じびか"),
        QA("自他", "じた"),
        QA("自作", "じさく"),
        QA("自力", "じりき"),
        QA("自動", "じどう"),
        QA("自由", "じゆう"),
        QA("花火", "はなび"),
        QA("苦言", "くげん"),
        QA("荷台", "にだい"),
        QA("荷物", "にもつ"),
        QA("薬品", "やくひん"),
        QA("薬局", "やっきょく"),
        QA("薬箱", "くすりばこ"),
        QA("血刀", "ちがたな"),
        QA("行進", "こうしん"),
        QA("行進曲", "こうしんきょく"),
        QA("表皮", "ひょうひ"),
        QA("表面", "ひょうめん"),
        QA("西洋", "せいよう"),
        QA("記号", "きごう"),
        QA("記章", "きしょう"),
        QA("詩人", "しじん"),
        QA("調子", "ちょうし"),
        QA("談話", "だんわ"),
        QA("負数", "ふすう"),
        QA("起動", "きどう"),
        QA("起電力", "きでんりょく"),
        QA("路上", "ろじょう"),
        QA("車両", "しゃりょう"),
        QA("転落", "てんらく"),
        QA("軽油", "けいゆ"),
        QA("軽自動車", "けいじどうしゃ"),
        QA("軽重", "けいちょう"),
        QA("農家", "のうか"),
        QA("農業", "のうぎょう"),
        QA("返事", "へんじ"),
        QA("返答", "へんとう"),
        QA("進化", "しんか"),
        QA("進歩", "しんぽ"),
        QA("運動", "うんどう"),
        QA("運指", "うんし"),
        QA("運送", "うんそう"),
        QA("道具", "どうぐ"),
        QA("部品", "ぶひん"),
        QA("部屋", "へや"),
        QA("都", "みやこ"),
        QA("配当", "はいとう"),
        QA("重力", "じゅうりょく"),
        QA("重根", "じゅうこん"),
        QA("重油", "じゅうゆ"),
        QA("野球", "やきゅう"),
        QA("野球場", "やきゅうじょう"),
        QA("金山", "きんざん"),
        QA("金細工", "きんさいく"),
        QA("金銀", "きんぎん"),
        QA("鉄橋", "てっきょう"),
        QA("銀山", "ぎんざん"),
        QA("銀行", "ぎんこう"),
        QA("陽光", "ようこう"),
        QA("陽子", "ようし"),
        QA("集中", "しゅうちゅう"),
        QA("集客", "しゅうきゃく"),
        QA("電光石火", "でんこうせっか"),
        QA("電力", "でんりょく"),
        QA("電子", "でんし"),
        QA("電気", "でんき"),
        QA("電波", "でんぱ"),
        QA("電波暗室", "でんぱあんしつ"),
        QA("電波暗箱", "でんぱあんばこ"),
        QA("電線", "でんせん"),
        QA("音楽", "おんがく"),
        QA("食事", "しょくじ"),
        QA("飲食", "いんしょく"),
        QA("高等学校", "こうとうがっこう"),
        QA("麦畑", "むぎばたけ"),
        QA("鼻水", "はなみず"),
]
def pickQA(n : int) -> List[QA]:
    u'''問題文選択
    '''
    picked : List[int] = []
    while len(picked) < n:
        i = random.randrange(0, len(QAs))
        if i not in picked:
            picked.append(i)
    return [QAs[i] for i in picked]

class QAFrame(tkinter.Frame):
    u'''問題表示クラス'''
    def __init__(self, window: tkinter.Tk,
                 qa: QA,
                 *args: Any, **kwargs: Any) -> None:
        tkinter.Frame.__init__(self, window, *args, **kwargs)
        self.pack()
        qtext = qa.q
        status = tkinter.StringVar()
        statusLabel = tkinter.Label(self, textvariable=status)
        questionLabel = tkinter.Label(self, text=qtext)
        self.ansEntry = AnsEntry(self, status, qa.a)
        statusLabel.grid(column=0, row=0)
        questionLabel.grid(column=1, row=0)
        self.ansEntry.grid(column=2, row=0)
    def result(self) -> bool:
        u'''回答内容が正しいなら文字列 'OK' を返す。
        '''
        return self.ansEntry.result()
class AnsEntry(tkinter.Entry):
    u'''回答入力
    '''
    def __init__(self, frame: tkinter.Frame,
                 status: tkinter.StringVar, trueAns: str) -> None:
        tkinter.Entry.__init__(self, frame)
        self.status = status
        self.trueAns = trueAns
        self.stringVar = tkinter.StringVar()
        self.stringVar.trace('w', self._updated)
        self.configure(textvariable=self.stringVar)
    def _updated(self, *args: Any) -> None:
        self.status.set("updated: {0}".format(args[0]))
        if args[0] != self.stringVar._name:
            return
        s = self.stringVar.get()
        if s == self.trueAns or s == 'abrakadabra':
            self._success()
        else:
            self._ng()
    def _success(self) -> None:
        self.status.set("OK")
    def _ng(self) -> None:
        self.status.set("NG")
    def result(self) -> bool:
        return str(self.status.get()) == "OK"
class SummaryFrame(tkinter.Frame):
    u'''結果表示
    '''
    def __init__(self, window: tkinter.Tk,
                 qainstances: List[QAFrame], *args: Any, **kwargs: Any) -> None:
        tkinter.Frame.__init__(self, window, *args, **kwargs)
        self.window = window
        self.qainstances = qainstances
        self.after(1000, self._check)
        self.status = tkinter.StringVar()
        statusLabel = tkinter.Label(self, textvariable=self.status)
        statusLabel.pack()
    def _check(self) -> None:
        ngcount = sum([(0 if qainstance.result() else 1)
                       for qainstance in self.qainstances])
        if ngcount == 0:
            self.status.set('おわり！！！'.format(ngcount))
            self.after(1000, self.window.destroy)
        else:
            self.status.set('のこり {0} もん'.format(ngcount))
            self.after(1000, self._check)

def display_qa() -> None:
    # メイン関数
    window = tkinter.Tk()
    window.attributes('-fullscreen', True)
    window.title("test window")
    qainstances : List[QAFrame] = []
    for qa in pickQA(5):
        qaframe = QAFrame(window, qa, pady=10, padx=10)
        qainstances.append(qaframe)
    summary = SummaryFrame(window, qainstances)
    summary.pack()
    # デバッグ用
    if DEBUG:
        button = tkinter.Button(window, text="exit")
        button.pack()
        button.bind("<ButtonPress>", abort_process)
    window.mainloop()

if __name__ == '__main__':
    display_qa()
~~~

自動生成した読みが意図しないものになる可能性があるため、今回は呪文 (abrakadabra) を入力しても正解扱いにする、という逃げ道を追加してあります。

まとめ
------

子供から
「パパ、わからない字とひとという字はなに〜？」
などという謎のクイズを出題されるようになってしまいました。
苦し紛れに入れた、「一生使わない人がいそうな単語」まであります。ある程度、わからない単語が混ざっている方が子供との会話時間が増えていいのではないかと思います。

問題文を考えるのは結構大変で、やっているうちに精神分析の自由連想法を思い出して「どんな単語を思いつくかで内面が顕になるのでは」という思いすら湧いてきました。

追記
----

陽子（ようし）のつもりで入力した単語が「ようこ」になっていたり、取水口（しゅすいこう）が「しゅすいくち」になっていたりしたので、やはりある程度人の眼で見てチェックは必要なようです。