R

ReflexKR

Reflex公式ドキュメント
目で見て覚える正規表現
6限目

時間 4:10–5:00

午前3時のアクセス:名前付きグループとMatchオブジェクト

CASE-006

グレップ・アンド・コー データ探偵事務所

依頼書

午前3時のアクセス

依頼人

ネットショップ「マーケット・チョン」運営者のチョン社長

証拠品

マーケット・チョンのウェブサーバーのアクセスログ。偽の会員登録が押し寄せた、あの日の明け方の記録だ。

解決条件

ログ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 2210

1行は左から順に、アクセスしてきたIP、空欄2つ(-)、角かっこの中の時刻、引用符の中のリクエスト(メソッド・パス・プロトコル)、レスポンスコード、送信バイト数です。

6-1. 番号の代わりに名札を:(?P...) (10分)

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つになります。
  • 名前はPythonの識別子のルールに従います。数字で始まったり - が入ったりするとエラーです。

名前付きグループも 番号をそのまま持っています。 番号は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)

戻り値

finditerに切り替えて、「最初のマッチ」欄の m.group("ip") と groupdict() を見てみましょう。(?P<time>...) をただの (...) に変えると、辞書から何が消えるでしょうか。

ドットのひとこと

ほかの言語で (?<name>...) と書くのを見たことがあるなら要注意ですよ。Pythonでは P を抜かすと "unknown extension ?<n" エラーになっちゃいます。それから、同じ名前を2回付けると "redefinition of group name" エラーです。ぼくの名前だって、ひとつだけですからね。

6-2. ログ1行を丸ごと辞書に (10分)

いよいよ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'}
  • パターンの中にダブルクォートがあるので、raw文字列をシングルクォート r'...' で囲みました。
  • [^\]]+ は「閉じ角かっこではない文字の並び」、[^"]* は「引用符ではない文字の並び」です。2限目の否定クラスが、欄の境目を押さえてくれます。
  • 行全体がこの形でなければならないので、4限目で学んだ 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)

戻り値

パターンがリクエストのパス(path)で終わっています。プロトコル、レスポンスコード(status)、バイト数(size)までつなげて、行全体がfullmatchするようにしましょう。末尾が切れた最後の行は通ってはいけません。

6-3. どこにあったか:start、end、span (6分)

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)

戻り値

finditerで各行のパスとクエリ文字列を分けます。「最初のマッチ」欄で query が None なのを確かめたら、テキストの最初の2行を消して page=2 が捕まるか見てみましょう。先頭の [A-Z]+ を消すと、HTTP/1.1 の /1.1 までパスとして捕まってしまいます。

6-4. Matchオブジェクトのその他の属性 (6分)

よく使うわけではありませんが、知っておくとデバッグのときに重宝する属性です。

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 b

Matchオブジェクトは変更されない値なので、copy.copy(m) や copy.deepcopy(m) をしてもかまいません。結果は同じオブジェクトそのものです。

最後に、expand(template) はマッチ結果をひな形にはめ込んで、新しい文字列を作ります。ひな形の中の \g<名前> が、そのグループの文字に置き換わります。7限目の置換文字列とまったく同じ文法です。下の例の 요청 は韓国語で「リクエスト」という意味です。

print(m.expand(r"\g<method> 요청: \g<path>"))   # GET 요청: /admin

6-5. 午前3時の管理者ページ (8分)

では、事件の条件をパターンに入れましょう。時刻の欄 [20/Oct/2026:03:02:11 +0900] では、日付の後ろの最初の : に続く2桁が「時」です。\[[^:]+: で日付を読み飛ばし、その位置に 03 と書けば「午前3時」という条件になります。

  • ただ 03 や :03: で探すと、分や秒が03の行まで引っかかります。どの欄の03なのか、位置を固定すること が大切です。
  • パスは、/admin で始まるすべてのページを捕まえるように /admin\S* と書きます。

証拠分析室

re.search(r"", text)

戻り値

今のパターンは、朝9時に入ってきたチョン社長本人のアクセスまで捕まえてしまいます。時刻欄の「時」が03の /admin アクセスだけを残しましょう。名前付きグループで ip と hour を一緒に捕まえられれば、なおよしです。

正解の一例はこうです。

(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)

事件解決 (10分)

時刻を日・月・年・時・分・秒にすべて分けて名前を付け、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の制限を勧告。会員情報流出の可能性あり。

練習問題

  1. LINE_RE でログを読み、レスポンスコード(status)が 200 の行の size をすべて足してみましょう。int() を忘れずに。
  2. 名前付きグループ (?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d) で "2026-10-20" を見つけたあと、m.expand(r"\g<d>/\g<m>/\g<y>") の結果を予想してみましょう。
  3. 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>") でひな形にはめ込む。

← 前へ

5限目 · バラバラな日付:グループと選択

次へ →

7限目 · 報道前夜:置換と分割

新しい授業・講座のお知らせを受け取る

この講座に授業が追加されたり、続きの講座が公開されたりしたらメールでお知らせします。

収集項目:メールアドレス・目的:この講座の新しい授業・公開のお知らせ・保有期間:解除のご依頼まで。他の目的には使わず、第三者にも提供しません。お知らせメールに返信いただければすぐに削除します。

質問と回答

この回について気になることを書き込んでください。すべての言語のページの質問がここに集まります。

リンクや書式なしで、そのままの文字で表示されます。

R

ReflexKR

触って学ぶPythonとReflex。すべての例はReflex 0.10.0で実際に動かして確認しています。

ReflexKRはバナー広告なし・無料で運営しています。役に立ったら、コーヒー一杯で応援してください。 ☕ コーヒーで応援する

アフィリエイトと支援について
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex