CASE-010
グレップ・アンド・コー データ探偵事務所
依頼書
依頼人
カン班長
証拠品
カン班長宛てに届いた脅迫状3通と、昨日もう1通届いた手紙。鑑識班が4通を1行ずつ書き写した。
解決条件
同じ人物が送った手紙がどれかを見分け、その根拠をパターンで示す。
雨の夕方、カン班長がビニール袋を4つ机に並べます。「脅迫状です。3通は先週から届いていて、1通は昨日来た。新聞に記事が出たあとにね」。所長が眼鏡をかけ直します。「まねをしてるやつが1人まぎれてるってことか。証拠はパターンを残す。同じ手が書いた文には、同じ癖がある」
鑑識班が4通の手紙を1行ずつ書き写した記録です。本文(msg=)は短い英語の1行。「金曜日にサーバーが崩れる」「誰も私を読めない」「最後に会おう」「金を払え」といった意味です。
#1 2026.10.18 key='K-17' tel=(010)-0000-1018 msg=the server server falls on friday
#2 2026-10-19 key="K-17" tel=010-0000-1019 msg=no one can can read me
#3 2026/10/20 key='K-17' tel=(010)-0000-1020 msg=see you At at the end
#4 2026-10/21 key="K-17' tel=(010-0000-1021 msg=pay now or elseぱっと見では4通とも似ています。今日は「前で捕まえたものとまったく同じものがもう一度出てくるか」を問う文法、後方参照で癖を探します。
所長が1通目を指さします。「server server。同じ単語を2回打ってる。2通目も can can だ」。打ち間違いに見えますが、3通すべてにあるなら、それは癖です。
5限目でグループ番号を学びました。その番号は、パターンの中でも呼び出せます。\1 は「1番グループがたった今捕まえた文字と同じ文字」という意味です。これを後方参照(backreference)と呼びます。
import re
line = "msg=the server server falls on friday"
m = re.search(r"\b(\w+)\s+\1\b", line)
print(m.group()) # server server
print(m.group(1)) # server(\w+) が server を捕まえると、\1 はその瞬間から server という文字になります。\1 はパターン \w+ をもう一度書くこととは違います。だから、異なる2つの単語にはマッチしません。re.search(r"(\w+)\s+(\w+)", "the server").groups() # ('the', 'server')
re.search(r"(\w+)\s+\1", "the server") # None前後の \b も大切です。外すと、can cannot の can can まで「繰り返された単語」として捕まってしまいます。
re.search(r"(\w+)\s+\1", "can cannot") # <re.Match object; span=(0, 7), match='can can'>
re.search(r"\b(\w+)\s+\1\b", "can cannot") # None証拠分析室
re.search(r"", text)
戻り値
分析室の結果には、おかしな点があります。3通目の At at が抜けています。A と a は別の文字だからです。re.I(大文字小文字を区別しない。11限目で正式に学びます)を付けると、後方参照も大文字小文字を区別せずに比べます。
re.findall(r"\b(\w+)\s+\1\b", letters) # ['server', 'can']
re.findall(r"\b(\w+)\s+\1\b", letters, re.I) # ['server', 'can', 'At']
print(re.search(r"(a)\1", "aA", re.I)) # <re.Match object; span=(0, 2), match='aA'>ここで letters は、上の手紙4行を入れた文字列です。結果を見ると、同じ単語を2回打つ癖は1・2・3通目にしかありません。
5限目に所長が先送りにした問題がありました。[-./] で区切り文字を選ぶと、2026-10/20 のように混ざった日付も通ってしまいます。文字クラスは位置ごとに別々に選ぶからです。後方参照で最初の区切り文字を捕まえておき、2つ目の位置に同じ文字を要求すればよいのです。
re.fullmatch(r"\d{4}[-./]\d\d[-./]\d\d", "2026-10/21") # <re.Match object; span=(0, 10), match='2026-10/21'>
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026-10/21") # None
re.fullmatch(r"\d{4}([-./])\d\d\1\d\d", "2026/10/20") # <re.Match object; span=(0, 10), match='2026/10/20'>名前付きグループ(6限目)もパターンの中で呼び出せます。書き方は (?P=名前) です。グループが増えてきたら、番号より名前のほうが読みやすくなります。
DATE = r"(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)"
m = re.search(DATE, "#3 2026/10/20 key='K-17'")
print(m.groupdict()) # {'y': '2026', 'sep': '/', 'm': '10', 'd': '20'}(?P=sep) の位置に \2 と書いても同じです。(?P=sep)、置換文字列では \g<sep>(7限目)、Match オブジェクトでは m["sep"] です。証拠分析室
re.search(r"", text)
戻り値
key= の後ろの値を見てみます。1・3通目はシングルクォート 'K-17'、2通目はダブルクォート "K-17" で囲んでいます。4通目だけが "K-17' のように、ダブルクォートで開いてシングルクォートで閉じています。開き引用符をグループで捕まえ、閉じるときに \1 を要求すれば、対が合っているかがわかります。
QUOTED = r'(["\']).*?\1'
for line in letters.splitlines():
m = re.search(QUOTED, line)
print(line[:2], m.group() if m else None)
# #1 'K-17'
# #2 "K-17"
# #3 'K-17'
# #4 None\' と書きました。正規表現では \' はただの ' です。.*?(怠惰な量指定子、8限目)にしたのには理由があります。.* にすると、最初の引用符から最後の同じ引用符までを一気に食べてしまいます。re.search(r'(["\']).*\1', '"a" and "b"').group() # '"a" and "b"'
re.search(r'(["\']).*?\1', '"a" and "b"').group() # '"a"'証拠分析室
re.search(r"", text)
戻り値
ところが、同じパターンを findall に入れると妙な結果になります。
re.findall(QUOTED, letters) # ["'", '"', "'"]
[m.group() for m in re.finditer(QUOTED, letters)] # ["'K-17'", '"K-17"', "'K-17'"]5限目で学んだルールそのままです。グループが1つあると、findall はそのグループだけを返します。今回は (?:...) に変えることもできません。\1 が呼び出すグループがなくなってしまうからです。
ドットのひとこと
ぼくも最初は findall で引用符の対を集めようとして、引用符の文字ばかりひと口ぶんくわえて帰ってきちゃいました。後方参照にはグループが必ず要るので、全体が欲しいときは finditer で m.group() を取り出します。パターン全体をもう一度かっこで囲む手もありますけど、そうすると番号がずれて \1 が \2 になっちゃいますよ。
電話番号 tel= を見てみます。1・3通目は (010)-0000-1018 のように市外局番をかっこで囲み、2通目はかっこなしで書いています。4通目は (010-0000-1021 のように開きかっこしかありません。「開きかっこがあったなら、閉じかっこもなければならない」というルールは、条件付きパターンで書きます。
(?(1)yes|no) は「1番グループがマッチに参加していれば yes のパターン、そうでなければ no のパターン」です。no 側は省略できます。(?(1)yes) は「グループがあれば yes、なければ何も要求しない」です。
PHONE = r"(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}"
for tel in ["(010)-0000-1018", "010-0000-1019", "(010-0000-1021", "010)-0000-1021"]:
print(tel, bool(re.fullmatch(PHONE, tel)))
# (010)-0000-1018 True
# 010-0000-1019 True
# (010-0000-1021 False
# 010)-0000-1021 False(\()?:開きかっこを1番グループで捕まえますが、なくても構いません。(?(1)\)):1番グループが捕まっていれば閉じかっこを要求し、捕まっていなければそのまま素通りします。名前付きグループなら、番号の代わりに名前で条件をかけます。書き方は (?(名前)yes|no) です。次の例は、項目番号が (1) なら閉じかっこで、2. のようにかっこがなければピリオドで終わらなければならない、というルールです。
ITEM = r"(?P<p>\()?\d+(?(p)\)|\.)"
for s in ["(1)", "2.", "(3.", "4)"]:
print(s, bool(re.fullmatch(ITEM, s)))
# (1) True
# 2. True
# (3. False
# 4) False証拠分析室
re.search(r"", text)
戻り値
解決条件をすべて満たしても、分析室の4通目の行を見ると、まだ 010-0000-1021 に色が付いています。search と finditer はどの位置からでもマッチを始められるので、開きかっこを飛ばしてその後ろから始めてしまったのです。前に tel= を付けて、開始位置を縛ってあげましょう。
re.search(PHONE, "tel=(010-0000-1021").group() # '010-0000-1021'
re.search(r"tel=" + PHONE + r"\b", "tel=(010-0000-1021") # None後方参照の番号が2桁になると紛らわしくなります。パターンの中の \10 は「1番グループの後ろに文字 0」ではなく、10番グループです。10番グループがなければエラーになります。8進数の文字として読まれることもありません。
re.compile(r"(a)\10")
# re.PatternError: invalid group reference 10 at position 4
p = re.compile(r"(a)(b)(c)(d)(e)(f)(g)(h)(i)(j)\10")
print(p.fullmatch("abcdefghijj")) # <re.Match object; span=(0, 11), match='abcdefghijj'>\1[0] のように 0 を文字クラスに入れて切り離します。\g<1>0(7限目)は、パターンの中では使えません。パターンでは \g は未知のエスケープなのでエラーになります。\0 で始まるときか3桁のときだけです(\101 は A)。12限目でもう一度見ます。print(re.fullmatch(r"(a)\1[0]", "aa0")) # <re.Match object; span=(0, 3), match='aa0'>4つの癖をルールとして書き出し、手紙ごとにいくつ当てはまるかを数えます。辞書のキーは事務所の記録どおり韓国語のままです(반복 は「繰り返し」、날짜 は「日付」、따옴표 は「引用符」、괄호 は「かっこ」)。
RULES = {
"반복": re.compile(r"\b(\w+)\s+\1\b", re.I),
"날짜": re.compile(r"\d{4}([-./])\d\d\1\d\d"),
"따옴표": re.compile(r'key=(["\']).*?\1'),
"괄호": re.compile(r"tel=(\()?\d{2,3}(?(1)\))-\d{3,4}-\d{4}\b"),
}
for line in letters.splitlines():
hits = [name for name, rx in RULES.items() if rx.search(line)]
print(line[:2], hits)
# #1 ['반복', '날짜', '따옴표', '괄호']
# #2 ['반복', '날짜', '따옴표', '괄호']
# #3 ['반복', '날짜', '따옴표', '괄호']
# #4 []1・2・3通目は、4つの癖がすべて同じです。同じ単語を2回打ち、区切り文字を1種類にそろえ、引用符とかっこの対を一度も間違えません。4通目は見た目をまねただけで、癖は1つもありません。
カン班長が舌打ちします。「4通目は記事を見てふざけたやつで、本物は3通ってわけか」。所長がうなずきます。「本物は几帳面だ。対ひとつ間違えないやつだよ」
事件解決報告書 — 脅迫状1・2・3通目は同一人物(バックトラッカー)が送ったもの。繰り返し単語、そろった日付の区切り文字、引用符・かっこの対がすべて一致する。4通目は模倣と判断し、カン班長に別件として引き渡した。
報告書を閉じようとしたとき、所長が手紙の原本をもう一度手に取ります。鑑識班が書き写さなかった行がありました。本物の3通のいちばん下に、かっことバックスラッシュだけでできた妙な1行が、それぞれ1つずつ。
0000 のように同じ数字が4回続く場所を探すパターンを書いてみましょう。ヒント:\1 にも量指定子を付けられます。<b>bold</b> <i>x</b> から、開きタグと閉じタグの名前が同じものだけを探してみましょう。結果は <b>bold</b> 1つになるはずです。< で始まるなら > で終わらなければならない、というパターンを条件付きパターンで書いてみましょう。<kim@example.com> と kim@example.com は通り、<kim@example.com は失敗しなければなりません。バックトラッカーの痕跡
(a+)+$
3通の最後の行。順番どおりにつなげろ。1行になる。読めるものならな。
所長の手帳
パターンの中の \1 は、1番グループが実際に捕まえた文字と同じ文字。パターンをもう一度書くのではない。
繰り返し単語 \b(\w+)\s+\1\b、日付の区切り文字をそろえる \d{4}([-./])\d\d\1\d\d。
名前付きグループは (?P=name) で呼び出す。置換では \g<name>、Match では m['name']。
引用符の対 (["']).*?\1。findall は引用符だけを返すので、全体は finditer で。
条件付き (?(1)yes|no)、(?(name)yes|no)。no は省略できる:(?(1)yes)。
re.I では後方参照も大文字小文字を区別しない。\10 は10番グループ、1番の後ろの 0 は \1[0]。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。