Collez une URL produit dans ChatGPT et vous obtiendrez souvent un prix. Ouvrez la page en production : le chiffre est faux, périmé ou inventé. Il ne s'agit pas d'un extracteur défaillant. Le modèle n'a jamais ouvert l'URL. Il a inféré le résultat à partir des données d'entraînement, avec un ton affirmatif.
Traitez ChatGPT comme un parseur, pas comme un navigateur. Piloterr charge la page. Essayez d'abord le JSON-LD ou un sélecteur CSS. Seul le texte restant est envoyé à un modèle OpenAI. Pydantic écarte tout objet qui ne respecte pas le schéma.
La démonstration utilise books.toscrape.com, un catalogue public destiné à l'apprentissage. Limitez-vous aux pages publiques, respectez le robots.txt, et consultez Le scraping est-il légal ? avant de cibler un site commercial.
Dans ce tutoriel :
- Charger une fiche avec Website Crawler (1 crédit).
- Lire titre, prix, stock et UPC en CSS lorsque le balisage est stable.
- Recourir aux structured outputs de
gpt-5.6-lunalorsqu'il ne l'est pas. - Découper une catégorie sur
article.product_pod, et non au milieu d'un mot.
Deux tâches, deux outils
Un modèle de langage lit du texte. Il n'exécute pas le JavaScript, ne fait pas tourner les adresses IP et ne franchit pas un challenge Cloudflare. Si vous lui demandez d'aller chercher une URL, vous obtenez une page bloquée, une SPA vide, ou un payload inventé.
Conservez une séparation explicite :
- Charger une page complète (Piloterr, ou un endpoint de la bibliothèque s'il existe).
- Extraire les champs (CSS, JSON-LD, ou un modèle).
ChatGPT est pertinent lorsque le balisage est irrégulier, ou lorsqu'il évolue trop souvent pour justifier la maintenance d'un ensemble de sélecteurs. Amazon, LinkedIn et les cibles déjà couvertes disposent d'API maintenues. Utilisez-les.
Analyser le DOM avant d'appeler le modèle
Commencez par un extracteur CSS. Sur ce catalogue, le titre est h1, le prix p.price_color, l'UPC une ligne de tableau. Cette voie consomme moins de tokens que gpt-5.6-luna et renvoie la même chaîne d'un jour à l'autre.
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 reste None : cette page n'a pas d'ISBN. Ne demandez pas au modèle d'en inventer un.
Recherchez, dans cet ordre :
- Une API de la bibliothèque (Amazon Product, etc.)
- Du
application/ld+jsondans la page - Un nœud CSS ou XPath stable
- Un modèle, pour le reste : spécifications non structurées, FAQ, descriptions qui mélangent unités et précautions
Un prix déjà en clair passe par Price-Parser sans 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
Choisissez le mode de récupération avant le prompt
Un mode inadapté produit un HTML vide. Le modèle « extrait » alors depuis une page d'authentification.
| Mode | Crédits | Quand l'utiliser |
|---|---|---|
| Website Crawler | 1 | HTML statique ou API JSON. Le plus rapide. Pas de JS. |
| Website Rendering | 2 | Contenu après JavaScript. Besoin de wait_in_seconds ou wait_for. |
| Website WebUnlocker | 3 | Domaines allowlistés derrière Cloudflare, DataDome, PerimeterX, Akamai. Pas de JS. |
Comparatif : Crawler vs Rendering vs WebUnlocker. Ce tutoriel utilise Crawler, car books.toscrape.com est rendu côté serveur.
Charger le HTML
Python 3.10+, une clé API Piloterr, et une clé OpenAI. Les deux dans l'environnement. Jamais dans le 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 est un GET. query est l'URL cible. Le corps JSON est la chaîne 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 trop court. Passez sur Rendering ou WebUnlocker.")
return html
Quelques centaines de kilo-octets, c'est le comportement attendu. Quelques centaines de caractères correspondent souvent à une page de challenge. Changez de mode. Ne demandez pas au modèle de compenser un HTML incomplet.
Pour une fiche fortement dépendante de JavaScript, le même modèle s'applique à Rendering (2 crédits) :
RENDER = "https://api.piloterr.com/v2/website/rendering"
def fetch_rendered(url: str) -> str:
return fetch_html(url, endpoint=RENDER, wait_in_seconds=4)
Documentation : Website Crawler et Website Rendering. Un appel réussi coûte 1 ou 2 crédits. Les 4xx côté client et la plupart des 5xx plateforme ne sont pas facturés. Voir la page tarifaire et la version Markdown /pricing.md.
Réduire le HTML avant d'appeler le modèle
Les scripts, les SVG et le bandeau de cookies consomment des tokens et masquent les champs utiles.
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, puis validation
Lorsque le CSS échoue, le prompt devient l'extracteur. Nommez chaque champ. Utilisez null si le texte n'indique pas la valeur. client.responses.parse rattache la réponse à la classe Pydantic : un objet mal formé n'est pas traité comme un dictionnaire 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 suffit pour copier des champs. Passez à gpt-5.6 uniquement si deux prix se contredisent, ou si un tableau est mal interprété. Lancez un échantillon de 20 pages, mesurez les échecs, puis choisissez le modèle. client.chat.completions.parse fonctionne encore si vous restez sur Chat Completions. Préférez responses.parse avec openai 2.x.
Un seul 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))
Forme attendue sur la fiche « A Light in the Attic » (l'UPC est sur la page, pas l'ISBN) :
{
"title": "A Light in the Attic",
"price": "£51.77",
"availability": "In stock (22 available)",
"upc": "a897fe39b1053632",
"isbn": null
}
Un ISBN halluciné s'insère sans alerte dans un tableur, jusqu'à une commande erronée.
Pages longues : découpez sur les cartes produit
Envoyer une catégorie entière dans un seul prompt dépasse la fenêtre de contexte. Ne découpez pas à 12 000 caractères au milieu d'un mot. Analysez la liste, puis extrayez une carte.
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
Les cartes de listing n'ont pas de tableau UPC. Le CSS renvoie None, le modèle renseigne titre et prix. Fusionnez sur le titre, ou conservez le href si vous l'avez stocké.
print(extract_catalog(fetch_html(CATALOG)))
Laisser l'agent charger la page, conserver la même séparation
Coller du HTML dans l'interface ChatGPT convient à une démonstration. Un traitement quotidien s'exécute en Python, ou via un outil appelé par l'agent.
Le MCP Piloterr expose Crawler et Rendering sur https://mcp.piloterr.com/ avec le même x-api-key. Claude et Cursor peuvent charger une page. Vous validez ensuite le JSON dans votre code. Notes de spécification : MCP Piloterr sur 2026-07-28.
Pratiques à éviter
- Demander à ChatGPT une URL en production et faire confiance à la réponse.
- Envoyer le HTML brut, publicités et scripts inclus.
- Stocker la sortie du modèle sans validation Pydantic (ou équivalent).
- Utiliser Rendering sur un catalogue statique et facturer 2 crédits sans besoin.
- Extraire des pages authentifiées, ou collecter des données personnelles, au motif que le modèle « avait besoin de contexte ».
FAQ
ChatGPT peut-il extraire un site tout seul ?
Non. ChatGPT n'expose pas de navigateur que vous contrôlez, et n'offre aucune méthode fiable pour charger l'URL que vous venez de coller. Il peut reformuler un texte déjà récupéré. Chargez avec Website Crawler ou Website Rendering, puis extrayez.
Pourquoi ne pas appeler requests.get directement ?
Vous pouvez, sur books.toscrape.com. Sur une vitrine JavaScript ou un site protégé par Cloudflare, vous obtenez un nœud racine vide ou une page de challenge. Le modèle produira tout de même des champs à partir de cette réponse. Piloterr renvoie la page qu'un navigateur afficherait, et la facturation n'intervient que si le chargement réussit.
Quand Rendering est-il le mode adapté ?
Lorsque le prix ou la liste n'apparaissent pas dans « Afficher le code source », mais sont visibles dans DevTools après hydratation. Attendez avec wait_in_seconds ou wait_for. Si le HTML est déjà dans la première réponse, restez sur Crawler.
Comment empêcher les prix inventés ?
CSS ou JSON-LD d'abord. Puis consigne de copie verbatim, reasoning={"effort": "none"}, structured outputs, et Pydantic. Comparez la chaîne prix avec Price-Parser. Si la validation échoue, un nouvel essai, ou un sélecteur. Ne complétez pas un prix manquant.
ChatGPT ou une API de la bibliothèque ?
L'API lorsque le site est déjà couvert. ChatGPT pour un cas ponctuel ou un gabarit instable. Les deux se combinent : chargement Crawler, analyse CSS, modèle uniquement sur les champs en échec.
Et si la liste est trop longue pour un appel ?
Extrayez par article ou par ligne. Dédupliquez sur le titre ou l'URL. Ne découpez pas à l'index de caractère, sauf si la page n'a aucun balisage de liste.
Est-ce légal ?
Pages publiques, débit raisonnable, pas d'authentification, pas de données personnelles, et les conditions générales du site s'appliquent. Commencez par Le scraping est-il légal ?. Pour une réutilisation commerciale, licence ou flux officiel s'il en existe un.