CASE-004
グレップ・アンド・コー データ探偵事務所
依頼書
依頼人
ネットショップ「マーケット・チョン」のチョン社長
証拠品
会員登録の申請履歴と、IDのルールをチェックするPython関数1つ。
解決条件
ルールに合わないIDをすべてはじき出し、チェッカーがなぜ突破されたのかを明らかにする。
昼どき、チョン社長がノートパソコンを抱えて入ってきます。「IDのルールはちゃんと決めてあるんです。なのに、おかしなIDがどんどん入ってきて」所長はしばらく画面を眺めてから、短く言います。「ルールは合ってる。聞き方が間違ってるんだ」
マーケット・チョンのIDルールとチェック関数はこうです。
아이디 규칙: 영문 소문자로 시작하고, 소문자·숫자·밑줄(_)만 써서 4~16자ルールの中身は「英小文字で始まり、小文字・数字・アンダースコア(_)だけを使って4〜16文字」です。
import re
ID = r"[a-z][a-z0-9_]{3,15}"
def is_valid_id(uid):
return re.search(ID, uid) is not None登録申請の履歴です。
2026-10-18 14:02:51 signup id=jeong_fan
2026-10-19 03:11:02 signup id=ok_id!!!
2026-10-19 03:11:04 signup id=_admin
2026-10-19 03:11:05 signup id=ABC123
2026-10-19 03:11:07 signup id=a1
2026-10-19 09:40:13 signup id=minsu99まず、チョン社長のチェッカーが何を通してしまうのかを見てみます。
print(is_valid_id("ok_id!!!")) # True ← ビックリマークがあるのに通る
print(is_valid_id("_admin")) # True ← アンダースコアで始まるのに通る
print(is_valid_id("ABC123")) # Falsere.search は「この文字列の どこかに ルールに合う部分があるか」を尋ねます。ok_id!!! の中には ok_id が、_admin の中には admin があるので成功です。
re.search(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.search(ID, "!!!ok_id") # <re.Match object; span=(3, 8), match='ok_id'>入力の検証では、別の質問をしなければなりません。「この文字列の 全体が ルールに合っているか」。そのためには、パターンに「ここが始まり、ここが終わり」と書く必要があります。
角かっこの外の ^ は 文字列の始まり、$ は 文字列の終わり を表します。こうした記号を アンカー(anchor、いかり)と呼びます。
re.search(r"^id", "id=kim") # <re.Match object; span=(0, 2), match='id'>
re.search(r"^id", "my id=kim") # None
re.search(r"kim$", "id=kim") # <re.Match object; span=(3, 6), match='kim'>
re.search(r"kim$", "id=kim!") # None^id のマッチは、あくまで id の2文字です。[^...] と混同しないでください。^ は角かっこの先頭なら否定、角かっこの外なら始まりです。チェッカーのパターンの前後に ^ と $ を付ければ、ルールに合う部分が文字列の 最初から最後まで 続いていなければならなくなります。
証拠分析室
re.search(r"", text)
戻り値
アンカーを自分で書かなくても、どこで照らし合わせるかを関数で選べます。
| 関数 | 尋ねること | アンカーで書くと |
|---|---|---|
re.search | どこかに合う部分があるか | (アンカーなし) |
re.match | 先頭から 合う部分があるか | \Aパターン |
re.fullmatch | 全体が 合っているか | \Aパターン\Z |
\A と \Z は ^、$ に似たアンカーですが、その違いは4-4で見ます。
re.match(ID, "ok_id!!!") # <re.Match object; span=(0, 5), match='ok_id'>
re.match(ID, "!!!ok_id") # None
re.fullmatch(ID, "ok_id!!!") # None
re.fullmatch(ID, "ok_id") # <re.Match object; span=(0, 5), match='ok_id'>re.match は名前に反して「全体が合っているか」までは見ません。先頭部分さえ合えば成功なので、ok_id!!! を通してしまいます。re.fullmatch です。パターンにアンカーを付け忘れても安全で、次の節の落とし穴も避けられます。証拠分析室
re.search(r"", text)
戻り値
$ には隠れたルールがひとつあります。文字列の本当の終わりだけでなく、いちばん最後の改行の直前 でもマッチするのです。そのため ^abc$ は、末尾に改行の付いた "abc\n" も通してしまいます。
re.search(r"^abc$", "abc\n") # <re.Match object; span=(0, 3), match='abc'>
re.fullmatch(r"abc", "abc\n") # None
re.search(r"\Aabc\Z", "abc\n") # None\A は文字列のいちばん最初でだけ、\Z は文字列のいちばん最後でだけマッチします。改行の例外はありません。\z も使えます。Pythonでは \Z とまったく同じで、ほかの言語の正規表現と書き方をそろえるために追加されました。re.search(r"\Aabc\z", "abc") # <re.Match object; span=(0, 3), match='abc'>
re.search(r"\Aabc\z", "abc\n") # Noneドットのひとこと
ファイルから1行ずつ読んだ文字には、末尾に改行が付いていることが多いんです。^...$ でチェックすると、その改行を見て見ぬふりして通しちゃいます。するとIDの末尾に改行が付いたまま保存されちゃいますよ。検証には fullmatch か、\A...\Z を使ってくださいね。
3限目の宿題が残っています。電話番号のパターンが、1234-5678-9012 の ど真ん中 の 234 からマッチを始めてしまった事故です。今回は文字列全体ではなく、長い文章の中で 番号1つの始まりと終わり を教えてくれるアンカーが必要です。
\b は 単語境界 です。\w(文字・数字・アンダースコア)と \W(それ以外の文字)のあいだ、または文字列の始まり・終わりのうち \w の隣にあたる位置にマッチします。\B はその逆で、境界 ではない 位置です。どちらも文字を消費しません。
re.findall(r"\bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\Bid\b", "id=kim userid=lee id_card") # ['id']
re.findall(r"\bid\B", "id=kim userid=lee id_card") # ['id']3つの結果はどれも ['id'] ですが、見つけた位置が違います。
\bid\b :前後がどちらも境界である、id=kim の id\Bid\b :前が境界ではない、userid の id\bid\B :後ろがアンダースコア(\w)なので境界ではない、id_card の id電話番号の前後に \b を付ければ、数字の真ん中からは始められなくなります。1 と 2 のあいだは、どちらも \w なので境界ではないからです。
re.findall(r"\b\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}\b", phonebook)
# ['010-0000-1234', '010 0000 9876', '02-0000-5678', '010.0000.4321']ひとつ注意があります。Pythonではハングル(そして日本語のかなや漢字)も \w です。そのため、ハングルのすぐ後ろにくっついた数字の前には境界がありません。下の例の 전화 は「電話」という意味の韓国語です。
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화010-0000-1234") # []
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화 010-0000-1234") # ['010-0000-1234']証拠分析室
re.search(r"", text)
戻り値
登録履歴のように複数の行が1つの文字列に入っていると、^ は 最初の行の始まり でしかマッチしません。各行の先頭にある日付を抜き出したいなら、re.MULTILINE(略して re.M)フラグを渡します。すると ^ と $ が 行ごとに 始まりと終わりにマッチします。
re.findall(r"^\d{4}-\d\d-\d\d", signups)
# ['2026-10-18']
re.findall(r"^\d{4}-\d\d-\d\d", signups, re.MULTILINE)
# ['2026-10-18', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19']re.M を渡しても、\A と \Z はあいかわらず文字列全体の最初と最後でしかマッチしません。
re.findall(r"^\d", "1\n2", re.M) # ['1', '2']
re.findall(r"\A\d", "1\n2", re.M) # ['1']フラグは11限目でまとめて正式に扱います。今日は「^ と $ を行単位に切り替えるスイッチがある」とだけ覚えておいてください。
証拠分析室
re.search(r"", text)
フラグ
戻り値
チェッカーの re.search を re.fullmatch に変えます。パターンはそのままです。
def is_valid_id(uid):
return re.fullmatch(r"[a-z][a-z0-9_]{3,15}", uid) is not None
for line in signups.splitlines():
uid = line.split("id=")[1]
if not is_valid_id(uid):
print(line)
# 2026-10-19 03:11:02 signup id=ok_id!!!
# 2026-10-19 03:11:04 signup id=_admin
# 2026-10-19 03:11:05 signup id=ABC123
# 2026-10-19 03:11:07 signup id=a1はじかれた4件はどれもルール違反です。ok_id!!! はビックリマーク、_admin はアンダースコアで始まり、ABC123 は大文字、a1 は短すぎます。
事件解決報告書 — IDチェッカーがre.searchで「一部が合えば通す」状態になっていた。re.fullmatchに直し、ルール違反の登録4件をブロックした。
ところが所長が、出力された4行の時刻を指でなぞっていきます。03:11:02、03:11:04、03:11:05、03:11:07。「5秒で4回。それも午前3時に。人間の手じゃない」チョン社長の顔がこわばります。偽アカウントは同じ日の明け方、いっせいに押し寄せていたのです。誰が送り込んだのかは、まだ誰も知りません。
ここまでが第1部です。1文字、1つの位置、回数、境界まで、「何を探すか」を書く方法はすべて学びました。第2部では、見つけたものから 必要なかけらを取り出す 方法を学びながら、この明け方の客を追いかけます。
re.match(r"\d{4}", "2026-10-19") と re.match(r"\d{4}", "#2026") の結果を予想してから、実行してみましょう。re.findall(r"\bcat\b", "cat concat cats") と re.findall(r"\Bcat", "cat concat cats") は、それぞれどのcatを見つけるでしょうか。所長の手帳
^ は始まり、$ は終わり。アンカーは文字ではなく位置にマッチする。
re.search はどこでも、re.match は先頭だけ、re.fullmatch は全体。入力検証は fullmatch。
$ は最後の改行の前でもマッチする。改行の例外なしで終わりを見るなら \Z(3.14からは \z も同じ)。
\b は \w と \W のあいだの境界、\B は境界ではない位置。ハングルも \w。
re.M を渡すと ^ $ が行ごとにマッチする。\A \Z は変わらず文字列全体が基準。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。