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.

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, search y fullmatch, y que los tres devuelven None si 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.

Los 8 grupos de una línea del logLa línea 10.0.0.23 [12/10/2026 09:15:40] "POST /campus/login" 401 312 "Chrome/129" partida en piezas: ocho de ellas son grupos numerados del 1 al 8 (IP, fecha, hora, método, ruta, código, bytes y navegador) y el resto son corchetes, comillas y espacios. Debajo, la tabla con cada trozo del patrón, su variable y su valor.Los 8 grupos de una línea del log10.0.0.23␣[12/10/2026␣09:15:40]␣"123POST␣/campus/login"␣401␣312␣"Chrome/129"45678n.ºtrozo del patrónvariablevalor1(\d{1,3}(?:\.\d{1,3}){3})ip10.0.0.232(\d\d/\d\d/\d{4})fecha12/10/20263(\d\d:\d\d:\d\d)hora09:15:404(GET|POST)metodoPOST5(\S+)ruta/campus/login6(\d{3})codigo4017(\d+)tam3128([^"]*)navegadorChrome/129
Lo que no va entre paréntesis (corchetes, comillas, espacios) tiene que estar, pero no se guarda.

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 a GET y POST. 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én 0).
  • 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}")
Del log a la tabla de errores (p1)Diagrama de flujo de p1: cada una de las 12 líneas del log pasa por re.fullmatch. Si da None, su número se guarda en malas (solo la 11). Si encaja, m.groups() reparte los 8 grupos en variables y, si el código es 400 o más, la línea se imprime: 7 de las 11.Del log a la tabla de errores (p1)LOG: 12 líneas de textofor n, linea in enumerate(LOG.splitlines(), 1)m = re.fullmatch(PATRON, linea)m is NoneMatchmalas.append(n)la línea 11: el reiniciom.groups() → 8 variablesip, fecha, hora, metodo…int(codigo) >= 400 ?sí: fila de la tabla7 líneasno: a la siguiente4 líneasErrores: 7. Líneas que no son peticiones: [11]
fullmatch hace dos trabajos a la vez: comprueba que la línea tiene el formato y, con los grupos, la trocea.
  • 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. Con search, una línea con basura delante o detrás encajaría igual; con fullmatch, 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í que m vale None.
  • if m is None guarda 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() sobre None daría el AttributeError de 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 un ValueError al desempaquetar: es una buena comprobación de que has contado bien los paréntesis.
  • int(codigo) >= 400 convierte el código antes de comparar. Los grupos son strings, y comparar "401" >= 400 daría un TypeError.

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 aceptar GET, POST, PUT y DELETE.
  • Cambia fullmatch por search y 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 de 200, 5 de 401, 1 de 404 y 1 de 500. 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:

TrozoGrupoQué captura
(\S+)1La 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):2Solo la hora (sin minutos ni segundos)
.*Lo que sea: minutos, segundos, método y ruta
" (\d{3}) (\d+) 3 y 4El código y los bytes, detrás de la comilla
  • Se usa **re.match porque 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, m vale None y continue la salta.
  • Cada contador es un diccionario distinto: por_hora cuenta todas las peticiones de cada hora y fallos_login solo las de código "401". Fíjate en que se compara con el string "401", porque codigo es 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}")
  • '#' * n repite 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 .1f se escribe 23.5.
  • 10.0.0.23 tiene 2 fallos y 192.168.1.66 tiene 3, que es el mínimo para el aviso. El diccionario recuerda el orden en que se añadió cada IP, y por eso 10.0.0.23 sale 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 usa m.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.sub solo 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:

La plantilla de re.sub reordena los grupos (p3)Arriba, los 8 grupos de la línea 2 del log: 10.0.0, 12, 10, 2026, 09:15:40, POST, /campus/login y 401. Debajo, la plantilla \4-\3-\2T\5;\1.0;\6;\7;\8, con una flecha de cada grupo al hueco donde se copia. Resultado: 2026-10-12T09:15:40;10.0.0.0;POST;/campus/login;401.La plantilla de re.sub reordena los grupos (p3)10.0.01210202609:15:40POST/campus/login40112345678\4–\3–\2T\5;\1.0;\6;\7;\8resultado2026-10-12T09:15:40;10.0.0.0;POST;/campus/login;401
Cada \n de la plantilla copia el texto del grupo n; lo demás (guiones, la T, los «;)» se escribe tal cual. El orden lo decide la plantilla.

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.subn devuelve la línea nueva y el número de sustituciones. Como el patrón describe la línea completa, solo puede encajar una vez: n vale 1 si la línea era una petición y 0 si no.
  • Cuando no encaja, sub devuelve la línea original sin cambios. Por eso no basta con imprimir nueva: la línea del reinicio saldría tal cual, en medio del CSV. n == 0 es la forma limpia de detectarlo.
  • Fíjate en la cabecera: fecha_hora;ip;metodo;ruta;codigo sin 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 comprobar None.** Cualquier línea que no encaje (como la del reinicio) da AttributeError. Primero if 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, o codigo == "401" entre comillas.
  • **Usar search para validar.** Una línea con basura delante o detrás encaja igual. Para «la línea tiene este formato», fullmatch.
  • **Olvidar que sub solo 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:

  1. Después de la línea 5, m aparece como un objeto re.Match. Python Tutor no dibuja sus grupos por dentro: para verlos hay que pedírselos.
  2. En la línea 6, grupos se 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.
  3. En la línea 7, iso es un string nuevo. Compáralo con linea, que sigue igual: re.sub no cambia nada, devuelve otro texto.
  4. 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.
  5. 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.
  • **fullmatch valida y trocea a la vez:** si devuelve None, 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.
  • **findall sobre 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.sub con plantilla reordena campos;** re.subn dice además si la línea encajó. La plantilla, con r delante.

Checklist: ¿lo has practicado?

  • ¿Has ejecutado los tres programas y comprobado las salidas?
  • ¿Sabrías explicar por qué el programa 1 usa fullmatch y el 2 usa match?
  • ¿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í!

Siguiente paso: ejerciciosExpresiones regulares en Python — ejercicios con validadores, DNIs escondidos en un informe y dos logs que unificar (y la alerta que decía «Todo bien»)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Un comentario

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *