Récupérer les données produit d’une page marchande

Obtenir le nom, la référence, la marque, le prix et la disponibilité d’un article depuis la page qui le présente.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la donnée est déjà là : la boutique l’écrit pour les moteurs de recherche, typée et nommée par schema.org, et la lire est un appel à l’analyseur JSON de votre langage. Le code n’est intéressant que par ce qu’il refuse de trancher — plusieurs produits sur la même page, une offre parmi plusieurs, un prix écrit « 1 234,56 » qui n’est pas un nombre — et par deux détails de production : aucun montant ne passe par un flottant, si bien qu’un prix écrit `24.90` en nombre JSON ressort `24.90` et non `24.9` ; et les blocs sont balayés à la main, aux deux niveaux, parce qu’une expression régulière sur une page qu’on n’a pas écrite s’effondre, vingt mille balises ouvertes suffisant à la faire dépasser dix secondes. Montez à N3 pour la boutique qui n’émet rien, en sachant que la garde qui y rend l’appel défendable — écarter toute valeur absente de la page — écarte aussi des valeurs justes.

Le bloc JSON-LD que la page publie sur elle-même

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-product-data-from-a-shop-page/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Lire le produit qu'une page de boutique publie déjà sur elle-même.

Niveau N0. Presque toutes les boutiques bâties sur une plateforme de commerce
émettent leur produit en JSON-LD, dans un bloc
`<script type="application/ld+json">`, parce que les moteurs de recherche le
demandent. Le nom, la référence, la marque, le prix, la devise et la
disponibilité y sont déjà, nommés par schema.org, typés, et écrits par la
boutique elle-même. Les lire, c'est analyser du JSON.

C'est tout ce niveau, et c'est pourquoi écrire des sélecteurs — ou envoyer la
page à un modèle — est le cas de sur-ingénierie le mieux documenté de
l'extraction web : les deux vont chercher dans le rendu ce que la page
déclare en clair quelques lignes plus haut.

Deux choses sont refusées plutôt que devinées. Une page peut porter plusieurs
produits — celui dont elle parle, et le carrousel des articles voisins — et
rien dans le format ne dit lequel est lequel : tous ceux qui sont trouvés
sont rendus, et l'appelant choisit. Et un prix écrit « 1 234,56 » n'est pas
un nombre pour schema.org, qui demande un point : plutôt que de le lire comme
mille ou comme un, `price` revient nul, avec le texte écrit par la boutique
dans `price_text`.

Aucun nombre du document ne devient un flottant, `price` compris. Une
boutique qui écrit `"price": 24.90` — en nombre JSON, ce que font plusieurs
plateformes — a écrit deux décimales, et un flottant n'a aucun moyen de s'en
souvenir : il ressort `24.9`, du champ même dont la raison d'être est de
garder les centimes. L'analyseur est donc prié de rendre chaque nombre sous
la forme des chiffres qu'il a lus, et `price` est une chaîne de chiffres dont
l'appelant fait ce que son propre type monétaire demande.

