R

ReflexKR

Documentación de Reflex
Expresiones regulares a la vista
Lección 6

Tiempo 4:10–5:00

El acceso de las 3 de la madrugada: grupos con nombre y objetos Match

CASE-006

Grep & Co. Agencia de Detectives de Datos

Expediente

El acceso de las 3 de la madrugada

Cliente

La señora Jeong, dueña de la tienda en línea «Mercado Jeong»

Pruebas

El log de accesos del servidor web de Mercado Jeong. Es el registro de la misma madrugada en que llovieron los registros falsos.

Para cerrar el caso

Dividir cada línea del log en campos con nombre (un dict) y encontrar la IP que entró en la página de administración (/admin) a las 3 de la madrugada.

La señora Jeong ha vuelto, y esta vez se ha traído el portátil entero. «Las cuentas falsas llegaron de madrugada, ¿no? Pues me dicen que a esa hora alguien abrió el panel de administración. Y yo a esa hora estaba durmiendo».

La directora echa un vistazo a la pantalla. «Es un log de servidor web. Cada línea tiene unos siete campos. Si los llamas por número, te vas a liar. Ponles nombre».

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

De izquierda a derecha, cada línea tiene: la IP que se conectó, dos campos vacíos (-), la hora entre corchetes, la petición entre comillas (método, ruta y protocolo), el código de respuesta y el número de bytes enviados.

6-1. Etiquetas con nombre en vez de números: (?P...) (10 min)

En la lección 5 llamábamos a los grupos por su número: group(1), group(2). Cuando pasas de siete grupos, ya no hay quien se acuerde de qué número es cada cosa. Para ponerle nombre a un grupo, escribe ?P<nombre> justo después del paréntesis de apertura.

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") y m["ip"] son lo mismo. Si le pasas varios nombres, devuelve una tupla.
  • groupdict() reúne solo los grupos con nombre y los devuelve como diccionario {nombre: texto}. Cada línea del log se convierte en un diccionario.
  • Los nombres siguen las reglas de los identificadores de Python. Si empiezan por un número o llevan un -, da error.

Los grupos con nombre también conservan su número. Se numeran igual que en la lección 5, por el orden de los paréntesis de apertura. El nombre no es más que un alias encima del número.

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')

Laboratorio de pruebas

re.search(r"", text)

Valor devuelto

Cambia a finditer y mira m.group("ip") y groupdict() en la casilla de la primera coincidencia. Si cambias (?P<time>...) por un simple (...), ¿qué desaparece del diccionario?

El consejo de Dot

Si has visto en otros lenguajes que se escribe (?<name>...), ojo. En Python, si quitas la P, salta el error "unknown extension ?<n". Y si le pones el mismo nombre a dos grupos, error "redefinition of group name". Yo también tengo un solo nombre.

6-2. Una línea de log entera, convertida en diccionario (10 min)

Ahora vamos a ponerle nombre a todos los campos de la línea. Los espacios, las comillas y los corchetes entre campos se escriben en el patrón tal cual.

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'}
  • Como el patrón lleva comillas dobles, la cadena raw va entre comillas simples: r'...'.
  • [^\]]+ son «caracteres que no son un corchete de cierre», y [^"]*, «caracteres que no son comillas». Las clases negadas de la lección 2 marcan dónde acaba cada campo.
  • Toda la línea tiene que tener esta forma, así que usamos fullmatch, de la lección 4. Una línea mal formada da None y se descubre al instante.

Una vez convertido en diccionario, lo que sigue es Python normal y corriente. Supongamos que las seis líneas del log de arriba están en una cadena llamada 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

Todos los valores son cadenas. Para sumar los bytes, tienes que convertirlos tú, con int(row["size"]).

Laboratorio de pruebas

re.search(r"", text)

Valor devuelto

El patrón se queda en la ruta de la petición (path). Añade el protocolo, el código de respuesta (status) y el número de bytes (size) para que la línea entera pase con fullmatch. La última línea, cortada por el final, no debe pasar.

6-3. Dónde estaba: start, end y span (6 min)

Además de qué encontró, el objeto Match te dice, grupo a grupo, dónde lo encontró. Si le pasas el número o el nombre de un grupo, te da la posición de ese grupo.

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

Un grupo opcional puede no coincidir. Es lo que pasa con la cadena de consulta que va detrás de la ruta, como ?page=2. Un grupo que no coincidió tiene como texto None y como posición -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)

Cuando se cuela un None, es fácil que el procesamiento de cadenas falle. groups() y groupdict() aceptan el argumento default, con el valor que se pondrá en lugar de None.

print(a.groups())                  # ('/admin', None)
print(a.groups(default=""))        # ('/admin', '')
print(a.groupdict(default="-"))    # {'path': '/admin', 'query': '-'}

Laboratorio de pruebas

re.search(r"", text)

Valor devuelto

Con finditer separas la ruta y la cadena de consulta de cada línea. Comprueba en la casilla de la primera coincidencia que query es None, y luego borra las dos primeras líneas del texto para ver si se atrapa page=2. Si quitas el [A-Z]+ del principio, el /1.1 de HTTP/1.1 también se toma por una ruta.

6-4. El resto de atributos del objeto Match (6 min)

