Una API de web scraping elimina la parte más difícil de la recolección: proxies, renderizado, reintentos y bloqueos. Lo que no elimina es todo lo que sucede después de que llega la respuesta, y ahí es donde la mayoría de los pipelines realmente fallan.
Los fallos son silenciosos. Filas duplicadas por un reintento que nadie deduplicó. Una columna de precios llena de cadenas como "$16.99" que alguien convirtió a número en un dashboard. Un rediseño del sitio que convirtió un campo en null hace tres semanas. Un error del extractor que no se puede corregir sin pagar por volver a obtener todo de nuevo.
Esta guía trata sobre el lado de la carga: llevar la salida de la API de scraping a una base de datos SQL de forma fiable, a volumen, sin perder la capacidad de explicar o reproducir lo que se almacenó. Los ejemplos usan PostgreSQL y la Shifter Web Scraping API, y los patrones se aplican igual a cualquier base de datos SQL.
Empieza por la respuesta que realmente obtienes
Con la Shifter Web Scraping API eliges entre dos formas de respuesta.
HTML en bruto, donde tú haces el parseo. O JSON estructurado, pasando extract_rules que asignan selectores CSS a campos:
curl "https://scrape.shifter.io/v1?api_key=YOUR_API_KEY&url=https://shop.example.com/p/42&render_js=1&extract_rules=%7B%22title%22%3A%7B%22selector%22%3A%22h1%22%2C%22output%22%3A%22text%22%7D%2C%22price%22%3A%7B%22selector%22%3A%22.price%22%2C%22output%22%3A%22text%22%7D%7D"
# {"title": "Example Product", "price": "$19.99"}
Dos propiedades de esa salida condicionan tu esquema. Un campo cuyo selector no coincide con nada vuelve como null en lugar de hacer fallar la solicitud, así que un elemento ausente y un selector roto se ven idénticos en la respuesta. Y las salidas de tipo texto son cadenas de visualización, así que precios, valoraciones y fechas llegan formateados para humanos, no tipados para una base de datos. La sintaxis de las reglas, incluida la extracción de listas para páginas de resultados, está en la documentación de reglas de extracción.
Tres capas, no una tabla
El diseño que sobrevive al contacto con producción separa lo que recibiste de lo que concluiste.
| Capa | Contenido | Por qué existe |
|---|---|---|
| Aterrizaje en bruto | Cada respuesta exitosa, tal como se recibió, con metadatos de la petición | Reproducir la extracción sin volver a obtener los datos |
| Observaciones tipadas | Valores parseados, tipados y validados con un estado de parseo | Lo que consultan los analistas y las aplicaciones |
| Estado actual | El último valor por entidad, derivado de las observaciones | Lecturas rápidas para productos y dashboards |
La capa en bruto es la que los equipos se saltan y luego lamentan. Los créditos se gastan en solicitudes exitosas, así que un error en tu parseo que solo se puede corregir volviendo a obtener los datos cuesta el rastreo completo dos veces. Aterriza la respuesta primero, parsea después, y una corrección del parser se convierte en una consulta de repetición.
La tabla de aterrizaje
CREATE TABLE scrape_raw (
job_id text PRIMARY KEY,
source_url text NOT NULL,
market text NOT NULL,
fetched_at timestamptz NOT NULL,
http_status smallint NOT NULL,
body jsonb NOT NULL,
body_hash text NOT NULL,
extractor_version text NOT NULL
);
CREATE INDEX scrape_raw_url_time ON scrape_raw (source_url, fetched_at DESC);
Hay varias decisiones deliberadas ahí.
job_id es la clave de idempotencia, calculada antes de la solicitud a partir de la URL y la ventana de programación, de modo que un reintento del mismo trabajo lógico aterriza en la misma clave en lugar de crear una segunda fila. extractor_version registra qué conjunto de reglas de extracción produjo el cuerpo, lo que permite distinguir más adelante un cambio del sitio de un cambio de las reglas. market registra dónde se hizo la observación, porque la misma URL puede devolver contenido diferente según el país. Y la clave de API nunca se almacena en los metadatos de la solicitud que persistes, ya que las credenciales en una base de datos son credenciales en cada copia de seguridad.
Cargar de forma idempotente
import hashlib
import json
import os
import psycopg
import requests
from psycopg.types.json import Jsonb
API = "https://scrape.shifter.io/v1"
RULES = {
"title": {"selector": "h1", "output": "text"},
"price": {"selector": ".price", "output": "text"},
}
EXTRACTOR_VERSION = "product-v3"
INSERT_RAW = """
INSERT INTO scrape_raw
(job_id, source_url, market, fetched_at, http_status, body, body_hash, extractor_version)
VALUES (%s, %s, %s, now(), %s, %s, %s, %s)
ON CONFLICT (job_id) DO NOTHING
"""
def job_id(url: str, market: str, window: str) -> str:
return hashlib.sha256(f"{url}|{market}|{window}".encode()).hexdigest()
def fetch(url: str, market: str) -> requests.Response:
params = {
"api_key": os.environ["SHIFTER_API_KEY"],
"url": url,
"render_js": 1,
"country": market,
"extract_rules": json.dumps(RULES),
}
return requests.get(API, params=params, timeout=90)
def land(conn: psycopg.Connection, url: str, market: str, window: str) -> None:
resp = fetch(url, market)
if resp.status_code != 200:
raise RuntimeError(f"{resp.status_code} for {url}")
body = resp.text
with conn.cursor() as cur:
cur.execute(
INSERT_RAW,
(
job_id(url, market, window),
url,
market,
resp.status_code,
Jsonb(json.loads(body)),
hashlib.sha256(body.encode()).hexdigest(),
EXTRACTOR_VERSION,
),
)
ON CONFLICT (job_id) DO NOTHING es lo que hace que la carga sea segura de reintentar. Ya sea que el fallo haya sido de la red, de tu worker o de la base de datos, ejecutar el trabajo de nuevo no puede producir un duplicado. Para MySQL, el equivalente es una clave única con INSERT IGNORE o ON DUPLICATE KEY UPDATE.
Rendimiento: desacoplar la obtención de la carga
El lado de la obtención tiene un techo estricto fijado por el límite de concurrencia de tu plan, y las solicitudes por encima de él devuelven 429. La base de datos tiene su propio techo, y normalmente es el que los equipos alcanzan primero al abrir una conexión y una transacción por página scrapeada.
Pon una cola entre ambos. Los workers de obtención, dimensionados según tu límite de concurrencia, escriben las respuestas en la cola. Un número reducido de workers de carga la vacían en lotes. Para volúmenes constantes, el executemany de psycopg en lotes de unos pocos cientos de filas es suficiente. Para grandes cargas retroactivas, haz COPY del lote a una tabla de staging sin registro y fusiónalo en una sola instrucción:
INSERT INTO scrape_raw
SELECT * FROM scrape_raw_staging
ON CONFLICT (job_id) DO NOTHING;
Eso convierte miles de idas y vueltas en una sola, y mantiene intacta la garantía de idempotencia.
Para renderizados largos, la API puede entregar de forma asíncrona: pasa webhook=<URL> y la respuesta se publica en tu endpoint cuando esté lista. Haz que ese receptor también sea idempotente en job_id, porque cualquier entrega HTTP puede terminar siendo reintentada por una parte u otra.
Asignar los errores de la API al comportamiento del pipeline
No todos los códigos de estado son candidatos a reintento, y un cargador que los trata de manera uniforme o bien bombardea una configuración rota o bien se rinde ante fallos transitorios. La tabla completa está en errores y límites.
| Estado | Comportamiento del pipeline |
|---|---|
408, 422, 500 | Reintentar con backoff exponencial |
429 | Reducir el ritmo y bajar la concurrencia de workers |
400, 401, 403 | Error de configuración: enviar a una cola de mensajes muertos y alertar, nunca reintentar |
509 | Créditos agotados: detener la fase de obtención y alertar, reintentar no ayuda |
Las solicitudes fallidas y las respuestas 4xx o 5xx del objetivo no se cobran, y la API ya reintenta los fallos transitorios hasta tres veces antes de devolver una respuesta, así que tus propios reintentos cuestan tiempo, no créditos. Aun así cuestan tiempo, que es por lo que el backoff importa.
Tipar las observaciones
Aquí es donde las cadenas de visualización se convierten en datos, y donde se introducen la mayoría de los errores silenciosos.
CREATE TABLE price_observation (
source_url text NOT NULL,
market text NOT NULL,
observed_at timestamptz NOT NULL,
price_amount numeric(12,2),
currency char(3),
raw_price text,
parse_status text NOT NULL,
job_id text NOT NULL REFERENCES scrape_raw (job_id),
PRIMARY KEY (source_url, market, observed_at)
);
Tres reglas la mantienen honesta.
Guarda la cadena en bruto junto al valor parseado. raw_price es lo que te permite auditar un número sospechoso sin volver a obtener los datos.
Parsea por mercado, no globalmente. "1.299,00" y "1,299.00" son el mismo precio bajo convenciones diferentes, y un símbolo de moneda no es una moneda: $ es dólares estadounidenses, canadienses o australianos según la tienda. Resuelve el código ISO a partir del símbolo y el mercado juntos.
Registra por qué un valor es null. Un parse_status de missing, unparseable u ok separa “la página no tenía precio” de “nuestro parser falló”, algo que el null de la API no puede indicar por sí solo.
Para el estado actual, deriva en lugar de mantener. En PostgreSQL:
CREATE VIEW price_current AS
SELECT DISTINCT ON (source_url, market) *
FROM price_observation
WHERE parse_status = 'ok'
ORDER BY source_url, market, observed_at DESC;
Una vista derivada no puede desincronizarse del historial que resume.
Detecta la deriva del esquema antes que tus usuarios
Los sitios cambian su marcado, y un selector cambiado no lanza un error. Devuelve null, la solicitud tiene éxito, se gasta un crédito, y la fila aterriza pareciendo válida.
La defensa es un monitor de tasa de nulos por campo, por fuente, por versión de extractor. Calcula la proporción de estados de parseo missing para cada campo en una ventana móvil y alerta cuando se desvía bruscamente de su línea base. Un campo de precio que pasa del 2% de faltantes al 60% de la noche a la mañana es un rediseño, y detectarlo el mismo día es la diferencia entre un selector corregido y tres semanas de historial inutilizable.
Cuando lo corrijas, sube extractor_version y reproduce las filas en bruto afectadas a través del nuevo parser. Ese es el beneficio de aterrizar las respuestas en bruto.
Retención y particionamiento
Las tablas de aterrizaje en bruto crecen más rápido y se leen menos. Particiónalas por fecha de obtención, conservalas el tiempo suficiente para cubrir tu ventana de repetición realista, y elimina las particiones antiguas en lugar de borrar filas. Las tablas de observaciones son el registro histórico y normalmente merecen una retención más larga, particionadas del mismo modo.
Qué monitorizar
| Métrica | Qué detecta |
|---|---|
| Obtenciones exitosas frente a filas aterrizadas | Pérdidas del cargador entre la API y la base de datos |
Conflictos de job_id duplicado | Tormentas de reintentos o solapamiento de programación |
| Tasa de nulos por campo y versión de extractor | Cambios de marcado y selectores rotos |
| Profundidad de la cola y retraso de carga | Un cargador que se queda atrás respecto a la fase de obtención |
Créditos consumidos frente a filas que parsearon ok | Dinero gastado en respuestas que no pudiste usar |
Esa última métrica es la vista de coste que importa: créditos por fila utilizable, no créditos por solicitud. El uso y la tasa de errores de la propia API son visibles en el panel, bajo Web Scraping API.
Preguntas frecuentes
¿Debo almacenar HTML o JSON extraído en la capa en bruto?
El JSON extraído es mucho más pequeño y normalmente suficiente. Almacena HTML solo para fuentes en las que esperes cambiar la lógica de extracción con frecuencia, y da a esa tabla una retención corta.
¿Es JSONB suficientemente bueno para consultar directamente?
Para exploración, sí. Para cualquier cosa de la que dependa un producto o un dashboard, promueve los campos a columnas tipadas, donde la base de datos pueda forzar tipos y usar índices normales.
¿Cómo evito pagar por páginas que no han cambiado?
Cada solicitud exitosa cuesta un crédito, así que el ahorro tiene que venir de obtener menos, no de escribir menos. Usa señales económicas, como una página de listado o de mapa del sitio, para decidir qué páginas de detalle realmente necesitan obtenerse.
¿Esto funciona también con la Amazon API?
Sí. Sus respuestas ya son JSON estructurado, así que las reglas de extracción quedan fuera, pero los precios siguen llegando como cadenas de visualización y los patrones de aterrizaje, tipado y deriva se aplican sin cambios.
En resumen
Una API de scraping resuelve la recolección. El diseño de tu base de datos decide si lo que recolectaste se mantiene fiable. Aterriza cada respuesta exitosa con una clave de idempotencia y una versión de extractor, tipa los valores por mercado manteniendo la cadena en bruto, registra por qué un valor es null, deriva el estado actual en lugar de mantenerlo, y vigila las tasas de nulos por campo para que los cambios de marcado salgan a la luz el mismo día.
Para Amazon en concreto, las opciones se comparan en las mejores API de web scraping para monitorización de Amazon, y una versión para el sector inmobiliario del mismo pipeline está en cómo las empresas inmobiliarias usan las API de web scraping. El producto está en la página de Web Scraping API, con planes en la página de precios.