Pegue una URL de producto en ChatGPT y a menudo obtendrá un precio. Abra la página en producción: el número es incorrecto, desactualizado o inventado. No se trata de un extractor defectuoso. El modelo nunca abrió la URL. Infirió el valor a partir de los datos de entrenamiento y lo formuló con seguridad.
Trate a ChatGPT como un parser, no como un navegador. Piloterr carga la página. Pruebe primero JSON-LD o un selector CSS. Solo el texto restante se envía a un modelo OpenAI. Pydantic descarta cualquier objeto que no cumpla el esquema.
La demostración utiliza books.toscrape.com, un catálogo público destinado a la práctica. Limítese a páginas públicas, respete el robots.txt y consulte ¿El scraping es legal? antes de apuntar a un sitio comercial.
En este tutorial:
- Cargar una ficha con Website Crawler (1 crédito).
- Leer título, precio, stock y UPC con CSS cuando el marcado sea estable.
- Recurrir a structured outputs de
gpt-5.6-lunacuando no lo sea. - Dividir una categoría en
article.product_pod, no a mitad de una palabra.
Dos tareas, dos herramientas
Un modelo de lenguaje lee texto. No ejecuta JavaScript, no rota direcciones IP y no supera un challenge de Cloudflare. Si le pide que recupere una URL, obtiene una página bloqueada, una SPA vacía o un payload inventado.
Conserve una separación explícita:
- Cargar una página completa (Piloterr, o un endpoint de la biblioteca si existe).
- Extraer campos (CSS, JSON-LD o un modelo).
ChatGPT es pertinente cuando el marcado es irregular, o cambia con tanta frecuencia que mantener un conjunto amplio de selectores cuesta más que un esquema. Amazon, LinkedIn y objetivos similares ya disponen de APIs mantenidas. Úselas.
Analizar el DOM antes de llamar al modelo
Empiece por un extractor CSS. En este catálogo el título es h1, el precio p.price_color y el UPC una fila de tabla. Esta vía consume menos tokens que gpt-5.6-luna y devuelve la misma cadena al día siguiente.
from bs4 import BeautifulSoup
from pydantic import BaseModel
class Book(BaseModel):
title: str
price: str
availability: str
upc: str | None = None
isbn: str | None = None
def extract_book_css(html: str) -> Book | None:
soup = BeautifulSoup(html, "html.parser")
title = soup.select_one("article.product_page h1")
price = soup.select_one("article.product_page p.price_color")
stock = soup.select_one("article.product_page p.instock.availability")
if not (title and price and stock):
return None
upc = None
for row in soup.select("article.product_page table tr"):
label = row.select_one("th")
value = row.select_one("td")
if label and value and label.get_text(strip=True) == "UPC":
upc = value.get_text(strip=True)
break
return Book(
title=title.get_text(strip=True),
price=price.get_text(strip=True),
availability=" ".join(stock.get_text().split()),
upc=upc,
isbn=None,
)
isbn permanece en None: esta página no tiene ISBN. No pida al modelo que invente uno.
Busque, en este orden:
- Una API de la biblioteca (Amazon Product y similares)
application/ld+jsonen la página- Un nodo CSS o XPath estable
- Un modelo para lo que quede: especificaciones no estructuradas, FAQ, textos que mezclan unidades y avisos
Los precios ya en claro pasan por Price-Parser sin LLM.
import json
def json_ld_blocks(html: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
blocks = []
for node in soup.select('script[type="application/ld+json"]'):
if not node.string:
continue
try:
parsed = json.loads(node.string)
except json.JSONDecodeError:
continue
blocks.extend(parsed if isinstance(parsed, list) else [parsed])
return blocks
Elija el modo de recuperación antes del prompt
Un modo inadecuado produce HTML vacío. El modelo «extrae» entonces desde una página de autenticación.
| Modo | Créditos | Cuándo |
|---|---|---|
| Website Crawler | 1 | HTML estático o APIs JSON. El más rápido. Sin JS. |
| Website Rendering | 2 | Contenido tras JavaScript. Necesita wait_in_seconds o wait_for. |
| Website WebUnlocker | 3 | Dominios en allowlist detrás de Cloudflare, DataDome, PerimeterX, Akamai. Sin JS. |
Comparativa: Crawler vs Rendering vs WebUnlocker. Este tutorial utiliza Crawler, porque books.toscrape.com se renderiza en servidor.
Cargar el HTML
Python 3.10+, una clave API de Piloterr y una clave OpenAI. Las dos en el entorno. Nunca en el script.
python --version
mkdir fetch-then-extract && cd fetch-then-extract
pip install requests beautifulsoup4 "openai>=2" pydantic
export PILOTERR_API_KEY="your-x-api-key"
export OPENAI_API_KEY="your-openai-key"
Crawler es un GET. query es la URL objetivo. El cuerpo JSON es la cadena HTML.
import os
import requests
API = "https://api.piloterr.com/v2/website/crawler"
KEY = os.environ["PILOTERR_API_KEY"]
PAGE = "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"
CATALOG = "https://books.toscrape.com/"
def fetch_html(url: str, endpoint: str = API, **extra: object) -> str:
response = requests.get(
endpoint,
headers={"x-api-key": KEY},
params={"query": url, "allow_redirects": "true", **extra},
timeout=60,
)
response.raise_for_status()
html = response.json()
if not isinstance(html, str) or len(html) < 200:
raise RuntimeError("Fetch returned too little HTML. Try Rendering or WebUnlocker.")
return html
Unos cientos de kilobytes es el comportamiento esperado. Unos cientos de caracteres suelen corresponder a una página de challenge. Cambie de modo. No pida al modelo que compense un HTML incompleto.
Para una ficha con un uso intensivo de JavaScript, el mismo modelo de solicitud se aplica a Rendering (2 créditos):
RENDER = "https://api.piloterr.com/v2/website/rendering"
def fetch_rendered(url: str) -> str:
return fetch_html(url, endpoint=RENDER, wait_in_seconds=4)
Documentación: Website Crawler y Website Rendering. Una llamada correcta cuesta 1 o 2 créditos. Los 4xx de cliente y la mayoría de 5xx de plataforma no. Consulte precios y la versión Markdown /pricing.md.
Reducir el HTML antes de llamar al modelo
Los scripts, los SVG y el banner de cookies consumen tokens y ocultan los campos útiles.
def readable_text(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "noscript", "svg", "iframe"]):
tag.decompose()
main = soup.select_one("article.product_page") or soup.body or soup
return main.get_text("\n", strip=True)
Structured outputs, luego validación
Cuando el CSS falla, el prompt se convierte en el extractor. Nombre cada campo. Use null si el texto no indica el valor. client.responses.parse vincula la respuesta a la clase Pydantic: un objeto mal formado no se trata como un diccionario fiable.
from openai import OpenAI
client = OpenAI()
SYSTEM = (
"Read the page text and fill the schema. "
"Copy strings as they appear. "
"Use null when a key is absent. "
"Do not invent a title, price, UPC, or ISBN."
)
def extract_book_llm(text: str) -> Book:
response = client.responses.parse(
model="gpt-5.6-luna",
input=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Fill the Book schema from this page text.\n\n{text}"},
],
text_format=Book,
reasoning={"effort": "none"},
)
book = response.output_parsed
if book is None:
raise ValueError("Model returned no parsed object")
return book
gpt-5.6-luna basta para copiar campos. Pase a gpt-5.6 solo si dos precios se contradicen o una tabla se interpreta mal. Ejecute una muestra de 20 páginas, mida los fallos y elija el modelo. client.chat.completions.parse sigue funcionando si se queda en Chat Completions. Prefiera responses.parse con openai 2.x.
Un solo script
if __name__ == "__main__":
html = fetch_html(PAGE)
book = extract_book_css(html)
if book is None:
book = extract_book_llm(readable_text(html))
print(book.model_dump_json(indent=2))
Forma esperada en la ficha «A Light in the Attic» (el UPC está en la página, el ISBN no):
{
"title": "A Light in the Attic",
"price": "£51.77",
"availability": "In stock (22 available)",
"upc": "a897fe39b1053632",
"isbn": null
}
Un ISBN alucinado puede insertarse en una hoja de cálculo sin alerta, hasta que alguien realice un pedido.
Páginas largas: divida en las tarjetas de producto
Enviar una categoría entera en un solo prompt supera la ventana de contexto. No divida a 12 000 caracteres a mitad de una palabra. Analice la lista, luego extraiga una tarjeta.
def product_cards(html: str) -> list[str]:
soup = BeautifulSoup(html, "html.parser")
return [str(card) for card in soup.select("article.product_pod")]
def extract_catalog(html: str) -> list[Book]:
books: list[Book] = []
seen: set[str] = set()
for card_html in product_cards(html):
book = extract_book_css(card_html) or extract_book_llm(readable_text(card_html))
key = book.title.casefold()
if key in seen:
continue
seen.add(key)
books.append(book)
return books
Las tarjetas del listado no tienen tabla UPC. El CSS devuelve None y el modelo rellena título y precio a partir del texto de la tarjeta. Fusione por título, o conserve el href si lo almacenó.
print(extract_catalog(fetch_html(CATALOG)))
Deje que el agente cargue la página, conserve la misma separación
Pegar HTML en la interfaz de ChatGPT es adecuado para una demostración. El procesamiento diario se ejecuta en Python, o en una herramienta que el agente llama.
El MCP de Piloterr expone Crawler y Rendering en https://mcp.piloterr.com/ con el mismo x-api-key. Claude y Cursor pueden cargar una página. Valide el JSON en su código. Notas de especificación: MCP de Piloterr el 2026-07-28.
Prácticas a evitar
- Pedir a ChatGPT una URL en producción y confiar en la respuesta.
- Enviar HTML en bruto, con anuncios y scripts incluidos.
- Guardar la salida del modelo sin validación Pydantic (o equivalente).
- Usar Rendering en un catálogo estático y facturar 2 créditos sin necesidad.
- Extraer páginas autenticadas, o recopilar datos personales, con el argumento de que el modelo «necesitaba contexto».
FAQ
¿Puede ChatGPT extraer un sitio por sí solo?
No. No dispone de un navegador que usted controle ni de un método fiable para cargar la URL que acaba de pegar. Puede reformular un texto ya recuperado. Cargue con Website Crawler o Website Rendering, luego extraiga.
¿Por qué no llamar a requests.get directamente?
Puede, en books.toscrape.com. En un escaparate JavaScript o un sitio protegido por Cloudflare obtiene un nodo raíz vacío o una página de challenge. El modelo emitirá campos a partir de esa respuesta. Piloterr devuelve la página que mostraría un navegador, y solo se factura si la carga tiene éxito.
¿Cuándo es Rendering el modo adecuado?
Cuando el precio o la lista no aparecen en «Ver código fuente», pero sí en DevTools después de la hidratación. Espere con wait_in_seconds o wait_for. Si el HTML ya viene en la primera respuesta, permanezca en Crawler.
¿Cómo evitar precios inventados?
CSS o JSON-LD primero. Luego instrucción de copiar tal cual, reasoning={"effort": "none"}, structured outputs y Pydantic. Compare la cadena de precio con Price-Parser. Si falla la validación, un nuevo intento o un selector. No complete un precio ausente.
¿ChatGPT o una API de la biblioteca?
La API cuando el sitio ya está cubierto. ChatGPT para un caso puntual o una plantilla inestable. Combinar ambos es adecuado: carga con Crawler, análisis CSS, modelo solo en los campos que fallaron.
¿Y si el listado es demasiado largo para una llamada?
Extraiga por article o por fila. Deduplique por título o URL. No corte por índice de carácter salvo que la página no tenga marcado de lista.
¿Es legal?
Páginas públicas, ritmo razonable, sin autenticación, sin datos personales, y los términos del sitio se aplican. Empiece por ¿El scraping es legal?. Para reutilización comercial, licencia o feed oficial si existe.