CASE-012
グレップ・アンド・コー データ探偵事務所
依頼書
依頼人
事務所の全員
証拠品
朝9時にメールボックスに届いた暗号文が1通。バックスラッシュと数字と英字だけでできている。
解決条件
暗号文をトークンに分けて解読し、バックトラッカーが誰なのかを明らかにする。
朝9時、事務所は人でいっぱいです。カン班長は戸口に立ち、チョン社長は手帳を開き、オ記者はノートパソコンを膝に載せています。所長がモニターを全員に見えるように向けます。「今日はみんなで読む。1文字ずつ」
メールボックスに届いた暗号文です。
\x46\N{HYPHEN-MINUS}\060\x32 \x3d \uc11c\uc9c4
\x34\x46 \060\063\x3a\060\x32
(a+)+$オ記者が首を横に振ります。「これ、文字じゃなくてただの記号じゃないですか」。所長が答えます。「記号にもルールがある。ルールを1つずつ書き出せば、機械が代わりに読んでくれる」
長いテキストを意味のあるかけら(トークン)に分けるプログラムを、トークナイザーと呼びます。今日はこれまで学んだことを総動員してトークナイザーを作り、この暗号文を解きます。
まずは練習として、3.5*(2 + x) のような計算式を分けてみます。Python 公式ドキュメントの「Writing a Tokenizer」の例と同じ構造です。
import re
TOKEN_SPEC = [
("NUMBER", r"\d+(?:\.\d*)?"), # 整数または小数
("ID", r"[A-Za-z_]\w*"), # 名前
("OP", r"[+\-*/]"), # 演算子
("LPAR", r"\("),
("RPAR", r"\)"),
("SKIP", r"[ \t]+"), # 空白は捨てる
("MISMATCH", r"."), # それ以外の文字:知らない文字
]
TOKEN = re.compile("|".join(f"(?P<{name}>{rx})" for name, rx in TOKEN_SPEC))
def tokenize(code):
for m in TOKEN.finditer(code):
kind, value = m.lastgroup, m.group()
if kind == "SKIP":
continue
if kind == "MISMATCH":
raise ValueError(f"모르는 글자 {value!r} (위치 {m.start()})")
yield kind, value
print(list(tokenize("3.5*(2 + x)")))
# [('NUMBER', '3.5'), ('OP', '*'), ('LPAR', '('), ('NUMBER', '2'), ('OP', '+'), ('ID', 'x'), ('RPAR', ')')]事務所のコードなので、エラーメッセージは韓国語のままです(모르는 글자 は「知らない文字」、위치 は「位置」)。
構造は3つだけです。
(?P<名前>ルール) を作り、| でつなぎます。ルールをリストに書いておくので、読むのも直すのも簡単です。finditer で前から1つずつ捕まえ、m.lastgroup(6限目)でどのルールが捕まえたかの名前を得ます。MISMATCH は . 1つです。どのルールにも合わない文字を、ここで捕まえます。list(tokenize("3.5 $ 2"))
# ValueError: 모르는 글자 '$' (위치 4)MISMATCH がないとどうなるでしょうか。finditer はマッチしない文字を黙って読み飛ばします。暗号文に妙な文字が紛れ込んでいても、気づかずに通り過ぎてしまうのです。
NO_GUARD = re.compile(r"(?P<NUMBER>\d+)|(?P<OP>[+*])")
[m.group() for m in NO_GUARD.finditer("3 $ 2")] # ['3', '2']| は左から試すので、順番がそのまま優先順位になります。広いルールを前に置くと、狭いルールには出番が回ってきません。
BAD = re.compile(r"(?P<ID>\w+)|(?P<NUMBER>\d+)")
[m.lastgroup for m in BAD.finditer("x 3")] # ['ID', 'ID']証拠分析室
re.search(r"", text)
戻り値
暗号文の \x46 や \uc11c のようなものを文字エスケープと呼びます。Python の文字列で使っていたエスケープのほとんどを、re はパターンの中でも直接理解します。だから raw 文字列 r"\t" で書いても、タブを探してくれます。
print(re.findall(r"\t|\n", "F-02\t03:02\n")) # ['\t', '\n']
print(re.fullmatch(r"\\", "\\")) # <re.Match object; span=(0, 1), match='\\'>\t(タブ)\n(改行)\r \f \v \a(ベル)、そしてバックスラッシュそのものは \\。\x41(2桁)、\u00e9(4桁)、\U0001F600(8桁)。\N{EM DASH} のように Unicode の文字名で。print(re.findall(r"\x46-\x30\x32", "badge=F-02")) # ['F-02']
print(re.fullmatch(r"caf\u00e9", "café")) # <re.Match object; span=(0, 4), match='café'>
print(re.fullmatch(r"\N{EM DASH}", "—")) # <re.Match object; span=(0, 1), match='—'>
print(len("\U0001F600"), "\U0001F600" == "\N{GRINNING FACE}") # 1 True8進数もあります。\0 で始まるか、3桁のときに8進数の文字になります(10限目で見たとおり、\10 はグループ番号です)。そして \b は、文字クラスの中でだけバックスペース文字になります。
print(re.findall(r"\101|\0", "A\0B")) # ['A', '\x00']
print(re.findall(r"[\b]", "F\b-02")) # ['\x08']逆に、決められていない英字のエスケープはエラーです。将来新しい意味を割り当てるために空けてある場所だからです。
re.compile(r"\q")
# re.PatternError: bad escape \q at position 0ドットのひとこと
ぼくは \b を見るたびに、しっぽが3回揺れちゃいます。正規表現の外(ただの "\b" という文字列)ではバックスペース、パターンでは単語の境界、なのにパターンの [ ] の中ではまたバックスペースなんですって。だからパターンはいつも r"..." で書いて、[\b] は本当にバックスペースが必要なときだけ使うんです。
証拠分析室
re.search(r"", text)
戻り値
分析室に赤いエラー欄が出るときのメッセージは、re.error という例外から来ています。3.13 からは re.PatternError という名前もありますが、この2つは同じ例外です。例外オブジェクトには、何が間違っていて、どこで間違えたのかが入っています。
print(re.PatternError is re.error) # True
try:
re.compile(r"[a-Z]")
except re.error as e:
print(e.msg) # bad character range a-Z
print(e.pattern) # [a-Z]
print(e.pos) # 1パターンが複数行なら、lineno(何行目)と colno(何文字目)が役に立ちます。位置は1から数えます。
user_pattern = r'''(?x)
(?P<badge> F-\d+ )
(?P<time \d\d:\d\d )
'''
try:
re.compile(user_pattern)
except re.PatternError as e:
print(e.msg) # missing >, unterminated name
print(e.pos, e.lineno, e.colno) # 36 3 9人が入力したパターンは、いつ間違っていてもおかしくありません。検索窓のようにパターンを受け取るプログラムでは、必ず try/except で囲みます。
def safe_compile(text):
try:
return re.compile(text)
except re.error as e:
print(f"패턴 오류: {e.msg} (위치 {e.pos})")
return None
safe_compile(r"(?P<SIGN>(a+)+$") # 패턴 오류: missing ), unterminated subpattern (위치 0)(패턴 오류 は「パターンエラー」です。)
ただし、try/except が防いでくれるのは文法エラーだけです。(a+)+$ のように文法は正しいのに遅いパターンは、例外も出さずに止まってしまいます(8限目)。だから証拠分析室は、パターンを切り離された作業員に実行させ、時間を超えたらその作業員を取り替えているのです。
証拠分析室
re.search(r"", text)
戻り値
ファイルやネットワークから読んだデータは、str ではなく bytes のことがあります。bytes には rb"..." のように bytes パターンを使います。str と bytes は混ぜられません。
data = "badge=F-02 서진".encode()
print(re.findall(rb"F-\d+", data)) # [b'F-02']
re.findall(r"F-\d+", data)
# TypeError: cannot use a string pattern on a bytes-like object
re.findall(rb"F-\d+", "badge=F-02")
# TypeError: cannot use a bytes pattern on a string-like objectbytes パターンの \w \d \s は ASCII だけを見ます。ハングルは複数のバイトに分かれているので、文字として見えません。ハングルを扱うなら、まず decode() で str にします。
print(re.findall(rb"\w+", data)) # [b'badge', b'F', b'02']
print(re.findall(r"\w+", data.decode())) # ['badge', 'F', '02', '서진']フラグも逆になります。re.L は bytes パターンでしか使えず(11限目)、re.U は bytes パターンに付けるとエラーです。
print(re.compile(rb"\w", re.L).flags) # 4
re.compile(rb"\w", re.U)
# ValueError: cannot use UNICODE flag with a bytes pattern2限目で、文字クラスの中の [[、--、&&、~~、|| は避けるように言いました。将来「集合演算」に使われる予定の場所なので、今は FutureWarning が出ます。文字そのものとして使いたいならエスケープします。
re.compile(r"[[a-z]")
# FutureWarning: Possible nested set at position 1
re.compile(r"[a-z--]")
# FutureWarning: Possible set difference at position 4
re.compile(r"[\[a-z]") # 警告なしre モジュールには、公式ドキュメントに載っていない内部の道具もあります。re.Scanner(トークナイザーを代わりに作ってくれるクラス)、Pattern.scanner()、Match.regs(すべてのグループの span)などです。存在だけ知っておいてください。ドキュメントにないものはいつ変わってもおかしくないので、実際のコードには今日作った finditer + lastgroup の方式を使います。
m = re.search(r"(F)-(\d+)", "badge=F-02")
print(m.regs) # ((6, 10), (6, 7), (8, 10))いよいよ暗号文のルールを書き出します。文字エスケープ4種類、署名、空白、そしてすき間をふさぐ MISMATCH です。11限目で学んだとおり、re.X で書き下してコメントを付けます。
import unicodedata
CIPHER = re.compile(r'''
(?P<HEX> \\x [0-9a-fA-F]{2} ) # \x46 16進数2桁
| (?P<UNI> \\u [0-9a-fA-F]{4} ) # \uc11c 16進数4桁
| (?P<NAME> \\N \{ [A-Z -]+ \} ) # \N{...} 文字名
| (?P<OCT> \\ [0-7]{3} ) # \060 8進数3桁
| (?P<SIGN> \(a\+\)\+\$ ) # 署名。re.escape("(a+)+$") と同じ
| (?P<SPACE> [ \n]+ ) # 空白と改行はそのまま
| (?P<MISMATCH> . ) # それ以外の文字はすべて怪しい
''', re.X)
def decode(message):
out = []
for m in CIPHER.finditer(message):
kind, value = m.lastgroup, m.group()
if kind in ("HEX", "UNI"):
out.append(chr(int(value[2:], 16)))
elif kind == "OCT":
out.append(chr(int(value[1:], 8)))
elif kind == "NAME":
out.append(unicodedata.lookup(value[3:-1]))
elif kind == "SPACE":
out.append(value)
elif kind == "MISMATCH":
raise ValueError(f"모르는 토큰 {value!r} (위치 {m.start()})")
return "".join(out).strip()
print(decode(message))
# F-02 = 서진
# 4F 03:02re.X の中でも、文字クラス [A-Z -] と [ \n] の空白は残ることを思い出してください。署名 SIGN は何も出力せずに捨てます(모르는 토큰 は「知らないトークン」)。
証拠分析室
re.search(r"", text)
戻り値
解読した文字が合っているかは、re に直接聞いてみることもできます。暗号文の1行目は、それ自体が正しい正規表現です。re も同じエスケープを理解するからです。
first = message.splitlines()[0]
print(re.fullmatch(first, "F-02 = 서진")) # <re.Match object; span=(0, 9), match='F-02 = 서진'>F-02 = 서진。서진 はハングルで「ソ・ジン」と読みます。4階、午前3時02分。11限目の入退室記録とぴったり同じ時刻です。
カン班長が尋ねます。「ソ・ジンってのは誰です?」。所長は引き出しから、伏せてあった写真立てを取り出し、机の上に立てます。若い2人が、この事務所の看板の下に立っている写真です。
「グレップ・アンド・コーを私と一緒に立ち上げた人。F-02 はあの人の入館証だよ」。所長は写真の中の男性を指先でなぞります。「天才だった。何でも1行の正規表現で書いてしまう。誰もそのコードを読めなかったし、誰も直せなかった。だから私たちが1つずつ書き下していったんだけど……あの人は、それが自分を消すことだと思ったんだろうね。ある日、入館証だけを置いて出ていった」
「入館証はどうして無効にしなかったんです?」。所長は答える代わりに、画面の最後の行 (a+)+$ を見つめます。「読めない正規表現こそ最高だと言っていた人が、自分の名前を読めるように送ってきた。最初から、誰かに読んでほしかったんだよ」
事件解決報告書 — バックトラッカーの正体は、グレップ・アンド・コーの創立メンバー、ソ・ジン。廃棄されていなかった入館証 F-02 で事務所に出入りし、挑戦状と手紙を残していた。彼が残した正規表現はすべて書き下してコメントを付け、事件手帳に貼った。入館証 F-02 は停止した。
その日の午後、所長と新人探偵は12の事件ファイルを最初からめくり直します。「今ならどう書くか」
re.escape("$4.99")。\d{2,3}[가-힣]\d{4} のように量指定子で短くし、検証は fullmatch で。search ではなく re.fullmatch(r"[a-z][a-z0-9_]{3,15}", user_id)。(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)。名前があるので、番号を数えなくていい。groupdict() で1行を dict に。count= のようなキーワード引数。^(a|aa)+$ の代わりに ^a+$。重なる繰り返しを作らない。re.X で書き下し、re.compile で名前を付ける。MISMATCH。夜遅く、メールボックスにもう1通届きます。今度は暗号ではありません。
ハン・ビョル。最後まで読んでくれてありがとう。あのころ、誰もおれのコードを読めなかったんじゃない。おれが読めるように書かなかったんだ。次はコメントを付けるよ。
(?x) a+ $ # もう引き返さない
署名が変わっていました。新人探偵は癖のように、その署名を分析室に入れてみます。外側の繰り返しが消えたパターンは、失敗するときもためらいません。
sign = r"(?x) a+ $ # もう引き返さない"
print(re.fullmatch(sign, "a" * 30 + "b")) # None所長が古い手帳を新人探偵の机に置きます。最初のページから、事件ごとに所長の字でびっしり埋まっています。「これからは、きみのものだ。短い正規表現が勝つんじゃない。読める正規表現が勝つ。それだけは忘れないで」
= を ASSIGN として、改行を NEWLINE として追加し、x = 3.5 の次の行に y = x * 2 がある2行のコードをトークンに分けてみましょう。\U の後ろに16進数8桁が続くトークンを追加し、\U0000C11C\U0000C9C4 を解読してみましょう。e.pos の位置に ^ を付けて見せる関数を作ってみましょう。証拠分析室がしていることと同じです。所長の手帳
トークナイザー:(?P<名前>ルール) を | でつなぎ、finditer + m.lastgroup。順番がそのまま優先順位、最後は MISMATCH。
パターンの中のエスケープ:\t \n \r \f \v \a \\、\x41 \u00e9 \U0001F600 \N{EM DASH}、8進数 \0 \101。[\b] はバックスペース。
決められていない英字のエスケープ(\q)はエラー。ユーザーのパターンは try/except re.error、e.msg e.pattern e.pos e.lineno e.colno。
bytes には rb パターン。str と混ぜると TypeError。bytes の \w は ASCII、re.L は bytes 専用、re.U は bytes ではエラー。
文字クラスの中の [[ -- && ~~ || は FutureWarning。re.Scanner、Pattern.scanner、Match.regs はドキュメントにない内部の道具。
短い正規表現より、読める正規表現が勝つ。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。