No se usan a menudo, pero vienen muy bien para depurar.

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 es el número del último grupo que se cerró, y lastgroup, su nombre. Si no tiene nombre, o no coincidió ningún grupo, valen None.
  • Si los grupos están anidados, el paréntesis de fuera se cierra el último. Por eso el lastindex de ((\d+)\.(\d+)) no es 3, sino 1.
  • lastgroup te dice qué rama coincidió cuando unes grupos con nombre con |, como en (?P<a>x)|(?P<b>y). En la lección 12 usaremos esta propiedad para construir un tokenizador.
  • pos y endpos son el rango de búsqueda. Con las funciones del módulo, siempre valen 0 y la longitud de la cadena. Cómo indicar tú el rango lo verás con re.compile, en la lección 11.
  • string es la cadena original donde se buscó, y re, el objeto patrón que se usó.
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

Los objetos Match son inmutables, así que puedes hacer copy.copy(m) o copy.deepcopy(m) sin problema. El resultado es el mismo objeto.

Por último, expand(template) mete el resultado de la coincidencia en una plantilla y crea una cadena nueva. Cada \g<nombre> de la plantilla se sustituye por el texto de ese grupo. Es exactamente la misma sintaxis que las cadenas de reemplazo de la lección 7. (En el ejemplo, 요청 significa «petición»).

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

6-5. La página de administración a las 3 de la madrugada (8 min)

Ahora metemos en el patrón las condiciones del caso. En el campo de la hora, [20/Oct/2026:03:02:11 +0900], los dos dígitos que siguen al primer : después de la fecha son la hora. Con \[[^:]+: te saltas la fecha, y si escribes 03 en esa posición, ya tienes la condición «a las 3 de la madrugada».

  • Si buscas simplemente 03 o :03:, también caen las líneas cuyos minutos o segundos son 03. Lo importante es clavar la posición: de qué campo es ese 03.
  • La ruta se escribe /admin\S* para atrapar todas las páginas que empiezan por /admin.

Laboratorio de pruebas

re.search(r"", text)

Valor devuelto

El patrón actual atrapa también el acceso de las 9 de la mañana, que es de la propia señora Jeong. Deja solo los accesos a /admin cuya hora sea 03. Mejor aún si capturas ip y hour con grupos con nombre.

Una posible solución es esta.

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

Caso resuelto (10 min)

Dividimos la hora en día, mes, año, hora, minutos y segundos, les ponemos nombre a todos y, línea a línea, los sacamos como de un diccionario para aplicar las condiciones. Gracias a los grupos con nombre, la condición se lee casi como una frase.

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'}

A las 3:02 de la madrugada, 203.0.113.66 abrió el panel de administración y, ocho segundos después, descargó hasta la página 2 de la lista de clientes. El 198.51.100.4 de las 9 de la mañana era la IP de la oficina de la señora Jeong. Ella se queda blanca. «¿La lista de clientes? Entonces los datos de los clientes...».

Informe de caso cerrado — El 20 de octubre a las 03:02, la IP externa 203.0.113.66 accedió a la página de administración y consultó la lista de clientes. Se recomienda cambiar de inmediato la contraseña de administración y restringir las IP con acceso. Posible filtración de datos de clientes.

Ejercicios

  1. Lee el log con LINE_RE y suma el size de todas las líneas cuyo código de respuesta (status) sea 200. No te olvides de int().
  2. Busca "2026-10-20" con los grupos con nombre (?P<y>\d{4})-(?P<m>\d\d)-(?P<d>\d\d) y adivina qué devuelve m.expand(r"\g<d>/\g<m>/\g<y>").
  3. Adivina qué dan span("b"), groupdict(default="?") y lastgroup en re.search(r"(?P<a>\d)(?P<b>x)?", "7"), y compruébalo.

La libreta de la directora

  • (?P<name>...) le pone nombre a un grupo. m.group("name"), m["name"], m.groupdict().

  • Los grupos con nombre también tienen número, por el orden de los paréntesis de apertura.

  • start(g), end(g), span(g). Un grupo que no coincide es None y su posición, -1.

  • groups(default=...) y groupdict(default=...) ponen otro valor en lugar de None.

  • lastindex es el número del último grupo que se cerró; lastgroup, su nombre. Dice qué rama coincidió entre grupos con nombre unidos con |.

  • m.pos, m.endpos, m.string, m.re. m.expand(r"\g<name>") rellena una plantilla.

← Anterior

Lección 5 · Fechas por todas partes: grupos y alternativas

Siguiente →

Lección 7 · La víspera de la noticia: sustitución y división

Recibe avisos de nuevas lecciones

Te avisaremos por correo cuando este curso tenga lecciones nuevas o se abra un curso de continuación.

Datos: tu correo electrónico · Finalidad: avisos de nuevas lecciones o de la publicación de este curso · Conservación: hasta que pidas que lo borremos. No lo usamos para nada más ni lo compartimos. Responde a cualquier aviso y lo borraremos enseguida.

Preguntas y respuestas

Pregunta lo que quieras sobre esta lección. Aquí se reúnen las preguntas de todas las versiones de idioma de esta página.

Se muestra como texto plano, sin enlaces ni formato.

R

ReflexKR

Aprende Python y Reflex haciendo. Todos los ejemplos se ejecutaron y verificaron en Reflex 0.10.0.

ReflexKR es gratis y sin banners publicitarios. Si te ayudó, puedes apoyarlo con un café. ☕ Invítanos un café

Aviso de afiliados
한국어English日本語简体中文EspañolDeutsch

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

Built with Reflex