---
seo_title: Distill : extraire des données structurées | EnConvert
meta_desc: Extraction structurée pilotée par schéma depuis toute URL. Passe CSS gratuite d'abord, repli LLM ensuite, votre forme JSON en retour.
keywords: api extraction données structurées site web, scraper un site web vers un schéma json, api de web scraping avec llm, extraction par sélecteurs css api, alternative à firecrawl extract, api extraction données produit e-commerce, convertir un site web en json api, scraping structuré de données web
---

# API d'extraction de données structurées de sites web

<div class="alert alert-info">
<strong>Forfaits payants uniquement.</strong> Distill est disponible de façon générale et exige un forfait payant ; sur l'offre gratuite, la requête est rejetée avec <code>402</code>. Il entame votre quota mensuel d'ops comme n'importe quel autre appel, une op par URL aboutie, et la passe LLM dépense des crédits IA mensuels plutôt que des ops. Voir <a href="/fr/pricing">les tarifs</a>.
</div>

`POST /v2/distill` est une API pour extraire des données structurées de sites
web : elle récupère des champs à partir d'une ou plusieurs URLs pour
correspondre à un schéma que vous fournissez, soit un objet JSON-Schema, soit
une carte plate `{field: description}`. Elle exécute un moteur à deux passes :
d'abord une passe CSS gratuite (le `JsonCssExtractionStrategy` de Crawl4AI
piloté par vos sélecteurs), puis une passe plafonnée assistée par LLM
pour les champs que la passe CSS a laissés vides. La réponse `data`
est garantie de revenir dans la forme exacte que vous avez demandée.
C'est la réponse d'EnConvert à `/extract` de Firecrawl.

Voici le plus petit appel utile. Envoyez une URL et un schéma plat
`{field: description}`, et récupérez les champs extraits :

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com/product/widget"],
    "schema": {
      "name": "the product name",
      "price": "the listed price",
      "in_stock": "whether it is in stock"
    }
  }'
```

La réponse contient un résultat par URL, les `data` extraites, et quel
niveau les a produites :

```json
{
    "operation_id": "dst_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
    "total": 1,
    "completed": 1,
    "failed": 0,
    "results": [
        {
            "url": "https://example.com/product/widget",
            "url_final": "https://example.com/product/widget",
            "status": "completed",
            "data": {
                "name": "Widget Pro",
                "price": "$49.00",
                "in_stock": "yes"
            },
            "extraction_tier": "llm",
            "fields_from_css": 0,
            "fields_from_llm": 3,
            "render_quality": 0.91,
            "tokens": {"input": 4120, "output": 38},
            "cost_cents": 0.45,
            "warnings": []
        }
    ],
    "total_cost_cents": 0.45,
    "warnings": []
}
```

---

## Points de terminaison

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/v2/distill` | Distille une liste explicite d'URLs, ou découvre d'abord les URLs d'un site puis distille chacune, selon un schéma unique. |

**Content-Type :** `application/json`.

Contrairement à [perceive](/fr/docs/endpoints/perceive.md), distill est un unique
endpoint synchrone : il n'y a pas de re-fetch GET séparé ni de chemin batch
asynchrone. Chaque URL se rend séquentiellement via le singleton Chrome
headless partagé et l'ensemble complet des résultats revient dans une
seule réponse.

---

## Authentification

Authentifiez-vous avec une clé privée dans l'en-tête `X-API-Key` pour les
appels serveur à serveur. C'est le chemin qu'utilisent les exemples
ci-dessous.

```http
X-API-Key: sk_your_private_key
```

Les clés publiques avec un jeton bearer JWT fonctionnent aussi, en suivant
le même flux que tous les autres endpoints : générez un jeton avec votre
clé `pk_`, puis envoyez-le en tant que `Authorization: Bearer <token>`. Le
flux complet, y compris le verrouillage de domaine et le renouvellement de
jeton, se trouve dans [le guide d'authentification](/fr/docs/authentication.md).

Chaque clé API porte une liste blanche d'endpoints autorisés. Si
`/v2/distill` ne figure pas sur la liste de la clé, la requête est
rejetée avec `403`.

---

## Comment fonctionne distill

Une requête distille une liste d'URLs par rapport à un schéma unique. Le
flux est le même pour chaque URL :

1. **Résoudre la liste d'URLs.** Avec `urls`, la liste est exactement celle
   que vous avez envoyée (dédupliquée, ordre préservé). Avec
   `discover_from`, distill exécute d'abord
   [discover](/fr/docs/coming-soon/discover.md) sur l'URL de départ (en analysant le
   sitemap, en crawlant, ou les deux), puis distille les URLs découvertes
   jusqu'à `max_pages`.