Une offre est choisie comme un produit ne l'est pas : une page peut en
publier plusieurs — une par taille, par couleur, par marchand — et seule la
première est lue. `offers` dit dans le rapport combien il y en avait, pour
qu'un appelant qui en voit plus d'une sache que le prix obtenu est l'un de
plusieurs.
"""

from __future__ import annotations

import json
import re

# schema.org writes availability as a URL, a bare name, or an http URL from
# before the site moved to https. All three mean the same thing.
AVAILABILITY = re.compile(r"^(?:https?://schema\.org/)?(\w+)$")

# A price is a number with a dot, says schema.org. Anything else is text.
PRICE = re.compile(r"^-?\d+(?:\.\d+)?$")

# Which of the three GTIN keys answered. Their length is an information of its
# own — eight, thirteen or fourteen digits — and « what is not read is named »
# applies to what is read too.
GTIN_KEYS = ("gtin", "gtin14", "gtin13", "gtin12", "gtin8")


def read_products(html) -> dict:
    """
    Every schema.org Product the page declares, in the order they appear.

    `source` says where the answer came from, so a caller that finds nothing
    knows whether the page is silent or whether its JSON-LD is broken.
    """
    if not isinstance(html, str):
        return {"source": None, "products": [], "reason": f"expected HTML, not {type(html).__name__}"}

    nodes, broken = [], 0
    for block in _blocks(html):
        try:
            # `parse_float=str` and `parse_int=str`: no number in this
            # document becomes a float. See the header — a price written 24.90
            # must come back as 24.90.
            nodes.extend(_flatten(json.loads(block, parse_float=str, parse_int=str)))
        except (ValueError, RecursionError):
            broken += 1

    products = [_product(node) for node in nodes if _is_product(node)]
    if products:
        return {"source": "json-ld", "products": products, "reason": None}
    reason = "the JSON-LD on this page could not be parsed" if broken else "no JSON-LD product"
    return {"source": None, "products": [], "reason": reason}


def _blocks(html: str) -> list:
    """
    The JSON-LD script blocks, scanned rather than matched.

    A regular expression would do it in one line and would be a denial of
    service on a page you did not write: « <script type="application/ld+json"> »
    repeated twenty thousand times with no closing tag makes the engine retry
    from every opening. This walks the string once.
    """
    found, cursor, lower = [], 0, html.lower()
    while True:
        start = lower.find("<script", cursor)
        if start < 0:
            return found
        opening = lower.find(">", start)
        closing = lower.find("</script", opening) if opening >= 0 else -1
        if opening < 0 or closing < 0:
            return found
        if "application/ld+json" in lower[start:opening]:
            found.append(html[opening + 1:closing])
        cursor = closing + len("</script")


def _flatten(value, depth: int = 0):
    """Every node of the document: a graph, a list of blocks, or one object."""
    if depth > 8:
        return []
    if isinstance(value, list):
        return [n for item in value for n in _flatten(item, depth + 1)]
    if not isinstance(value, dict):
        return []
    found = [value]
    for key in ("@graph", "mainEntity", "itemListElement", "item"):
        if key in value:
            found.extend(_flatten(value[key], depth + 1))
    return found


def _is_product(node) -> bool:
    kinds = node.get("@type")
    kinds = kinds if isinstance(kinds, list) else [kinds]
    return "Product" in kinds


def _product(node) -> dict:
    offers = _flatten(node.get("offers"))
    offer = offers[0] if offers else {}
    raw_price = offer.get("price")
    price = str(raw_price) if raw_price is not None else None
    brand = node.get("brand")
    availability = offer.get("availability")
    match = AVAILABILITY.match(availability) if isinstance(availability, str) else None
    gtin_key = next((k for k in GTIN_KEYS if _text(node.get(k))), None)
    return {
        "name": _text(node.get("name")),
        "sku": _text(node.get("sku")),
        "gtin": _text(node.get(gtin_key)) if gtin_key else None,
        "gtin_key": gtin_key,
        "brand": _text(brand.get("name") if isinstance(brand, dict) else brand),
        # None when the shop did not write a schema.org number, with the text
        # it did write beside it: a price read wrong is worse than a price not
        # read. Digits, never a float — see the header.
        "price": price if price and PRICE.match(price) else None,
        "price_text": price,
        "currency": _text(offer.get("priceCurrency")),
        "availability": match.group(1) if match else _text(availability),
        # How many offers the page published for this product. Only the first
        # is read; a caller that sees more than one knows it.
        "offers": len(offers),
    }


def _text(value):
    return value.strip() if isinstance(value, str) else None

JavaScript

snippets/extract-product-data-from-a-shop-page/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Lire le produit qu'une page de boutique publie déjà sur elle-même.
 *
 * Niveau N0. Presque toutes les boutiques bâties sur une plateforme de commerce
 * émettent leur produit en JSON-LD, dans un bloc
 * `<script type="application/ld+json">`, parce que les moteurs de recherche le
 * demandent. Le nom, la référence, la marque, le prix, la devise et la
 * disponibilité y sont déjà, nommés par schema.org, typés, et écrits par la
 * boutique elle-même. Les lire, c'est analyser du JSON.
 *
 * C'est tout ce niveau, et c'est pourquoi écrire des sélecteurs — ou envoyer la
 * page à un modèle — est le cas de sur-ingénierie le mieux documenté de
 * l'extraction web : les deux vont chercher dans le rendu ce que la page
 * déclare en clair quelques lignes plus haut.
 *
 * Deux choses sont refusées plutôt que devinées. Une page peut porter plusieurs
 * produits — celui dont elle parle, et le carrousel des articles voisins — et
 * rien dans le format ne dit lequel est lequel : tous ceux qui sont trouvés
 * sont rendus, et l'appelant choisit. Et un prix écrit « 1 234,56 » n'est pas
 * un nombre pour schema.org, qui demande un point : plutôt que de le lire comme
 * mille ou comme un, `price` revient nul, avec le texte écrit par la boutique
 * dans `price_text`.
 *
 * Aucun nombre du document ne devient un flottant, `price` compris. Une
 * boutique qui écrit `"price": 24.90` — en nombre JSON, ce que font plusieurs
 * plateformes — a écrit deux décimales, et un flottant n'a aucun moyen de s'en
 * souvenir : il ressort `24.9`, du champ même dont la raison d'être est de
 * garder les centimes. L'analyseur est donc prié de rendre chaque nombre sous
 * la forme des chiffres qu'il a lus, et `price` est une chaîne de chiffres dont
 * l'appelant fait ce que son propre type monétaire demande.
 *
 * Une offre est choisie comme un produit ne l'est pas : une page peut en
 * publier plusieurs — une par taille, par couleur, par marchand — et seule la
 * première est lue. `offers` dit dans le rapport combien il y en avait, pour
 * qu'un appelant qui en voit plus d'une sache que le prix obtenu est l'un de
 * plusieurs.
 */

// schema.org writes availability as a URL, a bare name, or an http URL from
// before the site moved to https. All three mean the same thing.
const AVAILABILITY = /^(?:https?:\/\/schema\.org\/)?(\w+)$/;

// A price is a number with a dot, says schema.org. Anything else is text.
const PRICE = /^-?\d+(?:\.\d+)?$/;

// Which of the GTIN keys answered. Their length is an information of its own —
// eight, twelve, thirteen or fourteen digits — and « what is not read is
// named » applies to what is read too.
const GTIN_KEYS = ['gtin', 'gtin14', 'gtin13', 'gtin12', 'gtin8'];

/**
 * No number in this document becomes a float. `JSON.parse` hands the reviver
 * the source text it read, and that is what is kept: a price written 24.90
 * must come back as 24.90, not as 24.9.
 */
const parseKeepingDigits = (block) =>
  JSON.parse(block, (key, value, context) => (
    typeof value === 'number' && context && typeof context.source === 'string'
      ? context.source
      : value));

/**
 * Every schema.org Product the page declares, in the order they appear.
 *
 * `source` says where the answer came from, so a caller that finds nothing
 * knows whether the page is silent or whether its JSON-LD is broken.
 *
 * @param {string} html
 */
export function readProducts(html) {
  if (typeof html !== 'string') {
    return { source: null, products: [], reason: `expected HTML, not ${kindOf(html)}` };
  }

  const nodes = [];
  let broken = 0;
  for (const block of blocks(html)) {
    try {
      nodes.push(...flatten(parseKeepingDigits(block)));
    } catch {
      broken += 1;
    }
  }

  const products = nodes.filter(isProduct).map(product);
  if (products.length > 0) return { source: 'json-ld', products, reason: null };
  const reason = broken ? 'the JSON-LD on this page could not be parsed' : 'no JSON-LD product';
  return { source: null, products: [], reason };
}

/**
 * The JSON-LD script blocks, scanned rather than matched.
 *
 * A regular expression would do it in one line and would be a denial of
 * service on a page you did not write: « <script type="application/ld+json"> »
 * repeated twenty thousand times with no closing tag makes the engine retry
 * from every opening. This walks the string once.
 */
function blocks(html) {
  const found = [];
  const lower = html.toLowerCase();
  let cursor = 0;
  for (;;) {
    const start = lower.indexOf('<script', cursor);
    if (start < 0) return found;
    const opening = lower.indexOf('>', start);
    const closing = opening >= 0 ? lower.indexOf('</script', opening) : -1;
    if (opening < 0 || closing < 0) return found;
    if (lower.slice(start, opening).includes('application/ld+json')) {
      found.push(html.slice(opening + 1, closing));
    }
    cursor = closing + '</script'.length;
  }
}

/** Every node of the document: a graph, a list of blocks, or one object. */
function flatten(value, depth = 0) {
  if (depth > 8) return [];
  if (Array.isArray(value)) return value.flatMap((item) => flatten(item, depth + 1));
  if (typeof value !== 'object' || value === null) return [];
  const found = [value];
  for (const key of ['@graph', 'mainEntity', 'itemListElement', 'item']) {
    if (key in value) found.push(...flatten(value[key], depth + 1));
  }
  return found;
}

function isProduct(node) {
  const kinds = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
  return kinds.includes('Product');
}

function product(node) {
  const offers = flatten(node.offers);
  const offer = offers.length > 0 ? offers[0] : {};
  const rawPrice = offer.price;
  const price = rawPrice === undefined || rawPrice === null ? null : String(rawPrice);
  const brand = node.brand;
  const availability = offer.availability;
  const match = typeof availability === 'string' ? AVAILABILITY.exec(availability) : null;
  const gtinKey = GTIN_KEYS.find((k) => text(node[k])) ?? null;
  return {
    name: text(node.name),
    sku: text(node.sku),
    gtin: gtinKey ? text(node[gtinKey]) : null,
    gtin_key: gtinKey,
    brand: text(typeof brand === 'object' && brand !== null ? brand.name : brand),
    // null when the shop did not write a schema.org number, with the text it
    // did write beside it: a price read wrong is worse than a price not read.
    // Digits, never a float — see the header.
    price: price !== null && PRICE.test(price) ? price : null,
    price_text: price,
    currency: text(offer.priceCurrency),
    availability: match ? match[1] : text(availability),
    // How many offers the page published for this product. Only the first is
    // read; a caller that sees more than one knows it.
    offers: offers.length,
  };
}

const text = (value) => (typeof value === 'string' ? value.trim() : null);

const kindOf = (value) => (value === null ? 'null' : typeof value);

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Collez le source d’une fiche produit, ou seulement son bloc JSON-LD. Rien ne part sur le réseau : le code lit le texte que vous lui donnez.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

Ce qu’il renvoie

1 produit lu

Le détail

ProduitNomRéférenceMarquePrixDeviseDisponibilité
1Moulin à café LumièreMC-4501Lumière19.90EURInStock

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : la page est lue dans votre processus et n’est transmise à personne
  • L’aspiration d’un site tiers relève de ses conditions d’utilisation et du droit applicable, que la technique employée ne change pas

Point de rupture

Le bloc est ce que la boutique a choisi de publier, et il peut ne plus correspondre à la page : sur une fiche en promotion, le JSON-LD annonce 24,90 quand le corps affiche 19,90. Témoin dans le même test : sur une page en accord avec elle-même, le prix lu est celui du corps.

Quand passer au niveau suivant

La boutique n’émet aucun bloc JSON-LD, et son rendu change assez souvent pour qu’écrire des sélecteurs revienne à les réécrire.

Les quatre options

  1. RecommandéN0Le bloc JSON-LD que la page publie sur elle-mêmeGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Le texte de la page envoyé à un modèle, et toute valeur absente écartéemodéré · Une seconde

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Apprendre à reconnaître un prix dans un arbre de balises est un vrai sujet — l’induction de motifs d’extraction —, et il se heurte ici à une comparaison qui le rend sans objet : le modèle devrait être réentraîné à chaque refonte de chaque boutique, là où le bloc JSON-LD est écrit par la boutique elle-même et suit ses propres changements. Sur les pages qui n’en émettent pas, un modèle appris sur une boutique ne transfère pas à la suivante, et il faudrait un jeu étiqueté par site.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Un petit modèle spécialisé auto-hébergé pour lire une page marchande est le même travail que N3 avec une machine à tenir en plus, et sans la seule chose qui rend N3 intéressant ici : le fait qu’il n’ait rien à apprendre du site. Le compromis se joue entre lire une déclaration gratuite et payer une lecture ; un niveau intermédiaire qui paie la lecture sans le confort de l’API n’a pas d’endroit où se placer.

N3Le texte de la page envoyé à un modèle, et toute valeur absente écartéemodéré · Une seconde

Le texte de la page envoyé à un modèle, et toute valeur absente écartée

API de LLM généralisteN3modéréUne seconde

Python

snippets/extract-product-data-from-a-shop-page/n3.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Lire un produit sur une page qui ne publie rien de structuré, en le demandant
à un modèle.

Niveau N3. C'est le niveau de la boutique qui n'émet pas de JSON-LD et dont
le rendu change tous les trimestres, où écrire des sélecteurs revient à les
réécrire. Il y gagne sa place, et nulle part au-dessus : sur une page qui
porte déjà son JSON-LD, le niveau N0 lit les mêmes valeurs pour rien.

Trois choses que ce code fait et que le modèle ne fait pas, et qui sont le
vrai travail de ce niveau.

Il coupe, et il coupe d'abord. Une page de boutique fait des centaines de
kilooctets de balisage, presque tous en menus, en mouchards et en styles ; le
balisage est plafonné avant d'être lu, les balises sautent, et ce qui reste
est plafonné à son tour. Le fournisseur facture ce qu'on envoie, donc le
second plafond est un budget — et la fiche sur l'estimation du coût d'un
appel est l'endroit où ce budget se calcule. Le premier, lui, protège le
serveur : les blocs `script` et `style` sont balayés plutôt que reconnus par
une expression régulière, exactement comme au niveau N0 et pour la même
raison — vingt mille balises jamais refermées faisaient passer des dizaines
de secondes de processeur à ce niveau, avant même l'appel au modèle.

Il refuse une valeur inventée. Chaque chaîne rendue par le modèle est
cherchée dans le texte qui a été envoyé : un nom, une référence ou un prix
qui ne figure nulle part dans la page est écarté, et `invented` dit lesquels.
C'est la seule garde qui sépare une lecture d'une réponse vraisemblable, et
elle vaut plus que la consigne.

Et il traite un refus comme un refus. Un fournisseur qui ne peut pas répondre
lève ; un contenu nul n'est pas une valeur, et il n'est pas passé au décodeur
JSON en comptant sur l'exception.
"""

