Expresiones regulares a la vista
·
Resumen del curso
1
El pago desaparecido: buscar el texto tal cual
2
El coche que se dio a la fuga: clases de caracteres
3
La guía telefónica rota: cuantificadores
4
Los registros falsos: anclas y validación
5
Fechas por todas partes: grupos y alternativas
6
El acceso de las 3 de la madrugada: grupos con nombre y objetos Match
7
La víspera de la noticia: sustitución y división
8
El desafío del Backtracker: codicioso, perezoso y posesivo
9
Las reglas de la caja fuerte: lookaround
10
El mismo modus operandi: retrorreferencias y condicionales
11
La regex ilegible: flags y compilación
12
El Backtracker, desenmascarado: repaso y un tokenizador
Tiempo 4:10–5:00
CASE-006
Grep & Co. Agencia de Detectives de Datos
Expediente
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 2210De 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.
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.-, 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
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.
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'}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.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 302Todos 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
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")]) # /adminUn 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
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.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 bLos 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 요청: /adminAhora 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».
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./admin\S* para atrapar todas las páginas que empiezan por /admin.Laboratorio de pruebas
re.search(r"", text)
Valor devuelto
Una posible solución es esta.
(?P<ip>\S+) \S+ \S+ \[[^:]+:(?P<hour>03):[^\]]+\] "GET (?P<path>/admin\S*)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.
LINE_RE y suma el size de todas las líneas cuyo código de respuesta (status) sea 200. No te olvides de int()."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>").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.
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© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0
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.