2. **Rendu.** Chaque URL est rendue une fois dans Chrome headless via le
   même pipeline de capture qui alimente [perceive](/fr/docs/endpoints/perceive.md).
   Chaque URL d'une liste `urls` explicite est filtrée contre le SSRF en
   périphérie *avant* tout rendu : une seule URL pointant vers une adresse
   privée rejette donc la requête entière avec `400` au lieu de vous
   coûter un rendu. Les URLs atteintes via `discover_from` sont filtrées à
   chaque rendu et reviennent sous forme de lignes `failed`. Si
   `respect_robots=true`, chaque URL est en outre vérifiée par rapport au
   `robots.txt` du site. Aucun artefact n'est téléversé vers le stockage,
   car distill n'a besoin que du DOM rendu.
3. **Passe 1 : CSS (gratuite).** Si vous avez fourni un `css_schema`,
   l'extracteur CSS s'exécute sur le HTML rendu et remplit chaque champ
   adressable par sélecteur à coût LLM nul. Cette passe est bornée dans le
   temps à 10 secondes ; en cas de dépassement, l'URL bascule vers la
   passe LLM avec un avertissement.
4. **Passe 2 : LLM (plafonnée, uniquement si nécessaire).** Distill
   rassemble les champs du schéma que la passe CSS a laissés manquants ou
   vides et n'escalade *que ces champs* vers une passe assistée par LLM,
   sous des plafonds de budget stricts par appel et par période. Si votre
   plan n'a pas de niveau LLM, si la page a été signalée comme bloquée, ou
   si un plafond de budget est atteint, la passe LLM est ignorée et les
   champs manquants reviennent en `null` avec un avertissement.
5. **Normaliser.** Le résultat fusionné est remodelé pour correspondre
   exactement aux clés de votre schéma : les scalaires manquants
   deviennent `null`, les tableaux manquants deviennent `[]`, et toute clé
   supplémentaire est supprimée. La garantie de forme tient quel que soit
   ce que le CSS ou le LLM ont produit.

Une op est facturée par URL, et uniquement après que cette URL a
abouti. Les échecs de rendu (rejet SSRF, blocage robots, un rendu qui
plante) produisent une ligne de résultat `failed` et ne coûtent aucune
op.

---

## Paramètres de la requête

Vous devez fournir **exactement un** de `urls` ou `discover_from`, plus soit
un `schema`, soit un `prompt`. Envoyer les deux sources, ou aucune des deux,
produit un `422`. Les clés inconnues sont rejetées elles aussi : ni le corps
de la requête, ni `css_schema`, ni `CssField`, ni `discover_from` n'acceptent
de propriété supplémentaire, donc un paramètre mal orthographié produit un
`422` plutôt qu'un champ silencieusement ignoré.

### Source : URLs explicites

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `urls` | `string[]` | -- | URLs explicites à distiller. Chacune doit commencer par `http://` ou `https://` et compter au maximum 2,048 caractères. Max 50 URLs par requête (`MAX_DISTILL_URLS`). Mutuellement exclusif avec `discover_from`. |

