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

Bêta privée. Distill est appelable dès aujourd'hui avec votre clé d'API habituelle, sur tous les forfaits y compris Founding, et décompte votre quota mensuel d'ops comme n'importe quel autre appel. Ce n'est ni annoncé ni disponible de façon générale : les formes de requête et de réponse peuvent changer sans préavis, et il n'y a aucun engagement de stabilité ni de support, donc ne construisez rien de critique dessus pour l'instant. La feuille de route est sur Bientôt disponible, et chaque publication est annoncée dans le changelog.

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. Ce sera 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 :

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 :

{
    "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, 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.

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.

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 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. Le rendu est filtré contre le SSRF et, si respect_robots=true, vérifié 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 un schema. En envoyer les deux, ou aucun des deux, produit un 422.

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.
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. 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. Compilé en périphérie ; un motif avec des groupes re-quantifiés imbriqués (une forme ReDoS comme (a+)+) est rejeté avec 422. 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.

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.


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 :

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#

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#

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#

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#

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 (ou l'URL de départ de discover_from) 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. Levée par URL pendant le rendu.
401 Unauthorized Clé API / jeton JWT manquant ou invalide.
402 Payment Required Distill n'est pas inclus dans votre plan actuel, 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 Pas de schema, urls/discover_from envoyés ensemble ou aucun des deux, un schéma structurellement invalide, plus de 200 propriétés de schéma, un CssField invalide (attribute/pattern/fields manquant pour son type, une regex non compilable ou propice au ReDoS), 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 : une URL unique dont le rendu est rejeté pour SSRF ne remonte un 400 que lorsqu'elle est l'URL de départ d'une requête discover_from ; pour une liste urls explicite, un rejet de rendu par URL devient une ligne de résultat failed plutôt que de faire échouer toute la requête. 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.


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

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.