Expresiones regulares en Python — práctica con el log de un servidor: filtrar errores, contar intentos de acceso y convertirlo a CSV (y la IP que llamaba tres veces por segundo)
El técnico del laboratorio nos pasó el log de una mañana del campus virtual con una pregunta: «¿alguien está intentando entrar probando contraseñas?». Un inicio de sesión fallido deja en el log una petición a /campus/login con el código 401 (no autorizado), así que la pregunta se traduce a «¿desde qué IPs hay 401?». Con lo aprendido en la teoría, se responde con una sola línea:
import re LOG = """\ 10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131" 10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129" 172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17" 10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131" 192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5" 10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129" -- reinicio del servidor -- 10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129" """ # ¿Desde qué IP fallan los inicios de sesión (código 401)? Una sola línea: print(re.findall(r'(\S+) .*" 401 ', LOG))
Al ejecutarlo:
['10.0.0.23', '10.0.0.23', '192.168.1.66', '192.168.1.66', '192.168.1.66']
El patrón (\S+) .*" 401 se lee así: un trozo sin espacios al principio (la IP, que se guarda en el grupo), un espacio, lo que sea, y luego una comilla, un espacio, 401 y otro espacio. Como el patrón tiene un grupo, findall devuelve solo la IP de cada línea que encaja. Y como el punto no encaja con saltos de línea, .* no puede saltar de una línea a otra: cada coincidencia se queda dentro de su línea.
La respuesta tenía truco. 10.0.0.23 falla dos veces en 11 segundos y a la tercera entra: alguien que se equivocó al escribir. 192.168.1.66, en cambio, falla tres veces en tres segundos seguidos, y su navegador se llama curl: un programa, no una persona. Pero eso no lo dice la lista: lo dice el resto de la línea. Para verlo hay que trocear cada línea en sus campos, y eso es lo que vamos a hacer.
En esta práctica trabajamos con el log completo en tres programas: filtrar los errores comprobando que cada línea tiene el formato correcto, contar peticiones por código, por hora y por IP (para encontrar a nuestro curl), y convertir el log a CSV con re.sub, reordenando la fecha y anonimizando las IPs. Los tres son de nivel medio y se apoyan en la teoría de expresiones regulares; también usaremos diccionarios para contar, así que si te hace falta, repasa contenedores asociativos.
Tabla de Contenidos
Cómo usar este artículo
Cada programa está completo en un solo archivo. Cópialo, guárdalo con el nombre que se indica y lánzalo desde la terminal con python3 nombre.py. Debajo de cada uno tienes la salida real, un dibujo o una tabla con lo que pasa por dentro y una explicación por partes. Antes de mirar la salida, intenta predecir qué líneas del log encajarán con cada patrón: es exactamente lo que hay que entrenar con las expresiones regulares.
Conviene tener claro, de la teoría:
- La diferencia entre
match,searchyfullmatch, y que los tres devuelvenNonesi no encuentran nada. - Cómo se numeran los grupos (por su paréntesis de apertura) y qué devuelve
m.groups(). - Por qué
.*es avaricioso y qué hace[^"]*. - Que en el reemplazo de
re.sub,\1,\2… son el texto de cada grupo.
Los tres programas usan el mismo log, doce líneas de una mañana del campus virtual. Cada línea tiene esta forma: IP, [fecha hora], «MÉTODO ruta» entre comillas, código HTTP, bytes enviados y «navegador» entre comillas. La línea 11 no es una petición: es una marca que dejó el servidor al reiniciarse.
LOG = """\ 10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131" 10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129" 10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129" 172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17" 10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131" 192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5" 192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5" 10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129" -- reinicio del servidor -- 10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129" """
Ningún programa usa input() ni lee archivos. El log va escrito dentro del código, entre triples comillas, para que tu salida sea idéntica a la que ves aquí. Leerlo de un archivo de verdad es justo lo que aprenderemos en el siguiente tema. La barra invertida después de las comillas de apertura ("""\) evita que el texto empiece con una línea vacía.
Programa 1 · Filtrar los errores del log
filtrar.py
La idea
Queremos una tabla con las peticiones que dieron error (código 400 o mayor) y, además, la lista de líneas que no tienen el formato de una petición. Un mismo patrón resuelve las dos cosas: si una línea encaja entera con él, es una petición y sus grupos nos dan cada campo; si no encaja, es una línea rara.
Escribe el patrón pieza a pieza, copiando una línea del log y sustituyendo cada campo por su trozo de regex. Lo que no sea un campo (corchetes, comillas, espacios) se queda tal cual, escapado si hace falta.
El código
import re
LOG = """\
10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131"
10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129"
172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"
10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"
192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5"
10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"
-- reinicio del servidor --
10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129"
"""
# Una línea buena: IP [fecha hora] "MÉTODO ruta" código bytes "navegador"
PATRON = (r'(\d{1,3}(?:\.\d{1,3}){3}) ' # 1: IP
r'\[(\d\d/\d\d/\d{4}) (\d\d:\d\d:\d\d)\] ' # 2: fecha, 3: hora
r'"(GET|POST) (\S+)" ' # 4: método, 5: ruta
r'(\d{3}) (\d+) ' # 6: código, 7: bytes
r'"([^"]*)"') # 8: navegador
errores = 0
malas = []
print(f"{'Hora':<10}{'IP':<14}{'Código':>6} Petición")
for n, linea in enumerate(LOG.splitlines(), start=1):
m = re.fullmatch(PATRON, linea) # fullmatch: la línea ENTERA tiene que encajar
if m is None:
malas.append(n)
continue
ip, fecha, hora, metodo, ruta, codigo, tam, navegador = m.groups()
if int(codigo) >= 400: # 4xx: fallo del cliente; 5xx: fallo del servidor
errores += 1
print(f"{hora:<10}{ip:<14}{codigo:>6} {metodo} {ruta}")
print(f"Errores: {errores}. Líneas que no son peticiones: {malas}")
Al ejecutarlo:
Hora IP Código Petición 09:15:40 10.0.0.23 401 POST /campus/login 09:15:51 10.0.0.23 401 POST /campus/login 09:22:45 10.0.0.7 404 GET /fp1/tema9.pdf 09:31:08 192.168.1.66 401 POST /campus/login 09:31:09 192.168.1.66 401 POST /campus/login 09:31:10 192.168.1.66 401 POST /campus/login 10:02:33 10.0.0.41 500 GET /fp1/entregas Errores: 7. Líneas que no son peticiones: [11]
El patrón: ocho grupos, uno por campo
# Una línea buena: IP [fecha hora] "MÉTODO ruta" código bytes "navegador"
PATRON = (r'(\d{1,3}(?:\.\d{1,3}){3}) ' # 1: IP
r'\[(\d\d/\d\d/\d{4}) (\d\d:\d\d:\d\d)\] ' # 2: fecha, 3: hora
r'"(GET|POST) (\S+)" ' # 4: método, 5: ruta
r'(\d{3}) (\d+) ' # 6: código, 7: bytes
r'"([^"]*)"') # 8: navegador
Lo primero que llama la atención es que el patrón está partido en cinco líneas. Python junta automáticamente los strings que aparecen seguidos dentro de unos paréntesis: r'abc' r'def' es lo mismo que r'abcdef'. Así cada trozo cabe en una línea y lleva al lado un comentario con el número de sus grupos. Cada trozo, con su r delante.
Pieza a pieza:
- IP, grupo 1:
(\d{1,3}(?:\.\d{1,3}){3}). De 1 a 3 cifras, y luego tres veces «punto y de 1 a 3 cifras». La repetición va en un grupo sin captura(?:…), porque solo queremos repetir, no guardar; si fuera un grupo normal, desplazaría la numeración de todos los demás y además solo guardaría su última vuelta. - Fecha y hora, grupos 2 y 3:
\[(\d\d/\d\d/\d{4}) (\d\d:\d\d:\d\d)\]. Los corchetes del log son corchetes de verdad, así que se escapan (\[,\]); si no, el patrón los tomaría como el principio de un conjunto. - Método y ruta, grupos 4 y 5:
"(GET|POST) (\S+)". La alternativa va dentro de un grupo para que el|solo afecte aGETyPOST. La ruta es «uno o más caracteres que no son espacios». - Código y bytes, grupos 6 y 7:
(\d{3}) (\d+). El código siempre tiene 3 cifras; los bytes, las que sean (también0). - Navegador, grupo 8:
"([^"]*)". Aquí se ve por qué la teoría insistía en[^"]*: «todo lo que no sea una comilla» no puede pasarse de la comilla de cierre.
Escribir la ruta como "(GET|POST) (.*)" en lugar de (\S+). Con fullmatch funcionaría en este log, porque el resto del patrón obliga a retroceder, pero con search sobre una línea más larga el .* se llevaría trozos que no son la ruta. Describe cada campo con lo que puede contener.
El bucle: fullmatch valida y trocea a la vez
errores = 0
malas = []
print(f"{'Hora':<10}{'IP':<14}{'Código':>6} Petición")
for n, linea in enumerate(LOG.splitlines(), start=1):
m = re.fullmatch(PATRON, linea) # fullmatch: la línea ENTERA tiene que encajar
if m is None:
malas.append(n)
continue
ip, fecha, hora, metodo, ruta, codigo, tam, navegador = m.groups()
if int(codigo) >= 400: # 4xx: fallo del cliente; 5xx: fallo del servidor
errores += 1
print(f"{hora:<10}{ip:<14}{codigo:>6} {metodo} {ruta}")
enumerate(LOG.splitlines(), start=1)recorre las líneas numeradas desde 1, para que el número coincida con el que verías en un editor.- **
re.fullmatch** es la decisión clave. Consearch, una línea con basura delante o detrás encajaría igual; confullmatch, la línea entera tiene que tener la forma del patrón. La línea 11,-- reinicio del servidor --, no empieza por una IP, así quemvaleNone. if m is Noneguarda el número de la línea mala y **continue** salta directamente a la siguiente. Esto protege la línea de después:m.groups()sobreNonedaría elAttributeErrorde la teoría.m.groups()devuelve una tupla de 8 strings, que se reparten en 8 variables de una vez. Si el número de variables no coincidiera con el de grupos, Python daría unValueErroral desempaquetar: es una buena comprobación de que has contado bien los paréntesis.int(codigo) >= 400convierte el código antes de comparar. Los grupos son strings, y comparar"401" >= 400daría unTypeError.
Comprobemos la salida: de las 12 líneas, 11 son peticiones. Tienen código 400 o mayor cinco 401, un 404 y un 500: 5 + 1 + 1 = 7 errores, que son las 7 filas de la tabla. Las otras 4 peticiones son 200.
Las variables que no vas a usar (fecha, tam, navegador) se pueden recoger igualmente en el desempaquetado. Es más claro que acceder a m.group(3) o m.group(5) por número, y el día que te hagan falta ya tienen nombre.
Pruébalo tú
- Añade al log una línea con el método
PUT. ¿Aparece en la tabla o en la lista de líneas malas? Cambia el patrón para aceptarGET,POST,PUTyDELETE. - Cambia
fullmatchporsearchy añade una línea con texto extra al final (por ejemplo,# revisar). ¿Qué cambia en la salida? ¿Por qué no se detecta?
Programa 2 · Contar peticiones y detectar intentos de acceso
contar.py
La idea
Ahora queremos números: cuántas peticiones hay de cada código, cuántas en cada hora (con una barra de # para verlo de un vistazo), cuántos bytes ha servido el servidor y cuántos inicios de sesión fallidos tiene cada IP. Si una IP llega a 3, la marcamos como sospechosa.
Para contar, un diccionario con get(clave, 0) + 1. Para sacar varios campos de una línea, un patrón con grupos solo en los campos que te interesan; el resto se puede describir con .* si no hay riesgo de que se pase.
El código
import re
LOG = """\
10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131"
10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129"
172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"
10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"
192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5"
10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"
-- reinicio del servidor --
10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129"
"""
codigos = re.findall(r'" (\d{3}) ', LOG) # todos los códigos de una vez
por_codigo = {}
for c in codigos:
por_codigo = por_codigo.get(c, 0) + 1
print("Peticiones por código:", dict(sorted(por_codigo.items())))
por_hora = {}
fallos_login = {}
total_bytes = 0
for linea in LOG.splitlines():
m = re.match(r'(\S+) \[\d\d/\d\d/\d{4} (\d\d):.*" (\d{3}) (\d+) ', linea)
if not m:
continue # la línea del reinicio no es una petición
ip, hora, codigo, tam = m.groups()
por_hora[hora] = por_hora.get(hora, 0) + 1
total_bytes += int(tam)
if codigo == "401":
fallos_login[ip] = fallos_login.get(ip, 0) + 1
for hora, n in sorted(por_hora.items()):
print(f"{hora}:00-{hora}:59 {'#' * n} {n}")
print(f"Bytes servidos: {total_bytes} ({total_bytes / 1024:.1f} KB)")
for ip, n in fallos_login.items():
aviso = " <- sospechosa" if n >= 3 else ""
print(f"{ip:<14}{n} inicios de sesión fallidos{aviso}")
Al ejecutarlo:
Peticiones por código: {'200': 4, '401': 5, '404': 1, '500': 1}
09:00-09:59 ######### 9
10:00-10:59 ## 2
Bytes servidos: 24041 (23.5 KB)
10.0.0.23 2 inicios de sesión fallidos
192.168.1.66 3 inicios de sesión fallidos <- sospechosa
Todos los códigos de una vez con findall
codigos = re.findall(r'" (\d{3}) ', LOG) # todos los códigos de una vez
por_codigo = {}
for c in codigos:
por_codigo = por_codigo.get(c, 0) + 1
print("Peticiones por código:", dict(sorted(por_codigo.items())))
re.findall(r'" (\d{3}) ', LOG)se aplica al log entero, no línea a línea. Busca «comilla de cierre, espacio, tres cifras, espacio», que en este formato solo aparece justo antes del código HTTP. Como hay un grupo, la lista tiene solo las cifras: 11 códigos, uno por petición.- El contexto es lo que hace fiable el patrón. Un
\d{3}suelto encontraría también trozos de la IP, de la fecha o de los bytes; con la comilla delante, solo el código. dict(sorted(por_codigo.items()))ordena el diccionario por clave para que la salida salga siempre en el mismo orden: 4 de200, 5 de401, 1 de404y 1 de500. Suman 4 + 5 + 1 + 1 = 11.
Un patrón con solo los grupos necesarios
for linea in LOG.splitlines():
m = re.match(r'(\S+) \[\d\d/\d\d/\d{4} (\d\d):.*" (\d{3}) (\d+) ', linea)
if not m:
continue # la línea del reinicio no es una petición
ip, hora, codigo, tam = m.groups()
por_hora[hora] = por_hora.get(hora, 0) + 1
total_bytes += int(tam)
if codigo == "401":
fallos_login[ip] = fallos_login.get(ip, 0) + 1
El patrón de esta parte es más corto que el del programa 1, porque solo necesitamos cuatro campos:
| Trozo | Grupo | Qué captura |
|---|---|---|
(\S+) | 1 | La IP: lo primero de la línea, hasta el primer espacio |
\[\d\d/\d\d/\d{4} | La fecha, que se comprueba pero no se guarda | |
(\d\d): | 2 | Solo la hora (sin minutos ni segundos) |
.* | Lo que sea: minutos, segundos, método y ruta | |
" (\d{3}) (\d+) | 3 y 4 | El código y los bytes, detrás de la comilla |
- Se usa **
re.matchporque la IP está al principio** de la línea: aquí es justo lo que queremos. - El
.*es avaricioso: se come la línea hasta el final y retrocede hasta encontrar" ddd ddd. En este formato solo hay un sitio donde encaja, así que el resultado es correcto. Si el navegador pudiera contener algo como" 200 5, el avaricioso se iría a esa última aparición: por eso la pista pedía usarlo solo «si no hay riesgo de que se pase». - La línea del reinicio no encaja,
mvaleNoneycontinuela salta. - Cada contador es un diccionario distinto:
por_horacuenta todas las peticiones de cada hora yfallos_loginsolo las de código"401". Fíjate en que se compara con el string"401", porquecodigoes un grupo y no se ha convertido.
El informe
for hora, n in sorted(por_hora.items()):
print(f"{hora}:00-{hora}:59 {'#' * n} {n}")
print(f"Bytes servidos: {total_bytes} ({total_bytes / 1024:.1f} KB)")
for ip, n in fallos_login.items():
aviso = " <- sospechosa" if n >= 3 else ""
print(f"{ip:<14}{n} inicios de sesión fallidos{aviso}")
'#' * nrepite el carácter tantas veces como peticiones: 9 entre las 9:00 y las 9:59, y 2 entre las 10:00 y las 10:59. Una gráfica de barras de una línea.- Los bytes suman 24041. Compruébalo con la columna de bytes del log: 5120 + 312 + 312 + 1024 + 14040 + 0 + 312 + 312 + 312 + 87 + 2210 = 24041. Divididos entre 1024 dan 23,48, que con
.1fse escribe23.5. 10.0.0.23tiene 2 fallos y192.168.1.66tiene 3, que es el mínimo para el aviso. El diccionario recuerda el orden en que se añadió cada IP, y por eso10.0.0.23sale primero.
Un detector de verdad tendría en cuenta también el tiempo: tres fallos en tres segundos no son lo mismo que tres fallos en una semana. Las horas de nuestras tres peticiones (09:31:08, 09:31:09 y 09:31:10) ya están en el log; calcular la diferencia entre ellas es un buen «Pruébalo tú».
Pruébalo tú
- Añade un contador de peticiones por ruta (
/campus/login,/fp1/entregas…). Tendrás que añadir un grupo al patrón; vigila que los números de los grupos que ya usas no cambien… o usam.groups()y reparte en una variable más. - Haz que el aviso de IP sospechosa salga también si la IP tiene algún 401 y su navegador contiene
curl.
Programa 3 · Convertir el log a CSV con re.sub
reformatear.py
La idea
Para abrir el log en una hoja de cálculo, queremos convertir cada petición en una línea CSV (campos separados por ;) con este orden: fecha y hora en formato ISO (2026-10-12T09:15:02), la IP anonimizada (con el último número a 0, porque una IP completa es un dato personal), el método, la ruta y el código. Las líneas que no sean peticiones se descartan y se cuentan.
re.sub con una plantilla puede hacer todo el trabajo de golpe: un patrón que capture cada trozo que necesitas, en el orden en que aparece en el log, y una plantilla que los escriba en el orden en que los quieres. Y re.subn te dice además si ha habido sustitución.
El código
import re
LOG = """\
10.0.0.7 [12/10/2026 09:15:02] "GET /campus/inicio" 200 5120 "Firefox/131"
10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:15:51] "POST /campus/login" 401 312 "Chrome/129"
10.0.0.23 [12/10/2026 09:16:03] "POST /campus/login" 200 1024 "Chrome/129"
172.16.4.150 [12/10/2026 09:20:17] "GET /fp1/tema8.pdf" 200 14040 "Safari/17"
10.0.0.7 [12/10/2026 09:22:45] "GET /fp1/tema9.pdf" 404 0 "Firefox/131"
192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:09] "POST /campus/login" 401 312 "curl/8.5"
192.168.1.66 [12/10/2026 09:31:10] "POST /campus/login" 401 312 "curl/8.5"
10.0.0.41 [12/10/2026 10:02:33] "GET /fp1/entregas" 500 87 "Chrome/129"
-- reinicio del servidor --
10.0.0.41 [12/10/2026 10:05:12] "GET /fp1/entregas" 200 2210 "Chrome/129"
"""
PATRON = (r'(\d+\.\d+\.\d+)\.\d+ ' # 1: IP sin su último número
r'\[(\d\d)/(\d\d)/(\d{4}) (\S+)\] ' # 2: día, 3: mes, 4: año, 5: hora
r'"(\w+) (\S+)" (\d{3}) .*') # 6: método, 7: ruta, 8: código; .* el resto
PLANTILLA = r"\4-\3-\2T\5;\1.0;\6;\7;\8" # el orden lo decide la plantilla
print("fecha_hora;ip;metodo;ruta;codigo")
descartadas = 0
for linea in LOG.splitlines():
nueva, n = re.subn(PATRON, PLANTILLA, linea) # n vale 1 si la línea encajó, 0 si no
if n == 0:
descartadas += 1
else:
print(nueva)
print(f"# {descartadas} línea(s) descartada(s)")
Al ejecutarlo:
fecha_hora;ip;metodo;ruta;codigo 2026-10-12T09:15:02;10.0.0.0;GET;/campus/inicio;200 2026-10-12T09:15:40;10.0.0.0;POST;/campus/login;401 2026-10-12T09:15:51;10.0.0.0;POST;/campus/login;401 2026-10-12T09:16:03;10.0.0.0;POST;/campus/login;200 2026-10-12T09:20:17;172.16.4.0;GET;/fp1/tema8.pdf;200 2026-10-12T09:22:45;10.0.0.0;GET;/fp1/tema9.pdf;404 2026-10-12T09:31:08;192.168.1.0;POST;/campus/login;401 2026-10-12T09:31:09;192.168.1.0;POST;/campus/login;401 2026-10-12T09:31:10;192.168.1.0;POST;/campus/login;401 2026-10-12T10:02:33;10.0.0.0;GET;/fp1/entregas;500 2026-10-12T10:05:12;10.0.0.0;GET;/fp1/entregas;200 # 1 línea(s) descartada(s)
El patrón y la plantilla
PATRON = (r'(\d+\.\d+\.\d+)\.\d+ ' # 1: IP sin su último número
r'\[(\d\d)/(\d\d)/(\d{4}) (\S+)\] ' # 2: día, 3: mes, 4: año, 5: hora
r'"(\w+) (\S+)" (\d{3}) .*') # 6: método, 7: ruta, 8: código; .* el resto
PLANTILLA = r"\4-\3-\2T\5;\1.0;\6;\7;\8" # el orden lo decide la plantilla
Este patrón captura exactamente lo que va a salir y nada más:
- Grupo 1,
(\d+\.\d+\.\d+), son los tres primeros números de la IP con sus puntos. El cuarto número se describe fuera del grupo (\.\d+): tiene que estar, pero no se guarda, y así desaparece del resultado. - Grupos 2, 3 y 4 son el día, el mes y el año por separado, porque hay que reordenarlos.
- Grupo 5 es la hora, con
(\S+): todo hasta el siguiente espacio, que está después del corchete de cierre. El avaricioso se come también el]y luego lo devuelve, para que encaje el\]del patrón. - Grupos 6, 7 y 8 son el método, la ruta y el código. El método se describe esta vez con
\w+, que acepta cualquier palabra. - El **
.*final** encaja con el resto de la línea (bytes y navegador). Es imprescindible:re.subsolo reemplaza lo que encaja, y sin él los bytes y el navegador se quedarían pegados detrás del CSV.
La plantilla r"\4-\3-\2T\5;\1.0;\6;\7;\8" decide el orden de salida:
Comprobémoslo con la línea 2 del log, la del primer 401. Los grupos valen 10.0.0, 12, 10, 2026, 09:15:40, POST, /campus/login y 401. La plantilla escribe el 4 (2026), un guion, el 3 (10), un guion, el 2 (12), una T, el 5 (09:15:40), un ;, el 1 (10.0.0) seguido de .0;, y luego el 6, el 7 y el 8 separados por ;. Resultado: 2026-10-12T09:15:40;10.0.0.0;POST;/campus/login;401, que es la segunda línea de la salida.
Escribir la plantilla sin la r delante: "\4-\3-\2T…". Python convierte \4, \3… en caracteres de control antes de que lleguen a re, y la salida se llena de caracteres invisibles sin ningún error. Las plantillas de sub, igual que los patrones, siempre con r.
El bucle: subn para saber si encajó
print("fecha_hora;ip;metodo;ruta;codigo")
descartadas = 0
for linea in LOG.splitlines():
nueva, n = re.subn(PATRON, PLANTILLA, linea) # n vale 1 si la línea encajó, 0 si no
if n == 0:
descartadas += 1
else:
print(nueva)
print(f"# {descartadas} línea(s) descartada(s)")
re.subndevuelve la línea nueva y el número de sustituciones. Como el patrón describe la línea completa, solo puede encajar una vez:nvale 1 si la línea era una petición y 0 si no.- Cuando no encaja,
subdevuelve la línea original sin cambios. Por eso no basta con imprimirnueva: la línea del reinicio saldría tal cual, en medio del CSV.n == 0es la forma limpia de detectarlo. - Fíjate en la cabecera:
fecha_hora;ip;metodo;ruta;codigosin tildes ni espacios. Los nombres de columna así dan menos problemas al importar el CSV en otros programas.
Al convertir la fecha al formato año-mes-día, las líneas se pueden ordenar como texto y quedan en orden cronológico: "2026-10-12" va antes que "2026-11-01" tanto alfabéticamente como en el calendario. Con día/mes/año no pasa: "12/10" iría detrás de "01/11". Lo aprovecharemos en el último ejercicio.
Pruébalo tú
- Añade los bytes como última columna del CSV. Tendrás que convertir una parte del
.*final en un grupo; ¿qué número de grupo le toca? - Cambia la anonimización para que se quede solo con los dos primeros números (
10.0.0.23→10.0.0.0,172.16.4.150→172.16.0.0).
Errores típicos en esta práctica
- No escapar los corchetes del log.
[\d\d/…]es un conjunto, no un corchete: el patrón cambia por completo de significado. Los corchetes literales,\[y\]. - **Pedir
groups()sin comprobarNone.** Cualquier línea que no encaje (como la del reinicio) daAttributeError. Primeroif m is None: continue. - Contar mal los grupos. Si añades un paréntesis, todos los números de detrás cambian. Usa
(?:…)para los paréntesis que solo agrupan, y comenta el número de cada grupo. - Comparar grupos con números. Los grupos son strings:
int(codigo) >= 400, ocodigo == "401"entre comillas. - **Usar
searchpara validar.** Una línea con basura delante o detrás encaja igual. Para «la línea tiene este formato»,fullmatch. - **Olvidar que
subsolo reemplaza lo que encaja.** Si el patrón no cubre toda la línea, lo que quede fuera se copia tal cual al resultado.
Míralo paso a paso en Python Tutor
Python Tutor ejecuta el código línea a línea y muestra el valor de cada variable después de cada paso. Elige Python 3 y pega esta versión mínima del programa 3, con una sola línea:
import re
linea = '192.168.1.66 [12/10/2026 09:31:08] "POST /campus/login" 401 312 "curl/8.5"'
patron = r'(\S+) \[(\d\d)/(\d\d)/(\d{4}) (\S+)\]'
m = re.search(patron, linea)
grupos = m.groups()
iso = re.sub(patron, r"\4-\3-\2T\5 \1", linea)
print(grupos)
print(iso)
Salida esperada:
('192.168.1.66', '12', '10', '2026', '09:31:08')
2026-10-12T09:31:08 192.168.1.66 "POST /campus/login" 401 312 "curl/8.5"
Qué observar, paso a paso:
- Después de la línea 5,
maparece como un objetore.Match. Python Tutor no dibuja sus grupos por dentro: para verlos hay que pedírselos. - En la línea 6,
gruposse dibuja como una tupla de cinco strings: la IP, el día, el mes, el año y la hora. Todos entre comillas, también los números. - En la línea 7,
isoes un string nuevo. Compáralo conlinea, que sigue igual:re.subno cambia nada, devuelve otro texto. - Fíjate en
iso: el principio de la línea ha cambiado (2026-10-12T09:31:08 192.168.1.66) pero el resto, desde la comilla, sigue igual. El patrón de este ejemplo **no tiene.*al final**, así que solo se ha reemplazado la parte que encajaba, y lo de detrás se ha copiado tal cual. Es el último error típico de la lista, en vivo. - Añade
.*al final del patrón y vuelve a ejecutar: ahora el resto de la línea desaparece del resultado.
Resumen
- Un patrón por formato de línea: copia una línea real y sustituye cada campo por su trozo de regex. Corchetes y otros metacaracteres del log, escapados.
- **
fullmatchvalida y trocea a la vez:** si devuelveNone, la línea no tiene el formato; si no,m.groups()reparte los campos en variables. - Grupos solo donde hace falta:
(?:…)para repetir sin capturar, y un comentario con el número de cada grupo. - **
findallsobre el texto entero** es la forma más rápida de sacar un campo de todas las líneas, siempre que el patrón tenga contexto suficiente para no confundirlo con otro. - Los grupos son strings: convierte con
int()antes de comparar u operar. - **
re.subcon plantilla reordena campos;**re.subndice además si la línea encajó. La plantilla, conrdelante.
Checklist: ¿lo has practicado?
- ¿Has ejecutado los tres programas y comprobado las salidas?
- ¿Sabrías explicar por qué el programa 1 usa
fullmatchy el 2 usamatch? - ¿Puedes numerar a mano los ocho grupos del patrón del programa 1 sin mirar el dibujo?
- ¿Sabes qué pasa con la línea del reinicio en cada uno de los tres programas?
- ¿Podrías añadir una columna al CSV del programa 3 sin que se descoloquen los números de los grupos?
- ¿Has intentado los «Pruébalo tú» sin mirar la teoría?
Si has respondido que sí a casi todo, estás listo para los ejercicios de expresiones regulares, donde ya no te doy el código hecho: validar campos con fullmatch, encontrar DNIs escritos de cualquier manera en un informe de incidencias, unificar dos logs con formatos de fecha distintos y tres fragmentos con fallos muy típicos para cazar. ¡Nos vemos allí!
¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Un comentario