### Source : discover puis distill

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `discover_from` | `object` | -- | Découvre d'abord les URLs d'un site, puis distille chacune. Mutuellement exclusif avec `urls`. Nécessite le flag de plan `discover_enabled` (sinon `402`). |
| `discover_from.url` | `string` | -- | URL de départ. Doit commencer par `http://` ou `https://`. Max 2,048 caractères. |
| `discover_from.mode` | `string` | `hybrid` | `sitemap`, `crawl`, ou `hybrid`. Mêmes modes que [l'endpoint discover](/fr/docs/coming-soon/discover.md). |
| `discover_from.max_pages` | `integer` | `10` | Plafond sur les URLs découvertes et distillées, de 1 à 50. Chacune est un rendu complet, donc c'est borné par `MAX_DISTILL_URLS`. |

### Schéma ou prompt

Fournissez **soit** un `schema` (la forme de sortie que vous voulez),
**soit** un `prompt` (une description en langage courant de ce qu'il faut
extraire). Exactement un des deux est obligatoire ; si vous envoyez les
deux, `schema` l'emporte.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `schema` | `object` | `null` | La forme de sortie, envoyée sous la clé JSON `schema`. Soit un objet JSON-Schema (`{"type": "object", "properties": {...}}`), soit une carte plate tolérante `{field: description}`. Max 200 propriétés de premier niveau. La réponse `data` est garantie de correspondre à cette forme. Un schéma structurellement invalide produit un `422`. |
| `prompt` | `string` | `null` | Une description en langage naturel de ce qu'il faut extraire. Fournie sans `schema`, distill synthétise le schéma d'extraction à partir d'elle (un seul modèle) puis exécute le moteur normal à deux passes. Max 2,000 caractères. |

Le schéma est le contrat. Si vous envoyez un objet JSON-Schema, distill
lit ses `properties` ; si vous envoyez une carte plate, chaque clé nomme
un champ et chaque valeur est la description transmise au LLM. Dans les
deux cas, `data` revient avec exactement les clés de premier niveau du
schéma.

**Mode prompt seul.** Si vous envoyez un `prompt` au lieu d'un `schema`,
distill synthétise d'abord un schéma de champs à partir de votre prompt,
puis extrait par rapport à lui. Les champs synthétisés sont renvoyés en
écho dans la réponse sous `synthesized_schema`. Le mode prompt seul
utilise le niveau d'extraction LLM et nécessite un plan qui l'inclut ;
sans cela, distill renvoie un avertissement clair plutôt que de deviner.

### Schéma CSS (optionnel)

Fournissez un `css_schema` pour répondre à des champs gratuitement avant
tout appel LLM. Sans lui, chaque champ tombe directement dans la passe
LLM.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `css_schema.baseSelector` | `string` | -- | Sélecteur CSS pour le conteneur répétitif ; un enregistrement est extrait par correspondance. 1–1,024 caractères. Obligatoire quand `css_schema` est présent. |
| `css_schema.fields` | `CssField[]` | -- | 1–128 définitions de champs lues dans chaque conteneur. Obligatoire. |
| `css_schema.name` | `string` | `"distill"` | Étiquette optionnelle pour le schéma. Max 128 caractères. |
| `css_schema.target_field` | `string` | déduit | Quelle propriété de sortie de premier niveau les enregistrements CSS remplissent : une propriété tableau reçoit la liste complète des enregistrements, une propriété scalaire/objet reçoit le premier enregistrement. Si omis, distill le déduit si le schéma a exactement une propriété tableau. Il doit nommer une propriété que votre `schema` déclare réellement : une faute de frappe produit un `422`, et non un basculement silencieux vers la passe LLM payante. Max 128 caractères. |

Chaque entrée de `fields` est un `CssField` :

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `name` | `string` | -- | Clé de sortie pour ce champ. 1–128 caractères. Obligatoire. |
| `type` | `string` | -- | L'un de `text`, `attribute`, `html`, `regex`, `nested`, `list`, `nested_list`. Obligatoire. |
| `selector` | `string` | `null` | Sous-sélecteur CSS. Optionnel pour les types feuilles (`text`/`attribute`/`html`/`regex`) ; obligatoire pour `nested`/`list`/`nested_list`. Max 1,024 caractères. |
| `attribute` | `string` | `null` | Nom de l'attribut à lire. Obligatoire quand `type` vaut `attribute`. Max 128 caractères. |
| `pattern` | `string` | `null` | Motif regex. Obligatoire quand `type` vaut `regex`, et il doit contenir au moins un groupe de capture : la valeur extraite est le groupe 1 de la correspondance, un motif sans groupe ne peut donc jamais rien renvoyer et produit un `422`. Compilé en périphérie ; deux formes de ReDoS sont rejetées avec `422` : un groupe re-quantifié qui contient déjà un quantificateur (`(a+)+`, `(\d*)*`) et une alternance re-quantifiée de branches à un seul caractère (`(a|a)+`, `(\w|\w)*`). Les alternances à plusieurs caractères comme `(cat|dog)+` restent autorisées. Max 1,024 caractères. |
| `default` | any | `null` | Valeur quand le sélecteur ne correspond à rien. |
| `transform` | `string` | `null` | L'un de `lowercase`, `uppercase`, `strip`. |
| `fields` | `CssField[]` | `null` | Champs enfants, pour `nested`/`list`/`nested_list`. Max 64 enfants ; profondeur d'imbrication totale max 5. |

À signaler : le type de champ `computed` de Crawl4AI n'est délibérément
pas accepté. Sa forme expression exécute `eval` sur l'entrée de
l'appelant, et sa forme callable ne peut pas traverser une frontière
JSON, donc distill n'énumère que les sept types sûrs ci-dessus.

### Options de rendu

Distill ne rend que le DOM, donc il expose un petit sous-ensemble des
[options de rendu de perceive](/fr/docs/endpoints/perceive.md).

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `wait_for` | `string` | `null` | Attendre après la navigation un sélecteur CSS ou une expression JS (`"js:window.dataReady === true"`). Max 1,024 caractères. |
| `wait_timeout_ms` | `integer` | `30000` | Durée maximale d'attente de `wait_for`, en millisecondes. 0–60,000. |
| `headers` | `object` | `null` | En-têtes de requête personnalisés pour le rendu. |
| `cookies` | `array` | `null` | Cookies à injecter avant la navigation. |
| `respect_robots` | `boolean` | `false` | Quand `true`, une URL interdite par le `robots.txt` du site est rejetée et le résultat de cette URL est marqué `failed`. |

---

## Réponse

`POST /v2/distill` renvoie une `DistillResponse` :

| Field | Type | Description |
|-------|------|-------------|
| `operation_id` | `string` | ID opaque (`dst_...`). Citez-le au support. |
| `total` | `integer` | Nombre d'URLs traitées (lignes dans `results`). |
| `completed` | `integer` | URLs qui ont été rendues et ont produit un objet `data`. |
| `failed` | `integer` | URLs dont le rendu a été rejeté ou a planté. |
| `results` | `object[]` | Un `DistillItemResult` par URL, décrit ci-dessous. |
| `total_cost_cents` | `number` | Somme du coût LLM par URL sur l'ensemble de la requête, en cents. |
| `synthesized_schema` | `object` | Présent uniquement en mode prompt seul : le schéma qui a été synthétisé à partir de votre `prompt` et utilisé pour l'extraction. |
| `warnings` | `string[]` | Notes au niveau de la requête (ex. quota d'ops épuisé en cours de liste, incidents de crawl discover). |

Chaque entrée de `results` est un `DistillItemResult` :

| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | L'URL que vous avez envoyée (ou découverte). |
| `url_final` | `string` | L'URL après les redirections. Omis sur une ligne `failed`. |
| `status` | `string` | `completed` ou `failed`. |
| `data` | `object` | Les données extraites, normalisées sur exactement les clés de votre schéma. `null` sur une ligne `failed`. |
| `extraction_tier` | `string` | `css` (CSS uniquement), `llm` (LLM uniquement), `mixed` (les deux ont contribué), ou `none` (rien trouvé). |
| `fields_from_css` | `integer` | Nombre de champs remplis par la passe CSS. |
| `fields_from_llm` | `integer` | Nombre de champs remplis par la passe LLM. |
| `render_quality` | `number` | 0.0–1.0. Les scores bas signalent des défis anti-bot ou des murs de connexion. |
| `tokens` | `object` | Jetons LLM `{input, output}` utilisés. Zéro sauf si la passe LLM s'est exécutée. |
| `cost_cents` | `number` | Coût LLM en cents pour cette URL. Zéro sauf si la passe LLM s'est exécutée. |
| `error` | `string` | Défini uniquement quand `status` vaut `failed`. Un message générique, car le détail interne du rendu reste côté serveur. |
| `warnings` | `string[]` | Notes par URL : un timeout CSS, une passe LLM ignorée, un plafond de budget atteint. |

Pour être direct : la réponse ne contient aucune URL de téléchargement
signée ni aucun artefact stocké. Distill renvoie les `data` structurées en
ligne et rien d'autre. Si vous voulez aussi le Markdown de la page, le
HTML, une capture d'écran, ou un PDF, c'est à ça que sert
[perceive](/fr/docs/endpoints/perceive.md).

---

## Le modèle de coût à deux passes

La passe CSS est gratuite. La passe LLM coûte de l'argent, donc distill la
déclenche de façon aussi ciblée que possible et la plafonne de plusieurs
côtés.

**Elle n'escalade que les champs manquants.** Après la passe CSS, distill
calcule quels champs du schéma sont encore vides : un scalaire revenu en
`null`/`""`, un tableau revenu vide, ou un tableau dont les éléments
manquent d'un sous-champ déclaré. Seuls ces noms de champs entrent dans un
schéma réduit pour l'appel LLM, ce qui maintient le prompt et le coût au
minimum.

**Elle ignore entièrement la passe LLM quand** l'une des conditions
suivantes est vraie, renvoyant le résultat CSS seul avec un avertissement
plutôt que de dépasser le budget :

- Votre plan n'a pas de niveau LLM (`llm_extraction_enabled` plus un
  `agent_model_tier` différent de `none`).
- Le `render_quality` de la page l'a signalée comme bloquée par une
  protection anti-bot.
- Le budget LLM par requête pour cet appel est atteint, ou le plafond de
  budget par période est atteint.

**Les plafonds de budget sont superposés :**

| Cap | Value | Scope |
|-----|-------|-------|
| Par appel | $0.05 (`PER_REQUEST_CAP_CENTS`) | Le coût projeté dans le pire cas d'un appel LLM. Au-delà → ignoré avant toute E/S réseau. |
| Par requête | $0.50 (`_REQUEST_LLM_BUDGET_CENTS`) | Dépense LLM totale sur toutes les URLs d'un appel `/v2/distill`. Les URLs restantes renvoient du CSS seul. |
| Escalades par requête | 50 (`_MAX_LLM_ESCALATIONS`) | Au plus un appel LLM par URL, plafonné strictement. |
| Par période | Votre solde mensuel de crédits IA : $5 / $15 / $40 accordés par mois sur Indie / Studio / Production, les crédits inutilisés sont reportés | `ch_usage_periods.llm_cost_cents` sur les crédits accordés de la période (`usage.reserve_llm_budget`). |

> **Remarque.** Le budget par période est réservé atomiquement avant
> l'appel puis ajusté au coût réel après, de sorte que des appels
> concurrents ne peuvent pas collectivement dépasser le plafond. Un projet
> sans ligne de période d'usage active échoue par défaut de façon fermée,
> car une dépense qu'EnConvert ne peut pas comptabiliser est une dépense
> qu'il ne fait pas. Quand un plafond est atteint, les champs concernés
> reviennent en `null` avec un avertissement ; la requête réussit quand
> même.

Quand la passe LLM s'exécute, `extraction_tier` indique `llm` ou `mixed`,
et `tokens` et `cost_cents` indiquent ce que ça a coûté. Quand elle ne
s'exécute pas, les deux sont à zéro.

---

## Faire correspondre les enregistrements CSS à votre schéma

Le cas courant est une page de liste : une ligne répétitive, et un schéma
de sortie avec une propriété tableau pour contenir les lignes. Donnez à
distill un `css_schema` dont le `baseSelector` correspond à la ligne et
dont les `fields` lisent les colonnes, et il remplit le tableau
gratuitement :

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com/products"],
    "schema": {
      "type": "object",
      "properties": {
        "products": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "name": {"type": "string"},
              "price": {"type": "string"},
              "sku": {"type": "string"}
            }
          }
        }
      }
    },
    "css_schema": {
      "baseSelector": ".product-card",
      "target_field": "products",
      "fields": [
        {"name": "name", "type": "text", "selector": ".title"},
        {"name": "price", "type": "text", "selector": ".price"},
        {"name": "sku", "type": "attribute",
         "selector": ".product-card", "attribute": "data-sku"}
      ]
    }
  }'
