CASE-006
グレップ・アンド・コー データ探偵事務所
依頼書
依頼人
ネットショップ「マーケット・チョン」運営者のチョン社長
証拠品
マーケット・チョンのウェブサーバーのアクセスログ。偽の会員登録が押し寄せた、あの日の明け方の記録だ。
解決条件
ログ1行を名前付きの欄(dict)に分け、午前3時に管理者ページ(/admin)へ入ってきたIPを突き止める。
チョン社長がまたやってきました。今度はノートパソコンごと持ってきています。「偽アカウントが明け方に押し寄せたじゃないですか。その時間に管理画面が開かれた記録があるらしいんです。私、その時間は寝てたんですけど」
所長は画面にざっと目を通して言います。「ウェブサーバーのログだね。1行に欄が7つほどある。番号で呼ぶと混乱する。名前を付けて」
203.0.113.7 - - [20/Oct/2026:02:58:40 +0900] "GET /products/42 HTTP/1.1" 200 5123
198.51.100.23 - - [20/Oct/2026:03:01:05 +0900] "POST /login HTTP/1.1" 302 0
203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.66 - - [20/Oct/2026:03:02:19 +0900] "GET /admin/users?page=2 HTTP/1.1" 200 12044
198.51.100.4 - - [20/Oct/2026:09:15:30 +0900] "GET /admin HTTP/1.1" 200 8890
203.0.113.7 - - [20/Oct/2026:09:16:02 +0900] "GET /cart HTTP/1.1" 200 22101行は左から順に、アクセスしてきたIP、空欄2つ(-)、角かっこの中の時刻、引用符の中のリクエスト(メソッド・パス・プロトコル)、レスポンスコード、送信バイト数です。
5限目では、グループを group(1)、group(2) のように番号で呼びました。グループが7つを超えると、何番が何なのか覚えていられません。グループに名前を付けるには、開きかっこのすぐ後ろに ?P<名前> と書きます。
import re
line = '203.0.113.66 - - [20/Oct/2026:03:02:11 +0900] "GET /admin HTTP/1.1" 200 8890'
m = re.search(r"(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\]", line)
print(m.group("ip")) # 203.0.113.66
print(m["time"]) # 20/Oct/2026:03:02:11 +0900
print(m.group("ip", "time")) # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')
print(m.groupdict()) # {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900'}m.group("ip") と m["ip"] は同じです。名前を複数渡すと、タプルで返ってきます。groupdict() は 名前付きグループだけ を集めて、{名前: 文字} の辞書で返します。ログ1行が、そのまま辞書1つになります。- が入ったりするとエラーです。名前付きグループも 番号をそのまま持っています。 番号は5限目とまったく同じく、開きかっこの順に振られます。名前は、番号の上に付けたあだ名にすぎません。
print(m.group(1)) # 203.0.113.66
print(m[2]) # 20/Oct/2026:03:02:11 +0900
print(m.groups()) # ('203.0.113.66', '20/Oct/2026:03:02:11 +0900')証拠分析室
re.search(r"", text)
戻り値
ドットのひとこと
ほかの言語で (?<name>...) と書くのを見たことがあるなら要注意ですよ。Pythonでは P を抜かすと "unknown extension ?<n" エラーになっちゃいます。それから、同じ名前を2回付けると "redefinition of group name" エラーです。ぼくの名前だって、ひとつだけですからね。
いよいよ1行のすべての欄に名前を付けます。欄と欄のあいだのスペース、引用符、角かっこは、パターンに文字そのまま書きます。
LINE_RE = r'(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] "(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<size>\d+)'
m = re.fullmatch(LINE_RE, line)
print(m.groupdict())
# {'ip': '203.0.113.66', 'time': '20/Oct/2026:03:02:11 +0900', 'method': 'GET', 'path': '/admin', 'status': '200', 'size': '8890'}r'...' で囲みました。[^\]]+ は「閉じ角かっこではない文字の並び」、[^"]* は「引用符ではない文字の並び」です。2限目の否定クラスが、欄の境目を押さえてくれます。fullmatch を使います。形の崩れた行は None になり、すぐに見つかります。辞書にしてしまえば、あとは普通のPythonです。いちばん上のログ6行が access_log という文字列に入っているとしましょう。
rows = [re.fullmatch(LINE_RE, l).groupdict() for l in access_log.splitlines()]
print(len(rows)) # 6
print(rows[1]["method"], rows[1]["path"], rows[1]["status"]) # POST /login 302値はすべて 文字列 です。バイト数を足し合わせたいなら、int(row["size"]) のように自分で変換する必要があります。
証拠分析室
re.search(r"", text)
戻り値
Matchオブジェクトは、何を見つけたかと一緒に、どこで 見つけたかもグループごとに教えてくれます。引数にグループの番号か名前を渡すと、そのグループの位置がわかります。
m = re.fullmatch(LINE_RE, line)
print(m.start("path"), m.end("path")) # 51 57
print(m.span("status")) # (68, 71)
print(line[m.start("path"):m.end("path")]) # /admin省略可能なグループは、マッチしないこともあります。パスの後ろにつく ?page=2 のようなクエリ文字列がそうです。マッチしなかったグループは、文字が None、位置が -1 になります。
P = r"(?P<path>/[^\s?]*)(?:\?(?P<query>\S+))?"
a = re.search(P, "GET /admin HTTP/1.1")
b = re.search(P, "GET /admin/users?page=2 HTTP/1.1")
print(a.group("query"), a.span("query")) # None (-1, -1)
print(b.group("query"), b.span("query")) # page=2 (17, 23)None が混ざると、文字列の処理でエラーが起きやすくなります。groups() と groupdict() は、None の代わりに入れる値を default 引数で受け取れます。
print(a.groups()) # ('/admin', None)
print(a.groups(default="")) # ('/admin', '')
print(a.groupdict(default="-")) # {'path': '/admin', 'query': '-'}証拠分析室
re.search(r"", text)
戻り値
よく使うわけではありませんが、知っておくとデバッグのときに重宝する属性です。
m = re.search(r"(?P<method>[A-Z]+) (?P<path>\S+)", line)
print(m.lastindex, m.lastgroup) # 2 path
print(m.pos, m.endpos, len(line)) # 0 76 76
print(m.string is line) # True
print(m.re) # re.compile('(?P<method>[A-Z]+) (?P<path>\\S+)')lastindex は 最後に閉じた グループの番号、lastgroup はそのグループの名前です。名前がない場合や、マッチしたグループがない場合は None です。((\d+)\.(\d+)) の lastindex は3ではなく1です。lastgroup は、(?P<a>x)|(?P<b>y) のように名前付きグループを | でつないだとき、どちらがマッチしたか を教えてくれます。12限目では、この性質を使ってトークナイザーを作ります。pos、endpos は検索範囲です。モジュール関数で探した場合は、いつも 0 と文字列の長さです。範囲を自分で指定する方法は、11限目の re.compile で学びます。string は検索したもとの文字列、re は使ったパターンオブジェクトです。n = re.search(r"((\d+)\.(\d+))", "v3.14")
print(n.lastindex, n.lastgroup) # 1 None
n = re.search(r"(?P<a>x)|(?P<b>y)", "y")
print(n.lastindex, n.lastgroup) # 2 bMatchオブジェクトは変更されない値なので、copy.copy(m) や copy.deepcopy(m) をしてもかまいません。結果は同じオブジェクトそのものです。
最後に、expand(template) はマッチ結果をひな形にはめ込んで、新しい文字列を作ります。ひな形の中の \g<名前> が、そのグループの文字に置き換わります。7限目の置換文字列とまったく同じ文法です。下の例の 요청 は韓国語で「リクエスト」という意味です。
print(m.expand(r"\g<method> 요청: \g<path>")) # GET 요청: /adminでは、事件の条件をパターンに入れましょう。時刻の欄 [20/Oct/2026:03:02:11 +0900] では、日付の後ろの最初の : に続く2桁が「時」です。\[[^:]+: で日付を読み飛ばし、その位置に 03 と書けば「午前3時」という条件になります。
03 や :03: で探すと、分や秒が03の行まで引っかかります。どの欄の03なのか、位置を固定すること が大切です。/admin で始まるすべてのページを捕まえるように /admin\S* と書きます。証拠分析室
re.search(r"", text)
戻り値
正解の一例はこうです。
(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)時刻を日・月・年・時・分・秒にすべて分けて名前を付け、1行ずつ辞書のように取り出して条件をかけます。名前付きグループのおかげで、条件文が人の言葉のように読めます。
TIME_RE = (r'(?P<ip>\S+) \S+ \S+ '
r'\[(?P<day>\d\d)/(?P<mon>\w{3})/(?P<year>\d{4}):(?P<hour>\d\d):(?P<min>\d\d):(?P<sec>\d\d) [^\]]+\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+)')
suspects = set()
for l in access_log.splitlines():
m = re.match(TIME_RE, l)
if m and m["hour"] == "03" and m["path"].startswith("/admin"):
print(m["hour"] + ":" + m["min"], m["ip"], m["path"])
suspects.add(m["ip"])
# 03:02 203.0.113.66 /admin
# 03:02 203.0.113.66 /admin/users?page=2
print(suspects) # {'203.0.113.66'}午前3時2分、203.0.113.66 が管理画面を開き、その8秒後には会員一覧の2ページ目までダウンロードしていました。朝9時の 198.51.100.4 は、チョン社長のオフィスのIPでした。チョン社長の顔から血の気が引きます。「会員一覧ですって? じゃあ、お客さんの情報が……」
事件解決報告書 — 10月20日03:02、外部IP 203.0.113.66が管理者ページにアクセスし、会員一覧を閲覧した。管理者パスワードの即時変更と、アクセス元IPの制限を勧告。会員情報流出の可能性あり。
LINE_RE でログを読み、レスポンスコード(status)が 200 の行の size をすべて足してみましょう。int() を忘れずに。(?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d) で "2026-10-20" を見つけたあと、m.expand(r"\g<d>/\g<m>/\g<y>") の結果を予想してみましょう。re.search(r"(?P<a>\d)(?P<b>x)?", "7") の span("b")、groupdict(default="?")、lastgroup を予想してから確かめてみましょう。所長の手帳
(?P<name>...) でグループに名前を。m.group("name")、m["name"]、m.groupdict()。
名前付きグループも番号を持つ。番号は開きかっこの順。
start(g)、end(g)、span(g)。マッチしなかったグループは None、位置は -1。
groups(default=...)、groupdict(default=...) で None の代わりに別の値を。
lastindex は最後に閉じたグループの番号、lastgroup はその名前。| でつないだ名前付きグループのどれがマッチしたかを教えてくれる。
m.pos、m.endpos、m.string、m.re。m.expand(r"\g<name>") でひな形にはめ込む。
新しい授業・講座のお知らせを受け取る
この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。
R
ReflexKR
触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。
ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する
アフィリエイトと支援について© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
質問と回答
この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。