---
title: "6.7.3 Web scraping responsable: httpx, robots.txt y RGPD"
description: "Scraping con httpx, requests y BeautifulSoup contra un servidor local: robots.txt (RFC 9309), ritmo, reintentos, peticiones condicionales y RGPD."
url: "https://pythonparatrading.com/curso-python-trading/nivel-6/web-scraping-responsable/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-6/web-scraping-responsable.png"
---

# 6.7.3 · 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.

- Curso: [Nivel 6: IA generativa, LLMs, agentes, big data y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/)
- Módulo: 6.7 · Datos alternativos
- Duración: 75 min
- Requisitos: [1.3.7 · Licencias y términos de uso de datos](https://pythonparatrading.com/curso-python-trading/nivel-1/licencias-y-terminos-de-uso-de-datos/), [1.4.1 · Descarga por REST y websockets: paginación, límites y reintentos](https://pythonparatrading.com/curso-python-trading/nivel-1/descarga-rest-websockets-y-reintentos/), [6.1.2 · Fuentes de texto: EDGAR, CNMV, transcripciones y redes sociales](https://pythonparatrading.com/curso-python-trading/nivel-6/fuentes-de-texto-edgar-cnmv-transcripciones-y-redes/)

## Al terminar esta lección

- Decidir cuándo scrapear y cuándo no: API, descarga oficial o licencia antes que rastreo, y nunca detrás de un inicio de sesión.
- Leer y aplicar un robots.txt con `urllib.robotparser` según el RFC 9309 (coincidencia más larga, comodines, errores 4xx y 5xx, caché de 24 horas) y saber qué no garantiza.
- Escribir un cliente con httpx (y requests) que se identifica sin datos personales, limita su ritmo, respeta `Retry-After`, reintenta con espera creciente y usa `ETag` e `If-Modified-Since`.
- Extraer precios y ofertas con BeautifulSoup y JSON-LD, medir el acierto contra la verdad del servidor y descartar los datos personales en la ingesta.
- Situar el scraping en el marco de la UE: términos de uso, RGPD (artículos 4, 5, 6 y 14), derecho sui generis sobre bases de datos y excepción de minería de textos y datos.

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

```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»:

```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")
```
```output
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
9309[^1]. 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:

```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))
```
```output
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:

```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)
```
```output
                         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 emparejamiento[^2]: si el
rastreador corre en otra versión, hay que probarlo con un robots.txt conocido, como esta tabla.

<aside class="callout" data-kind="aviso">

El robots.txt no es el permiso: es una petición del sitio. Que una ruta esté permitida no autoriza a ignorar los
términos de uso, a sobrecargar el servidor ni a recoger datos personales. Y que no exista robots.txt (un 404) tampoco
convierte el sitio en una fuente libre.

</aside>

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

```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.3[^3]); 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 503[^4]: 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:

```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")
```
```output
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`:

```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())
```
```output
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:

```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%}")
```
```output
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 13[^3]):

```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)
```
```output
{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.* Barras horizontales: día 1, 11.888 bytes; día 2 sin peticiones condicionales, 11.886; día 2 con If-None-Match, 2.832 (destacado, un 76 % menos).

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

```python
v = cache["/producto/P01"]
r = bot.get("/producto/P01", {"If-Modified-Since": v["lm"]})
print(v["lm"], "->", r.status_code)
```
```output
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:

```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())
```
```output
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 navegadores[^5]:

```python skip
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:

```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])
```
```output
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:

```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)
```
```output
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:

```python
print("peticiones:", len(registro), "| 429:",
      sum(c == 429 for _, _, c in registro),
      "| reloj simulado:", reloj.now(), "s")
cliente.close()
servidor.shutdown()
```
```output
peticiones: 80 | 429: 5 | reloj simulado: 173.0 s
```

## El marco legal en la UE

<aside class="callout" data-kind="riesgo">

Lo que sigue resume normas y criterios publicados por las autoridades, con su fuente. No es asesoramiento jurídico: la
legalidad de un rastreo concreto depende del sitio, de sus términos, de los datos, del uso y de la jurisdicción, y se
consulta con un abogado antes de montar un proceso de recogida.

</aside>

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)[^6]. 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 identificables[^7]. 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ón[^8].
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 Intelectual[^9]. 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)[^10]. 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.**

```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()
```
```output
¿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).

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

```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)
```
```output
¿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.

3. 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*.

[^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`.
[^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).

## 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`?

   - A. No: la primera regla que coincide es `Disallow`
   - B. Sí: manda la regla que coincide con más caracteres, y `Allow: /empleo/ofertas/` es más larga
   - C. Solo si el `User-agent` es `*`
   - D. Depende del `Crawl-delay`

   *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?

   - A. Rastrear todo, porque no hay reglas
   - B. Rastrear solo la página de inicio
   - C. Suponer que está todo prohibido hasta que el robots.txt vuelva a estar disponible
   - D. Usar el robots.txt de otra web parecida

   *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?

   - A. El de un navegador, para que no lo bloqueen
   - B. El nombre del autor y su correo personal
   - C. El que pone la librería por defecto
   - D. Un nombre de producto propio y una URL con información del rastreador, sin datos personales

   *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?

   - A. Que la página no ha cambiado: se reutiliza la copia guardada y el servidor no envía el cuerpo
   - B. Que el producto ya no existe
   - C. Que el rastreador está bloqueado
   - D. Que hay que reintentar más tarde

   *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?

   - A. Se guardan por si acaso
   - B. Se guardan cifrados
   - C. No se guardan: se descartan en el parseo, porque el principio de minimización limita los datos a lo necesario para el fin
   - D. Se publican con el resto del dataset

   *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

### Documentación

- [RFC 9309: Robots Exclusion Protocol](https://www.rfc-editor.org/rfc/rfc9309.html) · rfc-editor.org
- [Python: urllib.robotparser](https://docs.python.org/3/library/urllib.robotparser.html) · docs.python.org
- [HTTPX: transportes (reintentos y MockTransport)](https://www.python-httpx.org/advanced/transports/) · python-httpx.org
- [RFC 9110: HTTP Semantics (peticiones condicionales, Retry-After)](https://www.rfc-editor.org/rfc/rfc9110.html) · rfc-editor.org
- [Reglamento (UE) 2016/679 (RGPD), texto en el BOE](https://www.boe.es/buscar/doc.php?id=DOUE-L-2016-80807) · boe.es
- [AEPD: herramientas de data scraping para la evaluación de tendencias (criterio de 25-11-2025)](https://www.aepd.es/informes-y-resoluciones/criterios-juridicos-aepd/herramientas-data-scraping-para-evaluacion-de-tendencias) · aepd.es
- [CEPD: Guidelines 03/2026 on web scraping in the context of generative AI (consulta pública)](https://www.edpb.europa.eu/public-consultations/guidelines-032026-on-web-scraping-in-the-context-of-generative-ai_en) · edpb.europa.eu

Lección anterior: [6.7.2 · Tipos de datos alternativos: qué mide cada uno](https://pythonparatrading.com/curso-python-trading/nivel-6/tipos-de-datos-alternativos/)

Lección siguiente: [6.7.4 · Proveedores y due diligence de datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-6/proveedores-y-due-diligence-de-datos/)