```

Comment les enregistrements se placent sur le schéma :

- `target_field` défini sur une propriété **tableau** → cette propriété
  reçoit la liste complète des enregistrements.
- `target_field` défini sur une propriété **scalaire/objet** → elle
  reçoit le premier enregistrement.
- `target_field` omis, le schéma a **exactement une** propriété tableau →
  distill le déduit et remplit cette propriété.
- Sinon → le premier enregistrement est traité comme un unique objet plat
  et ses clés correspondantes sont remontées au premier niveau.

Si le CSS remplit le tableau mais que certains éléments manquent d'un
sous-champ déclaré (disons que `sku` est absent sur la moitié des cartes),
distill escalade `products` vers la passe LLM pour combler les manques.
C'est ce qui distingue les deux passes d'un simple scraper : structuré
quand c'est possible, appuyé sur un modèle quand c'est nécessaire.

---

## Exemples de code

### curl : schéma plat, LLM uniquement

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": ["https://example.com/article"],
    "schema": {
      "headline": "the article headline",
      "author": "the author name",
      "published": "the publish date"
    }
  }'
```

### curl : discover puis distill

```bash
curl -X POST https://api.enconvert.com/v2/distill \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "discover_from": {
      "url": "https://example.com/blog",
      "mode": "sitemap",
      "max_pages": 25
    },
    "schema": {
      "title": "the post title",
      "summary": "a one-line summary"
    }
  }'
```

