Lección 6.7.3 · 75 min
Web scraping responsable
Buena parte de los datos alternativos (precios, ofertas de empleo, reseñas) se recogen de webs. Esta lección construye un rastreador completo contra una tienda local con su robots.txt: lo lee con urllib.robotparser, se identifica sin datos personales, limita su ritmo, respeta los 429, usa peticiones condicionales, parsea con BeautifulSoup y JSON-LD, y deja fuera los datos personales. Cierra con el marco legal en la UE, citado de las fuentes primarias y sin asesoramiento jurídico.
- Módulo
- 6.7 · Datos alternativos
- Duración
- 75 min
Lecciones del nivel 6
6.1 · NLP financiero clásico y moderno
6.2 · LLMs para investigación y trading
6.3 · RAG financiero
6.4 · Agentes de IA para trading
- 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
- 6.4.2 Marcos de agentes y MCP
- 6.4.3 Arquitecturas multiagente tipo TradingAgents
- 6.4.4 Evidencia y límites de los agentes
- 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
- 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
6.5 · IA como copiloto del quant
6.6 · Big data para trading
- 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
- 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
- 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
- 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
- 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
6.7 · Datos alternativos
Cuándo scrapear y cuándo no
Precios de tiendas, ofertas de empleo, reseñas, fichas de producto: muchos de los datos alternativos de la lección 6.7.2 se recogen de webs públicas con un programa que descarga páginas y extrae campos, el web scraping. Es técnicamente sencillo y por eso se abusa de él. Antes de escribir una línea, cuatro preguntas:
Fuente Mermaid
flowchart TB
A{Vía oficial} -- sí --> U[Usarla]
A -- no --> B{Detrás de login}
B -- sí --> N[No rastrear]
B -- no --> R{robots y términos}
R -- prohíben --> N
R -- permiten --> P{Datos personales}
P -- sí --> M[Minimizar o no]
P -- no --> S[Rastrear despacio]- ¿Hay una vía oficial? Una API, un fichero de descarga, un feed o una licencia del propio sitio o de un proveedor (lecciones 1.3.7 y 6.7.4). Siempre es preferible: es estable, tiene condiciones claras y no carga el servidor.
- ¿Hace falta iniciar sesión? Lo que está detrás de un usuario y una contraseña no es público: se rige por el contrato de la cuenta. Tampoco se saltan bloqueos, CAPTCHA ni límites técnicos.
- ¿Qué dicen el robots.txt y los términos de uso? El primero es una convención técnica; los segundos, un texto legal. Ninguno de los dos se sustituye por el otro.
- ¿Hay datos personales? Nombres, correos, fotos, opiniones firmadas. Si el fin no los necesita, no se recogen.
Esta lección no rastrea ninguna web real. Todo el código se ejecuta contra un servidor local que la primera celda
levanta en la propia máquina: una tienda ficticia con productos, precios, ofertas de empleo y su robots.txt. El código
para webs reales es el mismo con otra URL base; donde hace falta algo que no está instalado (Playwright), va en un
bloque skip.
Una tienda local para practicar
El servidor sirve, con los productos y marcas de las reseñas del nivel 6, un listado paginado, una ficha por producto
(con el precio en la página y en JSON-LD, el formato de datos estructurados de schema.org), ofertas de empleo con la
persona de contacto, una página cuyo precio carga JavaScript y un robots.txt. Se comporta como un servidor real en
lo que importa para el cliente: responde 429 (Too Many Requests) con Retry-After una de cada 15 peticiones
(siempre las mismas, para que la salida no dependa del reloj), da ETag y Last-Modified, contesta 304 a las
peticiones condicionales y apunta el User-Agent de cada petición. El precio de cada producto lo decide el servidor
(la verdad) y cambia en un 20 % de los productos el «día 2»:
import hashlib
import html
import json
import threading
from email.utils import formatdate
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import numpy as np
import pandas as pd
rng = np.random.default_rng(673)
cat = (pd.read_parquet("alt6_resenas.parquet")[["producto", "marca"]]
.drop_duplicates().sort_values("producto")
.reset_index(drop=True))
cat["id"] = [f"P{i:02d}" for i in range(1, len(cat) + 1)]
cat["precio1"] = np.round(rng.uniform(20, 1500, len(cat)), 0) - 0.01
cambia = rng.random(len(cat)) < 0.2
cat["precio2"] = np.where(
cambia, np.round(cat["precio1"] * rng.uniform(0.85, 1.1,
len(cat)), 2),
cat["precio1"])
cat["rebaja"] = rng.random(len(cat)) < 0.3 # precio tachado
emp = pd.read_csv("acciones6_empresas.csv").head(8)
ofertas = pd.DataFrame({
"id": [f"E{i:02d}" for i in range(1, 9)],
"empresa": emp["nombre_corto"],
"puesto": ["Analista de datos", "Ingeniera de backend",
"Jefe de tienda", "Técnico de mantenimiento",
"Comercial", "Controller financiero",
"Científica de datos", "Responsable de compras"],
"contacto": ["Laura Gómez", "Pablo Ruiz", "Marta Sanz",
"Jorge Pardo", "Elena Vidal", "Iván Mora",
"Sara León", "Raúl Ibáñez"]})
FECHAS = {1: 1790841600, 2: 1790928000} # 1 y 2 oct. 2026, 8:00
ROBOTS = """User-agent: *
Disallow: /cuenta/
Disallow: /buscar
Disallow: /*?orden=
Disallow: /empleo/
Allow: /empleo/ofertas/
Crawl-delay: 2
User-agent: AgresivoBot
Disallow: /
"""
def euros(x):
s = f"{x:,.2f}".replace(",", "X").replace(".", ",")
return s.replace("X", ".") + " €"
def ficha(fila, dia):
p = fila[f"precio{dia}"]
ld = {"@context": "https://schema.org", "@type": "Product",
"name": fila["producto"], "brand": fila["marca"],
"sku": fila["id"], "offers": {"@type": "Offer",
"price": f"{p:.2f}", "priceCurrency": "EUR"}}
antes = (f'<del class="precio-anterior">{euros(p * 1.25)}'
'</del> ' if fila["rebaja"] else "")
return (f"<html><head><title>{fila['producto']}</title>"
'<script type="application/ld+json">'
f"{json.dumps(ld)}</script></head><body>"
f"<h1>{fila['producto']}</h1><p>Marca: {fila['marca']}"
f'</p><p>{antes}<span class="precio">{euros(p)}'
"</span></p></body></html>")
def oferta(o):
ld = {"@context": "https://schema.org", "@type": "JobPosting",
"title": o["puesto"], "datePosted": "2026-10-01",
"hiringOrganization": {"@type": "Organization",
"name": o["empresa"]},
"jobLocation": {"@type": "Place", "address": "Madrid"}}
correo = (o["contacto"].lower().replace(" ", ".")
.translate(str.maketrans("áéíóú", "aeiou")))
return ('<html><head><script type="application/ld+json">'
f"{json.dumps(ld)}</script></head><body><h1>"
f"{o['puesto']}</h1><p>Contacto: {o['contacto']}, "
f"{correo}@tienda.example</p></body></html>")
class Tienda(BaseHTTPRequestHandler):
dia, n, uas = 1, 0, []
def do_GET(self):
url = urlparse(self.path)
Tienda.uas.append(self.headers.get("User-Agent", ""))
if url.path == "/robots.txt":
return self.enviar(200, ROBOTS, "text/plain")
Tienda.n += 1
if Tienda.n % 15 == 0:
return self.enviar(429, "despacio", "text/plain",
{"Retry-After": "5"})
if url.path == "/productos":
pag = int(parse_qs(url.query).get("pagina", ["1"])[0])
trozo = cat.iloc[(pag - 1) * 20: pag * 20]
items = "".join(
f'<li class="producto"><a href="/producto/{i}">'
f"{html.escape(p)}</a></li>"
for i, p in zip(trozo["id"], trozo["producto"]))
sig = (f'<a rel="next" href="/productos?pagina={pag + 1}">'
"Siguiente</a>" if pag * 20 < len(cat) else "")
return self.enviar(200, f"<ul>{items}</ul>{sig}")
if url.path.startswith("/producto/"):
fila = cat[cat["id"] == url.path.rsplit("/", 1)[1]]
if fila.empty:
return self.enviar(404, "no existe")
fila = fila.iloc[0]
cuerpo = ficha(fila, Tienda.dia)
etag = '"' + hashlib.sha256(
cuerpo.encode()).hexdigest()[:16] + '"'
dia_mod = 2 if (Tienda.dia == 2 and
fila["precio2"] != fila["precio1"]) else 1
lm = formatdate(FECHAS[dia_mod], usegmt=True)
if (self.headers.get("If-None-Match") == etag or
self.headers.get("If-Modified-Since") == lm):
return self.enviar(304, "", extra={"ETag": etag})
return self.enviar(200, cuerpo, extra={
"ETag": etag, "Last-Modified": lm})
if url.path == "/empleo/ofertas/":
items = "".join(f'<a href="/empleo/ofertas/{i}">{i}</a>'
for i in ofertas["id"])
return self.enviar(200, f"<body>{items}</body>")
if url.path.startswith("/empleo/ofertas/"):
o = ofertas[ofertas["id"] == url.path.rsplit("/", 1)[1]]
return self.enviar(200, oferta(o.iloc[0]))
if url.path.startswith("/precio-dinamico/"):
pid = url.path.rsplit("/", 1)[1]
return self.enviar(200, (
'<span id="precio">cargando</span><script>'
f"fetch('/api/precio/{pid}').then(r => r.json())"
".then(d => document.getElementById('precio')"
".textContent = d.precio)</script>"))
if url.path.startswith("/api/precio/"):
fila = cat[cat["id"] == url.path.rsplit("/", 1)[1]].iloc[0]
return self.enviar(200, json.dumps(
{"id": fila["id"],
"precio": fila[f"precio{Tienda.dia}"]}),
"application/json")
self.enviar(404, "no existe")
def enviar(self, codigo, texto, tipo="text/html", extra=None):
cuerpo = texto.encode()
self.send_response(codigo)
self.send_header("Content-Type", f"{tipo}; charset=utf-8")
self.send_header("Content-Length", str(len(cuerpo)))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if codigo != 304:
self.wfile.write(cuerpo)
def log_message(self, *args):
pass
servidor = ThreadingHTTPServer(("127.0.0.1", 0), Tienda)
threading.Thread(target=servidor.serve_forever, daemon=True).start()
BASE = f"http://127.0.0.1:{servidor.server_port}"
print(len(cat), "productos;", int(cambia.sum()),
"cambian de precio el día 2;", int(cat["rebaja"].sum()),
"con precio tachado;", len(ofertas), "ofertas de empleo")30 productos; 7 cambian de precio el día 2; 12 con precio tachado; 8 ofertas de empleorobots.txt: qué dice y qué no
El protocolo de exclusión de robots es un fichero de texto en la raíz del sitio, /robots.txt, con grupos de
reglas por rastreador. Se usaba desde 1994 como convención y en septiembre de 2022 se publicó como estándar, el RFC
93091Nota 1 · RFC 9309, «Robots Exclusion Protocol» (septiembre de 2022, https://www.rfc-editor.org/rfc/rfc9309.html,
consultado el 2026-10-23): emparejamiento sin distinguir mayúsculas por el nombre de producto; «The most specific
match is the match that has the most octets»; en empate, allow; * y $; 4xx «unavailable» (se puede
rastrear) y 5xx «unreachable» (prohibición total); caché de 24 horas como máximo; límite de análisis de al menos
500 KiB; «These rules are not a form of access authorization».. Sus reglas principales:
| Regla del RFC 9309 | Qué implica para el rastreador |
|---|---|
El grupo se elige por el nombre de producto del rastreador, sin distinguir mayúsculas; si no hay grupo propio, el de * | Hay que tener un nombre propio y usarlo de verdad en el User-Agent |
Entre allow y disallow, gana la coincidencia más larga; en empate, allow | El orden de las líneas no importa |
* es cualquier secuencia de caracteres y $ el final de la ruta | Disallow: /*?orden= bloquea cualquier URL con ese parámetro |
| Un 4xx al pedir el robots.txt (no existe) permite rastrear; un 5xx obliga a suponer prohibición total | Un error del servidor no es un permiso |
| La copia guardada no debe usarse más de 24 horas, salvo si el fichero es inaccesible | Hay que volver a pedirlo cada día |
| Las reglas «no son una forma de autorización de acceso» | Cumplir el robots.txt no da derecho a nada; incumplirlo no se arregla con un argumento técnico |
Crawl-delay (segundos entre peticiones) no está en el RFC, pero muchos sitios lo usan y urllib.robotparser lo
lee: respetarlo es lo prudente. El módulo de la biblioteca estándar tiene una trampa: read() descarga el fichero con
urllib y su User-Agent por defecto, no con el del rastreador. La celda lo muestra pidiendo al servidor que diga
qué cabecera recibió, y después descarga el robots.txt con httpx y el nombre propio:
import urllib.robotparser
import httpx
UA = "CursoPPT-precios/0.1 (+https://example.com/rastreador)"
rp_std = urllib.robotparser.RobotFileParser(f"{BASE}/robots.txt")
rp_std.read()
print("read() se identifica como:", Tienda.uas[-1])
cliente = httpx.Client(base_url=BASE, timeout=10.0,
headers={"User-Agent": UA})
r = cliente.get("/robots.txt")
print("httpx se identifica como:", Tienda.uas[-1], "->",
r.status_code)
rp = urllib.robotparser.RobotFileParser()
rp.parse(r.text.splitlines())
print("Crawl-delay:", rp.crawl_delay(UA))read() se identifica como: Python-urllib/3.14
httpx se identifica como: CursoPPT-precios/0.1 (+https://example.com/rastreador) -> 200
Crawl-delay: 2Con el robots.txt cargado, can_fetch responde para cada URL. La tabla prueba las reglas que importan: la ruta
prohibida, el parámetro con comodín, la regla larga que gana a la corta y el grupo de otro rastreador:
pruebas = ["/productos?pagina=1", "/producto/P01",
"/productos?orden=precio", "/cuenta/pedidos",
"/buscar?q=cafetera", "/empleo/candidatos/",
"/empleo/ofertas/E01", "/api/precio/P01"]
pd.DataFrame({
"CursoPPT": [rp.can_fetch(UA, BASE + u) for u in pruebas],
"AgresivoBot": [rp.can_fetch("AgresivoBot/2.0", BASE + u)
for u in pruebas]}, index=pruebas) CursoPPT AgresivoBot
/productos?pagina=1 True False
/producto/P01 True False
/productos?orden=precio False False
/cuenta/pedidos False False
/buscar?q=cafetera False False
/empleo/candidatos/ False False
/empleo/ofertas/E01 True False
/api/precio/P01 True False/empleo/ofertas/E01 está permitida aunque Disallow: /empleo/ aparezca antes: Allow: /empleo/ofertas/ coincide
con más caracteres. Ese comportamiento es el del RFC, pero no el de todas las versiones de Python: con el mismo
fichero, Python 3.12.14 aplica la primera regla que coincide y no entiende los comodines (bloquea las ofertas y deja
pasar ?orden=); Python 3.13.15 y 3.14.8, la del entorno del curso, siguen el RFC (comprobado al escribir la
lección, el 2026-10-23). La documentación del módulo remite al RFC 9309 sin detallar el emparejamiento2Nota 2 · Python, «urllib.robotparser — Parser for robots.txt» (https://docs.python.org/3/library/urllib.robotparser.html,
consultado el 2026-10-23): remite al RFC 9309; crawl_delay, request_rate y site_maps. El comportamiento con
Allow largo y comodines, comprobado ejecutando el mismo robots.txt en Python 3.12.14, 3.13.15 y 3.14.8.: si el
rastreador corre en otra versión, hay que probarlo con un robots.txt conocido, como esta tabla.
Un cliente que se identifica y va despacio
El cliente de la lección 1.4.1 ya reintentaba con espera creciente. Aquí se le añade lo que hace falta para rastrear un sitio ajeno:
Fuente Mermaid
flowchart TB
U[URL] --> R{robots.txt}
R -- sí --> L[Límite de ritmo]
L --> G[GET]
G -- 429 --> W[Retry-After]
W --> L
G -- 200 --> C[Caché]- Identificación: un nombre de producto propio y una URL que explica qué hace el rastreador y cómo contactar, sin
datos personales (ni nombre ni correo de una persona). Con un nombre propio, el sitio puede escribir un grupo para
él en el robots.txt; con el
User-Agentde un navegador, no. - Ritmo: una petición cada
Crawl-delaysegundos o más, nunca en paralelo contra el mismo sitio. - Errores: ante un 429, esperar lo que diga
Retry-After(RFC 9110, sección 10.2.33Nota 3 · RFC 9110, «HTTP Semantics» (https://www.rfc-editor.org/rfc/rfc9110.html, consultado el 2026-10-23):Retry-After(sección 10.2.3), peticiones condicionales conIf-None-MatcheIf-Modified-Since(sección 13.1) y 304 Not Modified (sección 15.4.5). El código 429 se define en el RFC 6585 (https://www.rfc-editor.org/rfc/rfc6585.html), que permite acompañarlo deRetry-After.); ante un 5xx, reintentar con espera creciente y algo de azar, y abandonar tras unos intentos. El parámetroretriesdel transporte de httpx solo reintenta errores de conexión (ConnectError,ConnectTimeout), no respuestas 429 o 5034Nota 4 · HTTPX, «Transports» (https://www.python-httpx.org/advanced/transports/, consultado el 2026-10-23): las peticiones «will be retried the given number of times in case an httpx.ConnectError or an httpx.ConnectTimeout occurs»; para otros casos remite a librerías de reintentos como tenacity.: esa lógica es del cliente.
Para que la salida no dependa del reloj de la máquina, el cliente usa un reloj simulado que avanza cuando el
cliente «duerme»; en producción, time.monotonic() y time.sleep() ocupan su lugar sin tocar nada más:
class Reloj:
"""Reloj simulado: sleep() avanza el tiempo sin esperar."""
def __init__(self):
self.t = 0.0
def now(self):
return self.t
def sleep(self, s):
self.t += s
reloj = Reloj()
azar = np.random.default_rng(0)
registro = [] # (tiempo, url, código)
class Rastreador:
def __init__(self, cliente, rp, ua, reloj, intentos=4):
self.c, self.rp, self.ua = cliente, rp, ua
self.reloj, self.intentos = reloj, intentos
self.pausa = max(rp.crawl_delay(ua) or 0, 1.0)
self.ultima = -np.inf
def get(self, ruta, cabeceras=None):
if not self.rp.can_fetch(self.ua, BASE + ruta):
raise PermissionError(f"robots.txt prohíbe {ruta}")
for n in range(self.intentos):
espera = self.ultima + self.pausa - self.reloj.now()
if espera > 0:
self.reloj.sleep(espera)
self.ultima = self.reloj.now()
r = self.c.get(ruta, headers=cabeceras)
registro.append((self.reloj.now(), ruta, r.status_code))
if r.status_code == 429:
self.reloj.sleep(float(r.headers["Retry-After"]))
elif r.status_code >= 500:
self.reloj.sleep(2 ** n + azar.uniform(0, 1))
else:
return r
r.raise_for_status()
bot = Rastreador(cliente, rp, UA, reloj)
try:
bot.get("/cuenta/pedidos")
except PermissionError as e:
print(e)
r = bot.get("/productos?pagina=1")
print(r.status_code, len(r.text), "bytes; reloj:", reloj.now(), "s")robots.txt prohíbe /cuenta/pedidos
200 1352 bytes; reloj: 0.0 sExtraer los datos: BeautifulSoup y JSON-LD
El listado tiene enlaces a las fichas y un enlace rel="next" a la página siguiente. Seguir ese enlace, en vez de
inventar URLs, hace que el rastreador no dependa de cómo numera el sitio sus páginas. BeautifulSoup convierte el HTML
en un árbol que se consulta con selectores CSS; en el entorno ia no está lxml, así que el analizador es el de la
biblioteca estándar, html.parser:
from bs4 import BeautifulSoup
fichas, ruta = {}, "/productos?pagina=1"
while ruta:
sopa = BeautifulSoup(bot.get(ruta).text, "html.parser")
for a in sopa.select("li.producto a"):
fichas[a["href"]] = None
sig = sopa.find("a", rel="next")
ruta = sig["href"] if sig else None
cache = {}
for ruta in fichas:
r = bot.get(ruta)
cache[ruta] = {"etag": r.headers.get("ETag"),
"lm": r.headers.get("Last-Modified"),
"html": r.text}
bytes_dia1 = sum(len(v["html"].encode()) for v in cache.values())
print(len(cache), "fichas;", bytes_dia1, "bytes; reloj:",
reloj.now(), "s")
print(pd.Series([c for _, _, c in registro]).value_counts()
.to_dict())30 fichas; 11888 bytes; reloj: 74.0 s
{200: 33, 429: 2}Treinta fichas y dos páginas de listado, con una petición cada 2 segundos (el Crawl-delay) y dos 429 por el camino,
que añaden 5 segundos de espera cada uno; la figura de la cabecera dibuja ese recorrido. El precio está dos veces en
cada ficha: en el texto visible (<span class="precio">1.299,00 €</span>, con el formato español) y en JSON-LD
("price": "1299.00"). Las dos formas de leerlo, contra la verdad del servidor:
import re
def precio_texto(h):
s = re.search(r"([\d.]+,\d{2}) €", h).group(1) # el primero
return float(s.replace(".", "").replace(",", "."))
def precio_css(sopa):
s = sopa.select_one("span.precio").get_text()
return float(s.replace(" €", "").replace(".", "")
.replace(",", "."))
def precio_jsonld(sopa):
ld = json.loads(sopa.find(
"script", type="application/ld+json").string)
return float(ld["offers"]["price"]), ld["sku"]
filas = []
for ruta, v in cache.items():
sopa = BeautifulSoup(v["html"], "html.parser")
p_ld, sku = precio_jsonld(sopa)
filas.append({"id": sku, "regex": precio_texto(v["html"]),
"css": precio_css(sopa), "jsonld": p_ld})
ext = pd.DataFrame(filas).merge(cat[["id", "precio1", "rebaja"]])
for m in ["regex", "css", "jsonld"]:
ok = np.isclose(ext[m], ext["precio1"])
print(f"{m:7s} aciertos: {ok.mean():.0%}")regex aciertos: 60%
css aciertos: 100%
jsonld aciertos: 100%La expresión regular «el primer importe en euros» falla en los productos con precio tachado: lee el precio anterior.
El selector CSS (span.precio) y el JSON-LD aciertan en todos. De los dos, JSON-LD es el más estable: lo publica el
sitio para los buscadores, con el precio como número y la moneda aparte, y no cambia cuando cambia el diseño de la
página. Si un rediseño renombra la clase precio, el selector se rompe sin avisar; por eso un rastreador en
producción comprueba cada día cuántos campos vacíos o fuera de rango ha extraído.
Peticiones condicionales y caché
Al día siguiente, el rastreador vuelve. La mayoría de las fichas no ha cambiado, y el protocolo HTTP tiene una forma
de no descargarlas otra vez: la petición condicional. El cliente envía el ETag que guardó (If-None-Match) o la
fecha de la última modificación (If-Modified-Since); si no ha cambiado, el servidor responde 304 Not Modified,
sin cuerpo (RFC 9110, sección 133):
Tienda.dia = 2
bytes_dia2, codigos, cambios = 0, [], []
for ruta, v in cache.items():
r = bot.get(ruta, {"If-None-Match": v["etag"]})
codigos.append(r.status_code)
if r.status_code == 200:
bytes_dia2 += len(r.content)
v.update(etag=r.headers["ETag"], html=r.text,
lm=r.headers["Last-Modified"])
cambios.append(ruta.rsplit("/", 1)[1])
print(pd.Series(codigos).value_counts().to_dict())
print("bytes de fichas: día 1", bytes_dia1, "| día 2",
bytes_dia2, f"({1 - bytes_dia2 / bytes_dia1:.0%} menos)")
verdad = cat.loc[cat["precio2"] != cat["precio1"], "id"].tolist()
print("cambios detectados = verdad:", sorted(cambios) == verdad){304: 23, 200: 7}
bytes de fichas: día 1 11888 | día 2 2832 (76% menos)
cambios detectados = verdad: TrueSiete fichas vuelven con 200 (las que cambiaron de precio, todas detectadas) y veintitrés con 304: un 76 % menos de bytes. Los 304 también
cuentan como petición y respetan el ritmo, pero el servidor no genera ni envía la página. If-Modified-Since funciona
igual con la fecha; es menos preciso (resolución de un segundo) y sirve cuando el sitio no da ETag:
v = cache["/producto/P01"]
r = bot.get("/producto/P01", {"If-Modified-Since": v["lm"]})
print(v["lm"], "->", r.status_code)Thu, 01 Oct 2026 08:00:00 GMT -> 304Para el research, la caché tiene otra ventaja: guardar el HTML original de cada día (no solo los campos extraídos) permite volver a parsearlo cuando se corrige un error del parser, sin volver a rastrear, y reconstruir qué se sabía en cada fecha (point-in-time, lección 1.4.4).
Páginas con JavaScript
Algunas páginas no traen el dato en el HTML: lo pide el navegador con JavaScript después de cargar. Un cliente HTTP como httpx no ejecuta JavaScript y ve el texto de relleno:
r = bot.get("/precio-dinamico/P01")
sopa = BeautifulSoup(r.text, "html.parser")
print("precio en el HTML:", sopa.select_one("#precio").get_text())
api = re.search(r"fetch\('([^']+)'\)", r.text).group(1)
print("el script pide:", api)
print("¿permitido?", rp.can_fetch(UA, BASE + api))
print(bot.get(api).json())precio en el HTML: cargando
el script pide: /api/precio/P01
¿permitido? True
{'id': 'P01', 'precio': 1492.99}El script pide los datos a un punto JSON del propio sitio; si el robots.txt y los términos lo permiten, pedir ese JSON
directamente es lo más eficiente (una petición pequeña en vez de cargar un navegador). Cuando no hay forma de evitar
el navegador (el dato se calcula en la página, o la página exige interacción), se usa un navegador automatizado como
Playwright, que controla Chromium, Firefox o WebKit. No está instalado en los entornos del curso (comprobado el
2026-10-23), así que su código va sin ejecutar. Se instala con pip install playwright y después playwright install, que descarga los navegadores5Nota 5 · Playwright para Python, «Getting started - Library» (https://playwright.dev/python/docs/library, consultado el
2026-10-23): pip install playwright y playwright install, que instala los navegadores; new_context admite
user_agent y locale (https://playwright.dev/python/docs/api/class-browser).:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
navegador = p.chromium.launch()
contexto = navegador.new_context(user_agent=UA, locale="es-ES")
pagina = contexto.new_page()
pagina.goto(f"{BASE}/precio-dinamico/P01")
pagina.wait_for_function(
"document.getElementById('precio').textContent"
" !== 'cargando'")
print(pagina.locator("#precio").inner_text())
navegador.close()Un navegador automatizado carga todo lo que carga una persona (scripts, imágenes, fuentes): pesa más en el servidor
y en la máquina propia. Las mismas reglas de ritmo, robots.txt e identificación valen para él (user_agent en el
contexto).
La biblioteca requests, la más extendida, hace lo mismo que httpx en modo síncrono (httpx añade un cliente asíncrono y HTTP/2 opcional). El patrón es el mismo: una sesión con la cabecera fija y un tiempo límite:
import requests
with requests.Session() as s:
s.headers["User-Agent"] = UA
r = s.get(f"{BASE}/robots.txt", timeout=10)
print(r.status_code, r.text.splitlines()[0], "|", Tienda.uas[-1])200 User-agent: * | CursoPPT-precios/0.1 (+https://example.com/rastreador)Datos personales: no recogerlos
Las ofertas de empleo de la tienda incluyen el nombre y el correo de la persona de contacto, como muchas reales. Para un estudio de contratación (cuántas ofertas, de qué empresas y categorías) no hacen falta. La forma más segura de cumplir es no guardarlos: el parser extrae del JSON-LD solo los campos del estudio y nunca toca el texto libre:
CAMPOS = ["title", "datePosted"]
sopa = BeautifulSoup(bot.get("/empleo/ofertas/").text,
"html.parser")
guardadas, correos_vistos = [], 0
for a in sopa.find_all("a"):
h = bot.get(a["href"]).text
correos_vistos += len(re.findall(r"[\w.]+@[\w.]+", h))
ld = json.loads(BeautifulSoup(h, "html.parser").find(
"script", type="application/ld+json").string)
fila = {k: ld[k] for k in CAMPOS}
fila["empresa"] = ld["hiringOrganization"]["name"]
guardadas.append(fila)
ofe = pd.DataFrame(guardadas)
texto = ofe.to_csv(index=False)
print("correos en las páginas:", correos_vistos,
"| en lo guardado:", len(re.findall(r"@", texto)))
ofe.head(3)correos en las páginas: 8 | en lo guardado: 0
title datePosted empresa
0 Analista de datos 2026-10-01 Bagvor Telecom
1 Ingeniera de backend 2026-10-01 Bakdor Pharmaceuticals
2 Jefe de tienda 2026-10-01 Bebvia PetroleumOcho correos en las páginas y ninguno en el dataset. La lista blanca de campos (CAMPOS) es la decisión importante:
lo que no está en la lista no se guarda, aunque mañana el sitio añada un campo nuevo con datos personales.
Al terminar, se cierra el cliente y el servidor:
print("peticiones:", len(registro), "| 429:",
sum(c == 429 for _, _, c in registro),
"| reloj simulado:", reloj.now(), "s")
cliente.close()
servidor.shutdown()peticiones: 80 | 429: 5 | reloj simulado: 173.0 sEl marco legal en la UE
Cuatro marcos se cruzan en un rastreo:
- Términos de uso. Son un contrato entre el sitio y quien lo usa, y pueden prohibir el acceso automatizado o la reutilización comercial del contenido. Que el robots.txt permita una ruta no anula una prohibición de los términos. Para datos de mercado, la lección 1.3.7 trató las licencias; la misma lógica (qué uso se permite, si hay redistribución) vale para el contenido de una web.
- Protección de datos (RGPD). Si lo que se recoge permite identificar a una persona, es un dato personal: «toda información sobre una persona física identificada o identificable» (artículo 4.1). Recogerlo es tratamiento y necesita una base jurídica del artículo 6; para el scraping se discute sobre todo el interés legítimo del 6.1.f, que exige que no prevalezcan los derechos del interesado. Los datos tienen que ser «adecuados, pertinentes y limitados a lo necesario» (5.1.c, minimización), y quien los obtiene sin pasar por el interesado tiene un deber de información (artículo 14), con excepciones como el esfuerzo desproporcionado (14.5.b)6Nota 6 · Reglamento (UE) 2016/679, texto publicado en el DOUE y reproducido por el BOE (https://www.boe.es/buscar/doc.php?id=DOUE-L-2016-80807, consultado el 2026-10-23): artículos 4.1, 5.1.c, 6.1.f y 14.5.b. La versión en EUR-Lex es https://eur-lex.europa.eu/eli/reg/2016/679/oj.. La AEPD resume que el scraping, «cuando recopila datos personales, sí implica un tratamiento», y archivó un caso porque la herramienta solo mostraba información agregada, sin personas identificables7Nota 7 · AEPD, criterio «Herramientas de data scraping para la evaluación de tendencias», 25 de noviembre de 2025 (procedimiento AI/00059/2024, https://www.aepd.es/informes-y-resoluciones/criterios-juridicos-aepd/herramientas-data-scraping-para-evaluacion-de-tendencias, consultado el 2026-10-23). La página advierte de que el resumen no es la publicación oficial.. El Comité Europeo de Protección de Datos adoptó el 8 de julio de 2026 unas directrices sobre scraping para IA generativa, en consulta pública hasta el 30 de octubre de 2026, que desarrollan el interés legítimo y la minimización8Nota 8 · CEPD (EDPB), «Guidelines 03/2026 on web scraping in the context of generative AI», consulta pública del 8 de julio al 30 de octubre de 2026 (https://www.edpb.europa.eu/public-consultations/guidelines-032026-on-web-scraping-in-the-context-of-generative-ai_en), y nota del 8 de julio de 2026 (https://www.edpb.europa.eu/news/edpb-sheds-light-on-anonymisation-and-web-scraping-for-generative-ai-and-adopts-final-version_en), consultadas el 2026-10-23. Es un texto en consulta y puede cambiar..
- Derecho sui generis sobre bases de datos. La Directiva 96/9/CE permite al fabricante de una base de datos que ha hecho una «inversión sustancial» prohibir la extracción o reutilización de la totalidad o de una parte sustancial (artículo 7.1) y también la extracción «repetida o sistemática» de partes no sustanciales que perjudique su explotación normal (7.5), durante quince años (artículo 10.1). En España está en el artículo 133 de la Ley de Propiedad Intelectual9Nota 9 · Directiva 96/9/CE, artículos 7.1, 7.5 y 10.1 (EUR-Lex: https://eur-lex.europa.eu/eli/dir/1996/9/oj; texto en español leído en OMPI Lex, https://www.wipo.int/wipolex/es/text/126790); Real Decreto Legislativo 1/1996, Ley de Propiedad Intelectual, artículo 133 (https://www.boe.es/buscar/act.php?id=BOE-A-1996-8930). Consultados el 2026-10-23.. Un rastreador diario de todo un catálogo de precios es, justamente, una extracción repetida y sistemática.
- Minería de textos y datos. La Directiva (UE) 2019/790 creó una excepción para la minería de textos y datos de contenido accesible de forma legítima; en España, el artículo 67 del Real Decreto-ley 24/2021. No se aplica cuando los titulares «hayan reservado expresamente el uso de las obras a medios de lectura mecánica u otros medios que resulten adecuados» (67.3)10Nota 10 · Real Decreto-ley 24/2021, de 2 de noviembre, artículo 67, «Minería de textos y datos», que transpone la Directiva (UE) 2019/790 (https://www.boe.es/buscar/act.php?id=BOE-A-2021-17910, consultado el 2026-10-23).. La norma no dice qué formato tiene esa reserva; un robots.txt o una cláusula de los términos que la expresen son los candidatos evidentes, y lo prudente es respetarlos como tal.
Para un fondo, a esto se suma otro riesgo que trata la lección 6.7.4: que el dato recogido sea información privilegiada o se haya obtenido incumpliendo un deber de confidencialidad. Los equipos de cumplimiento de los fondos suelen exigir una revisión antes de usar cualquier dato rastreado.
Errores típicos
- Rastrear lo que tiene API o licencia. Más trabajo, menos estabilidad y más riesgo que la vía oficial.
- Disfrazarse de navegador. Impide al sitio aplicar sus reglas y es la primera señal de mala fe si hay un conflicto.
- Poner datos personales en el
User-Agent. Un nombre de producto y una URL de información bastan. - Usar
robotparser.read()sin pensar: descarga con otroUser-Agenty no guarda la fecha para volver a pedirlo en 24 horas. Y no comprobar el emparejamiento de la versión de Python en uso. - Paralelizar contra un solo sitio o ignorar
Retry-After: el bloqueo llega antes que los datos. - Parsear el texto visible cuando hay datos estructurados: los precios tachados, las unidades y los rediseños rompen las expresiones regulares.
- Guardar todo «por si acaso»: los datos personales que no se recogen no hay que protegerlos, ni borrarlos, ni explicarlos.
Ejercicios
- Cambia el servidor para que responda 503 al pedir
/robots.txty comprueba qué haceRobotFileParser.read(). ¿Coincide con el RFC 9309?
Solución
class Caida(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(503)
self.end_headers()
def log_message(self, *args):
pass
s503 = ThreadingHTTPServer(("127.0.0.1", 0), Caida)
threading.Thread(target=s503.serve_forever, daemon=True).start()
rp503 = urllib.robotparser.RobotFileParser(
f"http://127.0.0.1:{s503.server_port}/robots.txt")
rp503.read()
print("¿se puede rastrear algo?",
rp503.can_fetch(UA, "http://127.0.0.1/productos"))
s503.shutdown()¿se puede rastrear algo? Falseread() marca todo como prohibido ante un 5xx, como pide el RFC 9309. Un rastreador propio que descarga el robots.txt
con httpx tiene que hacer lo mismo a mano: si el código es 5xx (o la conexión falla), no rastrear y volver a
intentarlo más tarde; si es 4xx, se puede rastrear (salvo 401 y 403, que el módulo de Python trata como prohibición
total, una decisión más prudente que el RFC).
- Añade al
Rastreadoruna caché del robots.txt que lo vuelva a pedir si han pasado más de 24 horas (con el reloj simulado) y compruébalo avanzando el reloj.
Solución
class RobotsCache:
def __init__(self, cliente, reloj, vida=24 * 3600):
self.c, self.reloj, self.vida = cliente, reloj, vida
self.rp, self.cuando, self.descargas = None, None, 0
def get(self):
if (self.cuando is None or
self.reloj.now() - self.cuando > self.vida):
r = self.c.get("/robots.txt")
self.rp = urllib.robotparser.RobotFileParser()
self.rp.parse(r.text.splitlines())
self.cuando = self.reloj.now()
self.descargas += 1
return self.rp
# el servidor ya está cerrado: un transporte simulado de httpx
falso = httpx.MockTransport(
lambda req: httpx.Response(200, text=ROBOTS))
with httpx.Client(transport=falso, base_url="http://tienda.example",
headers={"User-Agent": UA}) as c2:
rc = RobotsCache(c2, Reloj())
rc.get()
rc.reloj.sleep(3600)
rc.get()
rc.reloj.sleep(24 * 3600)
print("¿permitido?", rc.get().can_fetch(
UA, "http://tienda.example/producto/P01"))
print("descargas del robots.txt:", rc.descargas)¿permitido? True
descargas del robots.txt: 2Dos descargas: la inicial y la de después de 25 horas; la consulta a la hora usa la copia. httpx.MockTransport sustituye
al servidor: responde a cada petición con una función, sin red, útil también para probar el rastreador. En un rastreador real, la
fecha de descarga se guarda junto al fichero, y si la descarga falla con un 5xx se mantiene la última copia buena
(el RFC lo permite si el fichero es inaccesible) o se para el rastreo.
- Un proveedor te ofrece un dataset de precios rastreados de 2.000 tiendas online. ¿Qué preguntas le harías sobre cómo los recoge?
Solución
Las de esta lección, vueltas hacia el proveedor: si respeta el robots.txt de cada sitio y cómo trata los cambios, qué
User-Agent usa y si se identifica, a qué ritmo rastrea y si ha recibido quejas o bloqueos, si accede a algo detrás
de un inicio de sesión, si ha revisado los términos de uso de los sitios y qué hace con los que prohíben el rastreo,
si recoge datos personales (reseñas firmadas, vendedores particulares) y con qué base jurídica, si hay bases de datos
con derecho sui generis entre las fuentes, y si conserva el HTML original con fecha (para reconstruir el dato
point-in-time). Un proveedor serio tiene respuestas por escrito y una revisión legal; la lección 6.7.4 convierte
estas preguntas en un cuestionario de due diligence.
Footnotes
-
RFC 9309, «Robots Exclusion Protocol» (septiembre de 2022, https://www.rfc-editor.org/rfc/rfc9309.html, consultado el 2026-10-23): emparejamiento sin distinguir mayúsculas por el nombre de producto; «The most specific match is the match that has the most octets»; en empate,
allow;*y$; 4xx «unavailable» (se puede rastrear) y 5xx «unreachable» (prohibición total); caché de 24 horas como máximo; límite de análisis de al menos 500 KiB; «These rules are not a form of access authorization». ↩ -
Python, «urllib.robotparser — Parser for robots.txt» (https://docs.python.org/3/library/urllib.robotparser.html, consultado el 2026-10-23): remite al RFC 9309;
crawl_delay,request_rateysite_maps. El comportamiento conAllowlargo y comodines, comprobado ejecutando el mismo robots.txt en Python 3.12.14, 3.13.15 y 3.14.8. ↩ -
RFC 9110, «HTTP Semantics» (https://www.rfc-editor.org/rfc/rfc9110.html, consultado el 2026-10-23):
Retry-After(sección 10.2.3), peticiones condicionales conIf-None-MatcheIf-Modified-Since(sección 13.1) y 304 Not Modified (sección 15.4.5). El código 429 se define en el RFC 6585 (https://www.rfc-editor.org/rfc/rfc6585.html), que permite acompañarlo deRetry-After. ↩ ↩2 -
HTTPX, «Transports» (https://www.python-httpx.org/advanced/transports/, consultado el 2026-10-23): las peticiones «will be retried the given number of times in case an httpx.ConnectError or an httpx.ConnectTimeout occurs»; para otros casos remite a librerías de reintentos como tenacity. ↩
-
Playwright para Python, «Getting started - Library» (https://playwright.dev/python/docs/library, consultado el 2026-10-23):
pip install playwrightyplaywright install, que instala los navegadores;new_contextadmiteuser_agentylocale(https://playwright.dev/python/docs/api/class-browser). ↩ -
Reglamento (UE) 2016/679, texto publicado en el DOUE y reproducido por el BOE (https://www.boe.es/buscar/doc.php?id=DOUE-L-2016-80807, consultado el 2026-10-23): artículos 4.1, 5.1.c, 6.1.f y 14.5.b. La versión en EUR-Lex es https://eur-lex.europa.eu/eli/reg/2016/679/oj. ↩
-
AEPD, criterio «Herramientas de data scraping para la evaluación de tendencias», 25 de noviembre de 2025 (procedimiento AI/00059/2024, https://www.aepd.es/informes-y-resoluciones/criterios-juridicos-aepd/herramientas-data-scraping-para-evaluacion-de-tendencias, consultado el 2026-10-23). La página advierte de que el resumen no es la publicación oficial. ↩
-
CEPD (EDPB), «Guidelines 03/2026 on web scraping in the context of generative AI», consulta pública del 8 de julio al 30 de octubre de 2026 (https://www.edpb.europa.eu/public-consultations/guidelines-032026-on-web-scraping-in-the-context-of-generative-ai_en), y nota del 8 de julio de 2026 (https://www.edpb.europa.eu/news/edpb-sheds-light-on-anonymisation-and-web-scraping-for-generative-ai-and-adopts-final-version_en), consultadas el 2026-10-23. Es un texto en consulta y puede cambiar. ↩
-
Directiva 96/9/CE, artículos 7.1, 7.5 y 10.1 (EUR-Lex: https://eur-lex.europa.eu/eli/dir/1996/9/oj; texto en español leído en OMPI Lex, https://www.wipo.int/wipolex/es/text/126790); Real Decreto Legislativo 1/1996, Ley de Propiedad Intelectual, artículo 133 (https://www.boe.es/buscar/act.php?id=BOE-A-1996-8930). Consultados el 2026-10-23. ↩
-
Real Decreto-ley 24/2021, de 2 de noviembre, artículo 67, «Minería de textos y datos», que transpone la Directiva (UE) 2019/790 (https://www.boe.es/buscar/act.php?id=BOE-A-2021-17910, consultado el 2026-10-23). ↩
Respuesta plegada en cada pregunta
Autoevaluación
Un robots.txt contiene
Disallow: /empleo/y, más abajo,Allow: /empleo/ofertas/. Según el RFC 9309, ¿se puede rastrear/empleo/ofertas/E01?- ANo: la primera regla que coincide es
Disallow - BSí: manda la regla que coincide con más caracteres, y
Allow: /empleo/ofertas/es más larga - CSolo si el
User-agentes* - DDepende del
Crawl-delay
Ver respuesta
Respuesta B. Sí: manda la regla que coincide con más caracteres, y
Allow: /empleo/ofertas/es más largaEl RFC 9309 aplica la coincidencia más específica (la de más octetos) y, en empate,
Allow. Python 3.13.15 y 3.14.8 lo hacen así; Python 3.12.14 aplicaba la primera regla que coincidía y devolvía False (comprobado en la lección).- ANo: la primera regla que coincide es
El servidor responde 503 al pedir el robots.txt. ¿Qué debe hacer el rastreador según el RFC 9309?
- ARastrear todo, porque no hay reglas
- BRastrear solo la página de inicio
- CSuponer que está todo prohibido hasta que el robots.txt vuelva a estar disponible
- DUsar el robots.txt de otra web parecida
Ver respuesta
Respuesta C. Suponer que está todo prohibido hasta que el robots.txt vuelva a estar disponible
Un error 5xx significa «inaccesible» y el rastreador debe suponer prohibición total. Un 4xx (no existe) permite rastrear.
urllib.robotparser.read()sigue esa regla, y además trata 401 y 403 como prohibición total.¿Qué
User-Agentes el adecuado para un rastreador de precios en un repositorio compartido?- AEl de un navegador, para que no lo bloqueen
- BEl nombre del autor y su correo personal
- CEl que pone la librería por defecto
- DUn nombre de producto propio y una URL con información del rastreador, sin datos personales
Ver respuesta
Respuesta D. Un nombre de producto propio y una URL con información del rastreador, sin datos personales
El nombre propio permite al sitio aplicar sus reglas de robots.txt al rastreador (el RFC 9309 empareja el
User-agentcon ese nombre) y la URL le da un contacto. Disfrazarse de navegador impide al sitio aplicar sus reglas y un correo personal expone datos personales.Al volver a rastrear un producto, el cliente envía
If-None-Matchcon elETagguardado y el servidor responde 304. ¿Qué significa?- AQue la página no ha cambiado: se reutiliza la copia guardada y el servidor no envía el cuerpo
- BQue el producto ya no existe
- CQue el rastreador está bloqueado
- DQue hay que reintentar más tarde
Ver respuesta
Respuesta A. Que la página no ha cambiado: se reutiliza la copia guardada y el servidor no envía el cuerpo
304 Not Modified es la respuesta a una petición condicional que se cumple. En la lección, la segunda pasada descarga un 76 % menos de bytes: menos carga para el sitio y el mismo dato.
Las ofertas de empleo que se rastrean incluyen el nombre y el correo de la persona de contacto. ¿Qué se hace con ellos si el estudio solo necesita contar ofertas por empresa y categoría?
- ASe guardan por si acaso
- BSe guardan cifrados
- CNo se guardan: se descartan en el parseo, porque el principio de minimización limita los datos a lo necesario para el fin
- DSe publican con el resto del dataset
Ver respuesta
Respuesta C. No se guardan: se descartan en el parseo, porque el principio de minimización limita los datos a lo necesario para el fin
Un nombre y un correo son datos personales (artículo 4 del RGPD) y el artículo 5.1.c exige datos «limitados a lo necesario». Si el fin no los necesita, lo correcto es no recogerlos.
Ideas clave
- Antes de rastrear: ¿hay API, descarga oficial o licencia? ¿Qué dicen el robots.txt y los términos de uso? ¿Hay datos personales? Nunca detrás de un inicio de sesión ni saltando controles técnicos.
- El robots.txt (RFC 9309) se empareja por nombre de rastreador, gana la regla más larga, admite
*y$, un 5xx es prohibición total y se puede guardar 24 horas como máximo. No es una autorización ni un control de acceso. urllib.robotparser.read()descarga sin tuUser-Agent: descarga el robots.txt con tu cliente y pásalo aparse(). Comprueba el emparejamiento en tu versión de Python: 3.12.14 no sigue la regla de la más larga ni los comodines; 3.13.15 y 3.14.8 sí.- Un cliente responsable se identifica sin datos personales, va despacio (
Crawl-delayo menos), respetaRetry-Afteren los 429, reintenta con espera creciente, guardaETagyLast-Modifiedy pregunta con peticiones condicionales. - JSON-LD y los atributos semánticos son más estables que el texto visible: en la tienda local, una expresión regular sobre el texto falla en los 12 productos rebajados; el selector CSS y el JSON-LD aciertan en los 30.
- En la UE, scrapear datos personales es tratamiento (RGPD); una base de datos puede tener derecho sui generis (Directiva 96/9/CE); la excepción de minería de textos y datos cede si el titular se reserva el uso por medios de lectura mecánica. No es asesoramiento jurídico.
Recursos
Documentación
- RFC 9309: Robots Exclusion Protocol
- Python: urllib.robotparser
- HTTPX: transportes (reintentos y MockTransport)
- RFC 9110: HTTP Semantics (peticiones condicionales, Retry-After)
- Reglamento (UE) 2016/679 (RGPD), texto en el BOE
- AEPD: herramientas de data scraping para la evaluación de tendencias (criterio de 25-11-2025)
- CEPD: Guidelines 03/2026 on web scraping in the context of generative AI (consulta pública)