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.

Fig. — Las 80 peticiones del rastreador de la lección contra la tienda local, una cada 2 segundos (el Crawl-delay): los 429 del servidor (destacados) añaden la espera de Retry-After, 5 segundos
Lecciones del nivel 6
  1. 6.1 · NLP financiero clásico y moderno

    1. 6.1.1 Tokenización, embeddings, diccionarios financieros y FinBERT
    2. 6.1.2 Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales
    3. 6.1.3 Event studies con señales de texto, alineación temporal y topic modeling
  2. 6.2 · LLMs para investigación y trading

    1. 6.2.1 Cómo funciona un LLM: del siguiente token a la API
    2. 6.2.2 Prompting estructurado: JSON que se puede validar
    3. 6.2.3 Guidance, KPIs y tono: qué extrae bien un LLM
    4. 6.2.4 Fine-tuning ligero para tareas financieras: LoRA y QLoRA
    5. 6.2.5 Sesgo de anticipación en LLMs: el modelo que ya conoce el futuro
  3. 6.3 · RAG financiero

    1. 6.3.1 Embeddings y bases vectoriales: FAISS, Chroma, pgvector y Qdrant
    2. 6.3.2 Chunking de documentos financieros: tablas, notas al pie y XBRL
    3. 6.3.3 RAG sobre informes, folletos y KID, y cómo evaluarlo
  4. 6.4 · Agentes de IA para trading

    1. 6.4.1 Agentes con herramientas: tool calling, ReAct y planificación
    2. 6.4.2 Marcos de agentes y MCP
    3. 6.4.3 Arquitecturas multiagente tipo TradingAgents
    4. 6.4.4 Evidencia y límites de los agentes
    5. 6.4.5 Guardarraíles para agentes: el agente propone, el motor decide
    6. 6.4.6 Agentes de investigación: hipótesis, alpha mining y revisión
  5. 6.5 · IA como copiloto del quant

    1. 6.5.1 Asistentes de código con un flujo de tests primero
    2. 6.5.2 Revisión automática de backtests y migraciones de código
    3. 6.5.3 Riesgos del código generado por IA
  6. 6.6 · Big data para trading

    1. 6.6.1 Cuándo los datos son «big»: ticks, libros, opciones y universos
    2. 6.6.2 Parquet, Arrow, Polars en streaming y DuckDB fuera de memoria
    3. 6.6.3 Dask, Spark y Ray: de una máquina a un clúster
    4. 6.6.4 Bases de series temporales: TimescaleDB, QuestDB, ClickHouse, ArcticDB y kdb+
    5. 6.6.5 Almacenamiento en la nube, lakehouse (Delta, Iceberg) y GPU (RAPIDS, cuDF)
  7. 6.7 · Datos alternativos

    1. 6.7.1 Panorama y tamaño del mercado de datos alternativos
    2. 6.7.2 Tipos de datos alternativos: qué mide cada uno
    3. 6.7.3 Web scraping responsable
    4. 6.7.4 Proveedores y due diligence de datos alternativos
    5. 6.7.5 Del dato alternativo a la señal
    6. 6.7.6 Satélite, visión por computador y cadena de suministro

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:

sínosínoprohíbenpermitensínoVía oficialUsarlaDetrás de loginNo rastrearrobots y términosDatos personalesMinimizar o noRastrear despacio
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»:

IN [01]python
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")
OUT [01]Servidor local: tienda.example
30 productos; 7 cambian de precio el día 2; 12 con precio tachado; 8 ofertas de empleo

robots.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 9309Qué 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, allowEl orden de las líneas no importa
* es cualquier secuencia de caracteres y $ el final de la rutaDisallow: /*?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 totalUn error del servidor no es un permiso
La copia guardada no debe usarse más de 24 horas, salvo si el fichero es inaccesibleHay 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:

IN [02]python
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))
OUT [02]Leer el robots.txt con el nombre del rastreador
read() se identifica como: Python-urllib/3.14
httpx se identifica como: CursoPPT-precios/0.1 (+https://example.com/rastreador) -> 200
Crawl-delay: 2

Con 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:

IN [03]python
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)
OUT [03]Qué se puede rastrear
                         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:

sí429200URLrobots.txtLímite de ritmoGETRetry-AfterCaché
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-Agent de un navegador, no.
  • Ritmo: una petición cada Crawl-delay segundos 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 con If-None-Match e If-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 de Retry-After.); ante un 5xx, reintentar con espera creciente y algo de azar, y abandonar tras unos intentos. El parámetro retries del 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:

IN [04]python
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")
OUT [04]Cliente con robots.txt, ritmo y reintentos
robots.txt prohíbe /cuenta/pedidos
200 1352 bytes; reloj: 0.0 s

Extraer 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:

IN [05]python
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())
OUT [05]Recorrer el listado y las fichas
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:

IN [06]python
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%}")
OUT [06]Precio visible frente a JSON-LD
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):

IN [07]python
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)
OUT [07]Segunda pasada con If-None-Match
{304: 23, 200: 7}
bytes de fichas: día 1 11888 | día 2 2832 (76% menos)
cambios detectados = verdad: True
Fig. — Bytes que descarga el rastreador de la lección por las 30 fichas de producto: con peticiones condicionales, el día 2 solo baja las 7 que cambiaron de precio

Siete 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:

IN [08]python
v = cache["/producto/P01"]
r = bot.get("/producto/P01", {"If-Modified-Since": v["lm"]})
print(v["lm"], "->", r.status_code)
OUT [08]If-Modified-Since
Thu, 01 Oct 2026 08:00:00 GMT -> 304

Para 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:

IN [09]python
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())
OUT [09]Lo que ve un cliente HTTP en una página dinámica
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).:

IN [10]python
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:

IN [11]python
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])
OUT [11]El mismo robots.txt con requests
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:

IN [12]python
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)
OUT [12]Ofertas de empleo sin datos personales
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 Petroleum

Ocho 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:

IN [13]python
print("peticiones:", len(registro), "| 429:",
      sum(c == 429 for _, _, c in registro),
      "| reloj simulado:", reloj.now(), "s")
cliente.close()
servidor.shutdown()
OUT [13]Cerrar
peticiones: 80 | 429: 5 | reloj simulado: 173.0 s

Cuatro marcos se cruzan en un rastreo:

  1. 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.
  2. 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..
  3. 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.
  4. 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 otro User-Agent y 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

  1. Cambia el servidor para que responda 503 al pedir /robots.txt y comprueba qué hace RobotFileParser.read(). ¿Coincide con el RFC 9309?
Solución
IN [14]python
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()
OUT [14]Solución: robots.txt inaccesible
¿se puede rastrear algo? False

read() 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).

  1. Añade al Rastreador una 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
IN [15]python
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)
OUT [15]Solución: robots.txt con caducidad
¿permitido? True
descargas del robots.txt: 2

Dos 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.

  1. 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

  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». ↩

  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. ↩

  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 con If-None-Match e If-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 de Retry-After. ↩ ↩2

  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. ↩

  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). ↩

  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. ↩

  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. ↩

  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. ↩

  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. ↩

  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). ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. 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-agent es *
    • 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 larga

    El 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).

  2. 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.

  3. ¿Qué User-Agent es 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-agent con 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.

  4. Al volver a rastrear un producto, el cliente envía If-None-Match con el ETag guardado 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.

  5. 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 tu User-Agent: descarga el robots.txt con tu cliente y pásalo a parse(). 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-delay o menos), respeta Retry-After en los 429, reintenta con espera creciente, guarda ETag y Last-Modified y 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