### Python

```python
import requests

response = requests.post(
    "https://api.enconvert.com/v2/distill",
    headers={"X-API-Key": "sk_your_private_key"},
    json={
        "urls": ["https://example.com/product/widget"],
        "schema": {
            "name": "the product name",
            "price": "the listed price",
            "in_stock": "whether it is in stock",
        },
    },
)
response.raise_for_status()
result = response.json()

for item in result["results"]:
    if item["status"] == "completed":
        print(item["url"], "->", item["data"])
    else:
        print(item["url"], "FAILED:", item["error"])

print("total cost (cents):", result["total_cost_cents"])
```

### Node.js

```javascript
const res = await fetch("https://api.enconvert.com/v2/distill", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-API-Key": "sk_your_private_key"
    },
    body: JSON.stringify({
        urls: ["https://example.com/product/widget"],
        schema: {
            name: "the product name",
            price: "the listed price",
            in_stock: "whether it is in stock"
        }
    })
});

const result = await res.json();

for (const item of result.results) {
    if (item.status === "completed") {
        console.log(item.url, "->", item.data);
    } else {
        console.log(item.url, "FAILED:", item.error);
    }
}

console.log("total cost (cents):", result.total_cost_cents);
```

---

## Réponses d'erreur

| Status | Condition |
|--------|-----------|
| `400 Bad Request` | Une URL se résout vers une adresse privée, loopback, ou link-local, n'a pas de nom d'hôte, ou échoue autrement au filtre SSRF. Chaque URL d'une liste `urls` explicite est filtrée en périphérie avant tout rendu : une seule mauvaise URL rejette donc la requête entière. L'URL de départ de `discover_from` est filtrée de la même façon à l'intérieur de discover. |
| `401 Unauthorized` | Clé API / jeton JWT manquant ou invalide. |
| `402 Payment Required` | Distill n'est pas inclus dans votre plan actuel (l'offre gratuite ne le comprend pas), votre quota mensuel d'ops est épuisé, ou `discover_from` a été envoyé sans le flag de plan `discover_enabled`. |
| `403 Forbidden` | `/v2/distill` ne figure pas dans les endpoints autorisés de la clé API. |
| `422 Unprocessable Entity` | Ni `schema` ni `prompt` ; `urls`/`discover_from` envoyés ensemble ou aucun des deux ; une clé inconnue n'importe où dans le corps ; un schéma structurellement invalide ; un schéma qui ne déclare aucun champ (une carte plate ne portant que `type`, `properties` ou `required`) ; plus de 200 propriétés de schéma ; un `css_schema.target_field` qui ne nomme aucune propriété du schéma ; un `CssField` invalide (`attribute`/`pattern`/`fields` manquant pour son type, un `regex` sans groupe de capture, une regex non compilable ou propice au ReDoS, un `transform` inconnu) ; ou une imbrication de champs CSS de plus de 5 niveaux. |
| `500 Internal Server Error` | L'orchestration a échoué de façon inattendue. Le message inclut l'`operation_id` à citer au support. |

