CASE-011
グレップ・アンド・コー データ探偵事務所
依頼書
依頼人
ハン・ビョル所長
証拠品
脅迫状3通の最後の行と、事務所4階の入退室記録。
解決条件
3行をつないで1行の正規表現にし、それを読める形に書き直して、何を探す正規表現なのかを明らかにする。
夜11時、事務所には明かりが1つだけ灯っています。所長が古い機械式キーボードの前に座り、3通の手紙の最後の行を打ち写しています。「1行で書いた正規表現は、書いた本人にしか読めない。3か月もたてば、その本人にも読めなくなる」
手紙3通の末尾を順に書き写したファイルと、所長が黙って取り出してきた事務所4階の入退室記録です。正規表現のかけらに door、4f、badge が見えたからです。
<<(?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T]
(0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+
badge=(f-0[12])\b(?!.*denied)>>2026-10-19 22:41 door=4F badge=F-01 result=ok
2026-10-20 02:13 door=4F badge=S-07 result=ok
2026-10-20 02:58 door=4F badge=F-02 result=denied
2026-10-20 03:02 door=4F badge=F-02 result=ok
2026.10.20 03:05 gate=4F badge=f-02 result=ok
2026-10-20 09:00 door=4F badge=F-01 result=ok正規表現の先頭にある (?mi) が最初の手がかりです。パターンの外側から、パターンの読まれ方を変えるスイッチ、フラグです。今日はフラグを1つずつオンにしながら、この1行を解きほぐします。
入退室記録を見ると、裏口(gate)の機械だけが入館証の番号を小文字の f-02 で記録しています。re.I(re.IGNORECASE)を付けると、大文字と小文字を区別しなくなります。
import re
re.findall(r"badge=F-02", door_log) # ['badge=F-02', 'badge=F-02']
re.findall(r"badge=F-02", door_log, re.I) # ['badge=F-02', 'badge=F-02', 'badge=f-02']re.M(re.MULTILINE)は4限目でちょっと味見しただけでした。本来 ^ は文字列の先頭、$ は末尾でしかマッチしません。re.M をオンにすると、行ごとに先頭と末尾でマッチします。ここで door_log は上の入退室記録6行です。
re.findall(r"^\S+ 0[0-4]:\d\d", door_log) # []
re.findall(r"^\S+ 0[0-4]:\d\d", door_log, re.M)
# ['2026-10-20 02:13', '2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']
re.findall(r"\w+$", door_log) # ['ok']
re.findall(r"\w+$", door_log, re.M) # ['ok', 'ok', 'denied', 'ok', 'ok', 'ok']re.M がないと、1行目が夜10時なので深夜の記録が1つも出てきません。行単位のログを扱うとき、re.M はほぼ必須です。
証拠分析室
re.search(r"", text)
フラグ
戻り値
次は、手紙の末尾3行を1つにつなげなければなりません。<< から >> までを捕まえればよさそうですが、何も出てきません。
m = re.search(r"<<(.*)>>", tails)
print(m) # None1限目からドットが言い続けてきたことです。. は改行を越えられません。<< の後ろの .* は1行目の終わりで止まり、そこには >> がありません。
所長が椅子を回して、窓を開けます。「ドット、出てごらん」。re.S(re.DOTALL)をオンにすると、. は改行も含めた本当にどんな文字でもになります。
m = re.search(r"<<(.*)>>", tails, re.S)
one_line = m.group(1).replace("\n", "")
print(one_line)
# (?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T](0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+badge=(f-0[12])\b(?!.*denied)ドットが窓枠を蹴って外へ飛び出し、隣の窓から戻ってきて机の上に座ります。3行が1行になりました。
ドットのひとこと
窓が開きました! re.S をオンにすると、ぼくは改行も越えて次の行、そのまた次の行まで行けるんです。でも気をつけてくださいね。.* はもともと欲張りなので、窓が開くとテキストの終わりまでひとっ走りしちゃいます。複数行にまたがって短く捕まえたいなら .*? を使ってください。
証拠分析室
re.search(r"", text)
フラグ
戻り値
re.M と re.S は名前が似ていてよく混同されますが、役割はまったく違います。
re.M は、^ と $ がマッチする位置を変えます(行ごと)。re.S は、. が食べられる文字を変えます(改行を含む)。re.findall(r"^.+$", "a\nb", re.M) # ['a', 'b']
re.findall(r"^.+$", "a\nb", re.S) # ['a\nb']バックトラッカーの正規表現が (?mi) だけをオンにして s をオンにしていないのにも理由があります。末尾の (?!.*denied) は「この行の後ろのほうに denied があってはならない」という意味です。s をオンにすると .* が次の行まで越えていくので、下のどの行に denied があっても弾いてしまいます。
1行にはなりましたが、まだ読めません。re.X(re.VERBOSE)をオンにすると、パターンの中の空白と改行は無視され、# から行末まではコメントになります。だから、パターンを複数行に分けて書き、説明を添えられます。
所長がひとかけらずつ区切って打ち直します。番号付きグループには名前を付け、\2 は10限目で学んだ (?P=sep) に置き換えます。
DOOR = re.compile(r'''
^
(?P<year>\d{4}) (?P<sep>[-./]) (?P<month>\d\d) (?P=sep) (?P<day>\d\d) # 日付。区切り文字は1種類
[ T] # 日付と時刻の間:空白または T
(?P<hour>0[0-4]) : (?P<minute>\d\d) # 深夜0時台から4時台まで
\s+ (?:door|gate) = 4f # 4階の表口または裏口
\s+ badge = (?P<badge>f-0[12]) \b # 入館証 F-01 または F-02
(?! .* denied ) # 拒否された記録は除く
''', re.I | re.M | re.X)
for m in DOOR.finditer(door_log):
print(m["hour"], m["minute"], m["badge"])
# 03 02 F-02
# 03 05 f-02同じ正規表現なのに、今度は1行ずつ読めます。深夜0時〜4時台に、4階のドアから、F-01 か F-02 の入館証で実際に入った記録を探す正規表現だったのです。
re.X にも例外があります。本物の空白や # を探したいときは、エスケープするか文字クラスに入れます。文字クラスの中の空白は消されません。上のパターンの [ T] がそれです。
re.fullmatch(r"\d{4} - \d\d", "2026-10", re.X) # <re.Match object; span=(0, 7), match='2026-10'>
re.fullmatch(r"a b", "a b", re.X) # None
re.fullmatch(r"a\ b", "a b", re.X) # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a[ ]b", "a b", re.X) # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a\#b", "a#b", re.X) # <re.Match object; span=(0, 3), match='a#b'>re.X なしで、パターンの途中に短いコメントだけを入れたいときは (?#...) を使います。かっこの中はまるごと無視されます(下の例のコメントは韓国語で「創立メンバーの入館証」)。
re.findall(r"F-0[12](?#창립 멤버 출입증)", door_log, re.I)
# ['F-01', 'F-02', 'F-02', 'f-02', 'F-01']証拠分析室
re.search(r"", text)
フラグ
戻り値
所長は書き下したパターンを長いあいだ見つめています。「変数名ひとつなく、全部1行。このコメントを付ける癖……いや、コメントを付けない癖。あいつもいつも……」。そこで言葉を切ります。机の上に伏せてあった古い写真立てを引き出しにしまい、もう一度キーボードに手を置きます。「続けよう」
2限目で、str パターンの \w はハングルも、\d はアラビア数字の ٣ も含むと説明しました。Unicode が既定だからです。re.A(re.ASCII)をオンにすると、\w \W \d \D \s \S \b \B が ASCII の文字だけを見るようになります。
re.findall(r"\w+", "café 한글 F_02") # ['café', '한글', 'F_02']
re.findall(r"\w+", "café 한글 F_02", re.A) # ['caf', 'F_02']
re.findall(r"\d", "٣3") # ['٣', '3']
re.findall(r"\d", "٣3", re.A) # ['3'](한글 は「ハングル」という意味の韓国語です。)
re.U(re.UNICODE)は str パターンの既定値なので、わざわざ書く必要はありません。古いコードとの互換性のために残っています。re.L(re.LOCALE)は bytes パターンでしか使えず、コンピューターの地域設定によって結果が変わるので推奨されません。str パターンに付けるとエラーになります。re.compile(r"\w", re.L)
# ValueError: cannot use LOCALE flag with a str pattern証拠分析室
re.search(r"", text)
フラグ
戻り値
フラグは | で組み合わせます。値は re.RegexFlag という列挙型で、整数のように扱えます。フラグなしを意味する re.NOFLAG(3.11 から、値は 0)もあるので、関数の既定値に使うと便利です。
flags = re.I | re.M
print(flags) # re.IGNORECASE|re.MULTILINE
print(type(flags)) # <flag 'RegexFlag'>
print(int(flags)) # 10
def find_all(pattern, text, flags=re.NOFLAG):
return re.findall(pattern, text, flags)バックトラッカーのように、パターンの先頭に (?aiLmsux) のうち必要な文字を書いても構いません。(?im) は re.I | re.M と同じです。先頭以外に書くとエラーになります。
re.findall(r"(?im)^\S+ 0\d:\d\d(?=.*f-02)", door_log)
# ['2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']
re.compile(r"badge=(?i)f-02")
# re.PatternError: global flags not at the start of the expression at position 6パターンの一部だけにフラグをかけたいなら、(?i:...) のようにかっこで範囲を区切ります。(?-i:...) はその範囲でだけオフにし、(?i-s:...) は i をオンにして s をオフにします。
re.findall(r"badge=(?i:f)-02", door_log) # ['badge=F-02', 'badge=F-02', 'badge=f-02']
re.findall(r"(?i)BADGE=(?-i:F)-02", door_log) # ['badge=F-02', 'badge=F-02']
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nB\n") # None
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nBc") # <re.Match object; span=(0, 4), match='a\nBc'>a、L、u は文字の範囲を決めるフラグなので、範囲の中でオンにはできても、オフにはできません。
re.findall(r"(?a:\w+)", "café") # ['caf']
re.compile(r"(?-a:\w+)")
# re.PatternError: bad inline flags: cannot turn off flags 'a', 'u' and 'L' at position 4最後に、re.DEBUG は re がパターンをどう理解したかをそのまま表示してくれます。バックトラッカーの署名と、そこから外側の繰り返しを取り除いたパターンを比べてみましょう。以下は出力の最初の部分で、その下に実行命令のリストが続きます。
re.compile(r"(a+)+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
# SUBPATTERN 1 0 0
# MAX_REPEAT 1 MAXREPEAT
# LITERAL 97
# AT AT_END
re.compile(r"a+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
# LITERAL 97
# AT AT_END繰り返し(MAX_REPEAT)の中に、また繰り返しが入っています。8限目の挑戦状の遅いパターンが、まさにこの形です。
所長が書き下したパターンを DOOR という名前に入れたように、re.compile はパターンを Pattern オブジェクトにします。モジュール関数と同じメソッド search match fullmatch findall finditer sub split を持ち、さらに検索範囲 pos と endpos も受け取ります。
p = re.compile(r"\d\d:\d\d")
line = "2026-10-20 03:02 door=4F"
print(p.search(line)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.match(line)) # None
print(p.match(line, 11)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.fullmatch(line, 11, 16)) # <re.Match object; span=(11, 16), match='03:02'>
print(p.findall(line, 0, 15)) # []
print(len(list(p.finditer(line, 11)))) # 1pos は文字列を切り取るのとは違います。^ は pos の位置ではなく、文字列の本当の先頭(re.M なら行の先頭)でしかマッチしません。
q = re.compile(r"^\d\d")
print(q.match(line, 11)) # None
print(q.match(line[11:])) # <re.Match object; span=(0, 2), match='03'>Pattern オブジェクトは自分自身の情報も持っています。str パターンの flags には re.UNICODE(32)が自動で加わります。
print(DOOR.groups) # 7
print(DOOR.groupindex)
# {'year': 1, 'sep': 2, 'month': 3, 'day': 4, 'hour': 5, 'minute': 6, 'badge': 7}
print(DOOR.flags) # 106
print(re.RegexFlag(DOOR.flags)) # re.IGNORECASE|re.UNICODE|re.MULTILINE|re.VERBOSE
print(re.compile("x").flags) # 32
print(re.compile(one_line).pattern == one_line) # Truere.search(パターン, ...) も、内部でコンパイルしています。最近使ったパターンは内部キャッシュに残るので、再コンパイルはされません。re.purge() はこのキャッシュを空にします(使う場面はまれです)。re.compile の本当の利点は速度よりも名前です。DOOR と名付けたパターンは、コードの中で読めるものになります。re.Pattern[str]、re.Match[str] と書きます(bytes パターンなら [bytes])。def night_entries(p: re.Pattern[str], log: str) -> list[re.Match[str]]:
return list(p.finditer(log))
print(len(night_entries(DOOR, door_log))) # 2書き下したパターンで、入退室記録を読み直します。
for m in DOOR.finditer(door_log):
print(f"{m['year']}-{m['month']}-{m['day']} {m['hour']}:{m['minute']} {m['badge'].upper()}")
# 2026-10-20 03:02 F-02
# 2026-10-20 03:05 F-02手紙から取り出した元の1行も、まったく同じ2行を見つけます。意味は同じで、違うのは読めるかどうかだけです。
[m.group(7) for m in re.finditer(one_line, door_log)] # ['F-02', 'f-02']10月20日の深夜、02:58 に表口で一度拒否された F-02 の入館証が、4分後にもう一度かざされて中に入り、その3分後には裏口を通っていました。バックトラッカーは「おれがいつ来たのか、読めるものなら読んでみろ」と、この正規表現を送ってきたのです。
所長が入館証の名簿を広げます。「F-01 は私のだ。F-02 は……」。名簿のその欄には、名前の代わりに二重線が引かれています。「10年前に無効にしておくべきだった。できなかったんだよ」
事件解決報告書 — 3通の手紙の末尾は、1つの正規表現だった。書き下した結果、2026-10-20 03:02〜03:05 に入館証 F-02 が事務所4階に出入りしたことを確認。F-02 は創立当時に発行された入館証で、本日付で停止した。
所長は最後に、元の1行を画面に映したまま言います。「同じ正規表現だ。片方は書いた本人にしか読めず、もう片方は誰にでも読める。私たちは、誰にでも読めるほうで書く」
[a-z][a-z0-9_]{3,15} を re.X で書き下し、かけらごとにコメントを付けてみましょう。re.compile(r"\d+") の pos を使って、"no.17 at 03:02" から at の後ろに出てくる数字だけを探してみましょう。re.compile(r"badge=(?i)f-02") のエラーを確かめたあと、範囲フラグ (?i:...) を使って同じ意味になるように直してみましょう。バックトラッカーの痕跡
(a+)+$
書き下したか。次のは書き下すことすらできないだろう。明日の朝9時、メールボックスを開け。
所長の手帳
re.I は大文字小文字を無視、re.M は ^ $ が行ごと、re.S は . が改行まで。M と S は役割が違う。
re.X:空白と # コメントを無視する。文字クラスの中の空白と \ (バックスラッシュ+空白)は残る。(?#...) はどこでもコメント。
re.A:\w \d \s \b を ASCII に。str は re.U が既定、re.L は bytes 専用で非推奨。
組み合わせは re.I | re.M(re.RegexFlag)、なしは re.NOFLAG。インラインの (?im) は先頭にだけ。
範囲フラグ (?i:...) (?-i:...) (?i-s:...)。a L u はオフにできない。re.DEBUG でパターンの中身を見る。
re.compile は名前を付ける道具。pos endpos を受け取るが、^ は本当の先頭でだけ。属性は flags groups groupindex pattern。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。