from __future__ import annotations

import json
import re

# What the model is asked for, named as schema.org names it so that a caller
# can put an N0 answer and an N3 answer in the same table.
FIELDS = ("name", "sku", "brand", "price", "currency", "availability")

# A shop page is markup; this is what is left once the tags are gone. The
# pattern carries no nested quantifier on purpose: the one that reads
# « <(script|style)...>.*?</\1> » in a single line is the collapse the N0
# snippet of this entry was written to avoid, and it cost tens of seconds of
# CPU here, before the model was even called. The blocks are scanned instead,
# in `_without_scripts`.
TAGS = re.compile(r"<[^>]+>")
SPACES = re.compile(r"[ \t\r\f\v]*\n\s*|[ \t]{2,}")

# The budget in characters of markup, applied before anything reads the page.
# The charte asks that an input too large be refused before the call; a cap
# applied after the cleaning protects nothing, since the cleaning is the part
# that costs.
MAX_HTML = 400_000

# The budget, in characters of text. A page that does not fit is cut, not
# refused: a scraper that raises on a long page returns nothing at all.
MAX_CHARACTERS = 6000

# A single code fence around the whole answer is a common shape, and refusing
# it would buy another call for nothing.
FENCE = re.compile(r"\A\s*```(?:json)?\s*(.*?)\s*```\s*\Z", re.DOTALL)

MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts

PROMPT = (
    "Read this shop page and return the product it is about.\n"
    "Answer with JSON only, with these keys: {fields}.\n"
    "Copy each value from the page. Use null for anything the page does not say.\n\n"
    "Page:\n{page}"
)


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class ReadingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def _without_scripts(html: str) -> str:
    """
    The markup with every script and style block removed, scanned rather than
    matched — the same walk as `_blocks` in the N0 snippet, and for the same
    reason. An unclosed block swallows the rest of the page, which is what a
    browser does too.
    """
    kept, cursor, lower = [], 0, html.lower()
    while cursor < len(html):
        starts = [i for i in (lower.find("<script", cursor), lower.find("<style", cursor)) if i >= 0]
        if not starts:
            kept.append(html[cursor:])
            break
        start = min(starts)
        kept.append(html[cursor:start])
        tag = "script" if lower.startswith("<script", start) else "style"
        closing = lower.find(f"</{tag}", start)
        if closing < 0:
            break
        cursor = closing
    return "".join(kept)


def to_text(html: str) -> str:
    """The page without its markup, collapsed, and cut to the budget."""
    return SPACES.sub("\n", TAGS.sub(" ", _without_scripts(html[:MAX_HTML]))).strip()[:MAX_CHARACTERS]


def read_product(html: str, client=None, *, attempts: int = 3) -> dict:
    """
    The product a model reads on this page, with what it made up removed.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    client = client or ProviderClient()
    page = to_text(html)
    answer = _ask(client, page, attempts)

    product, invented = {}, []
    haystack = page.casefold()
    for field in FIELDS:
        value = answer.get(field) if isinstance(answer, dict) else None
        if value is None:
            product[field] = None
        elif str(value).strip().casefold() in haystack:
            product[field] = str(value).strip()
        else:
            # The model wrote something the page does not contain. It is not a
            # reading, so it is not kept — and the caller is told.
            product[field] = None
            invented.append(field)
    return {"source": "model", "product": product, "invented": invented,
            "characters_sent": len(page)}


def _ask(client, page: str, attempts: int) -> dict:
    prompt = PROMPT.format(fields=", ".join(FIELDS), page=page)
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            answer = client.complete(prompt=prompt, temperature=0)
            if not isinstance(answer, str):  # a refusal comes back as no content
                last_error = ValueError("the model answered no text")
                continue
            fenced = FENCE.match(answer)
            return json.loads(fenced.group(1) if fenced else answer)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
    raise ReadingUnavailable(str(last_error))

JavaScript

snippets/extract-product-data-from-a-shop-page/n3.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Lire un produit sur une page qui ne publie rien de structuré, en le demandant
 * à un modèle.
 *
 * Niveau N3. C'est le niveau de la boutique qui n'émet pas de JSON-LD et dont
 * le rendu change tous les trimestres, où écrire des sélecteurs revient à les
 * réécrire. Il y gagne sa place, et nulle part au-dessus : sur une page qui
 * porte déjà son JSON-LD, le niveau N0 lit les mêmes valeurs pour rien.
 *
 * Trois choses que ce code fait et que le modèle ne fait pas, et qui sont le
 * vrai travail de ce niveau.
 *
 * Il coupe, et il coupe d'abord. Une page de boutique fait des centaines de
 * kilooctets de balisage, presque tous en menus, en mouchards et en styles ; le
 * balisage est plafonné avant d'être lu, les balises sautent, et ce qui reste
 * est plafonné à son tour. Le fournisseur facture ce qu'on envoie, donc le
 * second plafond est un budget — et la fiche sur l'estimation du coût d'un
 * appel est l'endroit où ce budget se calcule. Le premier, lui, protège le
 * serveur : les blocs `script` et `style` sont balayés plutôt que reconnus par
 * une expression régulière, exactement comme au niveau N0 et pour la même
 * raison — vingt mille balises jamais refermées faisaient passer des dizaines
 * de secondes de processeur à ce niveau, avant même l'appel au modèle.
 *
 * Il refuse une valeur inventée. Chaque chaîne rendue par le modèle est
 * cherchée dans le texte qui a été envoyé : un nom, une référence ou un prix
 * qui ne figure nulle part dans la page est écarté, et `invented` dit lesquels.
 * C'est la seule garde qui sépare une lecture d'une réponse vraisemblable, et
 * elle vaut plus que la consigne.
 *
 * Et il traite un refus comme un refus. Un fournisseur qui ne peut pas répondre
 * lève ; un contenu nul n'est pas une valeur, et il n'est pas passé au décodeur
 * JSON en comptant sur l'exception.
 */

// What the model is asked for, named as schema.org names it so that a caller
// can put an N0 answer and an N3 answer in the same table.
export const FIELDS = ['name', 'sku', 'brand', 'price', 'currency', 'availability'];

// A shop page is markup; this is what is left once the tags are gone. The
// pattern carries no nested quantifier on purpose: the one that reads
// « <(script|style)...>[\s\S]*?</\1> » in a single line is the collapse the N0
// snippet of this entry was written to avoid. The blocks are scanned instead,
// in `withoutScripts`.
const TAGS = /<[^>]+>/g;
const SPACES = /[ \t\r\f\v]*\n\s*|[ \t]{2,}/g;

// The budget in characters of markup, applied before anything reads the page.
// The charte asks that an input too large be refused before the call; a cap
// applied after the cleaning protects nothing, since the cleaning is the part
// that costs.
export const MAX_HTML = 400_000;

// The budget, in characters of text. A page that does not fit is cut, not
// refused: a scraper that throws on a long page returns nothing at all.
export const MAX_CHARACTERS = 6000;

// A single code fence around the whole answer is a common shape, and refusing
// it would buy another call for nothing.
const FENCE = /^\s*```(?:json)?\s*([\s\S]*?)\s*```\s*$/;

export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

const PROMPT = [
  'Read this shop page and return the product it is about.',
  `Answer with JSON only, with these keys: ${FIELDS.join(', ')}.`,
  'Copy each value from the page. Use null for anything the page does not say.',
  '',
  'Page:',
].join('\n');

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class ReadingUnavailable extends Error {}

/**
 * The markup with every script and style block removed, scanned rather than
 * matched — the same walk as `blocks` in the N0 snippet, and for the same
 * reason. An unclosed block swallows the rest of the page, which is what a
 * browser does too.
 */
function withoutScripts(html) {
  const lower = html.toLowerCase();
  const kept = [];
  let cursor = 0;
  while (cursor < html.length) {
    const starts = [lower.indexOf('<script', cursor), lower.indexOf('<style', cursor)]
      .filter((i) => i >= 0);
    if (starts.length === 0) { kept.push(html.slice(cursor)); break; }
    const start = Math.min(...starts);
    kept.push(html.slice(cursor, start));
    const tag = lower.startsWith('<script', start) ? 'script' : 'style';
    const closing = lower.indexOf(`</${tag}`, start);
    if (closing < 0) break;
    cursor = closing;
  }
  return kept.join('');
}

/** The page without its markup, collapsed, and cut to the budget. */
export function toText(html) {
  return withoutScripts(html.slice(0, MAX_HTML))
    .replace(TAGS, ' ').replace(SPACES, '\n').trim().slice(0, MAX_CHARACTERS);
}

/**
 * The product a model reads on this page, with what it made up removed.
 *
 * @param {string} html
 * @param {{client?: {complete: Function}, attempts?: number}} [options]
 */
export async function readProduct(html, { client, attempts = 3 } = {}) {
  client ??= await providerClient();
  const page = toText(html);
  const answer = await ask(client, page, attempts);

  const product = {};
  const invented = [];
  const haystack = page.toLowerCase();
  for (const field of FIELDS) {
    const value = answer && typeof answer === 'object' ? answer[field] : null;
    if (value === null || value === undefined) {
      product[field] = null;
    } else if (haystack.includes(String(value).trim().toLowerCase())) {
      product[field] = String(value).trim();
    } else {
      // The model wrote something the page does not contain. It is not a
      // reading, so it is not kept — and the caller is told.
      product[field] = null;
      invented.push(field);
    }
  }
  return { source: 'model', product, invented, characters_sent: page.length };
}

async function ask(client, page, attempts) {
  const prompt = `${PROMPT}\n${page}`;
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    try {
      const answer = await client.complete({ prompt, temperature: 0 });
      if (typeof answer !== 'string') {
        lastError = new Error('the model answered no text'); // a refusal has no content
        continue;
      }
      const fenced = FENCE.exec(answer);
      return JSON.parse(fenced ? fenced[1] : answer);
    } catch (error) {
      lastError = error;
    }
  }
  throw new ReadingUnavailable(String(lastError));
}

Ce que ça implique

  • Vos données partent chez un tiers
  • Résultat variable d’un appel à l’autre
  • Difficile à tester
  • Dépend d’un fournisseur externe
  • Empreinte modérée

Ce que cela ajoute à votre périmètre réglementaire

  • Transfert du contenu de la page à un sous-traitant, avec l’encadrement contractuel que cela suppose
  • Localisation du traitement à vérifier auprès du fournisseur

Point de rupture

La garde vérifie d’où vient une valeur, pas ce qu’elle veut dire : la devise écrite « € » dans la page et rendue « EUR » par le modèle est comptée comme inventée, alors qu’elle est juste. Témoin : un prix absent de la page, « 24,90 » sur une page à 19,90, est bien écarté.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus.

Pour aller plus loin

Source des chiffres

Métadonnées