Quelques précisions sur les statuts à garder en tête. Le SSRF est filtré en
amont pour une liste `urls` explicite : une seule URL pointant vers une
adresse privée produit donc un `400` pour toute la requête, et rien n'est
rendu ni facturé ; une URL qui échoue simplement au rendu devient, elle,
une ligne de résultat `failed` à l'intérieur d'un `200`. Les URLs atteintes
via `discover_from` sont filtrées à chaque rendu et remontent également
sous forme de lignes `failed`. Un plafond de budget LLM n'est jamais une
erreur, car il dégrade vers un résultat CSS seul avec un avertissement. La référence complète des codes de statut se
trouve dans [le guide des codes d'erreur](/fr/docs/reference/errors.md).

---

## Limites

| Limit | Value |
|-------|-------|
| URLs par requête (`urls`) | 50 (`MAX_DISTILL_URLS`) |
| `discover_from.max_pages` | 1–50 |
| Longueur d'URL | 2,048 caractères |
| Propriétés de premier niveau du schéma | 200 (`MAX_SCHEMA_PROPERTIES`) |
| `css_schema.fields` | 1–128 |
| Enfants `CssField.fields` | 64 |
| Profondeur d'imbrication des champs CSS | 5 (`MAX_CSS_FIELD_DEPTH`) |
| Longueur de `wait_for` | 1,024 caractères |
| `wait_timeout_ms` | 0–60,000 ms |
| Timeout de la passe CSS | 10 secondes par URL |
| Plafond LLM par appel | $0.05 |
| Plafond LLM par requête | $0.50 |
| Escalades LLM par requête | 50 |
| Plafond LLM par période | Solde mensuel de crédits IA ($5 / $15 / $40 selon le palier ; les crédits inutilisés sont reportés) |
| Ops mensuelles (partagées entre tous les endpoints, 1 par URL aboutie) | 500 / 3,000 / 15,000 / 50,000 selon le palier ; voir [les tarifs](/fr/pricing.md) |

---

## Questions fréquentes

### Comment extraire des données structurées d'un site web avec une API REST ?

Envoyez `POST /v2/distill` avec `urls` (jusqu'à 50 par requête) et un `schema`, soit un objet JSON-Schema, soit une carte plate `{field: description}`. La réponse `data` revient normalisée sur exactement les clés de premier niveau de votre schéma.

### Puis-je scraper un site web vers un schéma JSON sans écrire de sélecteurs CSS ?

Oui. `css_schema` est optionnel, et sans lui chaque champ tombe directement dans la passe assistée par LLM. Fournir un `css_schema` remplit gratuitement les champs adressables par sélecteur et n'escalade que les champs que la passe CSS a laissés vides.

### Combien coûte la passe d'extraction LLM, et comment est-elle plafonnée ?

Les plafonds de budget sont superposés : $0.05 par appel LLM, $0.50 par requête `/v2/distill`, au plus 50 escalades par requête, et, par période, votre solde mensuel de crédits IA ($5 / $15 / $40 sur Indie / Studio / Production ; les crédits inutilisés sont reportés). L'extraction LLM consomme des crédits, pas des ops. Atteindre un plafond ne fait jamais échouer la requête : les champs concernés reviennent en `null` avec un avertissement.

### Pourquoi certains champs sont-ils null dans ma réponse distill ?

Les scalaires manquants sont normalisés en `null` (et les tableaux manquants en `[]`) pour préserver la garantie de forme. La passe LLM est ignorée (avec un avertissement) quand votre plan n'a pas de niveau LLM, que le `render_quality` de la page l'a signalée comme bloquée, ou qu'un plafond de budget a été atteint.

### Puis-je crawler un site entier et extraire le même schéma de chaque page ?

Oui. Envoyez `discover_from` avec une `url` de départ, un `mode` (`sitemap`, `crawl`, ou `hybrid`), et `max_pages` (1-50) au lieu de `urls`. Cela nécessite le flag de plan `discover_enabled` ; sans lui, la requête est rejetée avec `402`.
