Distill
POST /v2/distill extrae datos estructurados de una o varias URL para que
coincidan con un esquema que tú proporcionas. Funciona con un motor de dos
pasadas: primero una pasada CSS gratuita (la JsonCssExtractionStrategy de
Crawl4AI guiada por tus selectores) y luego —solo para los campos que la
pasada CSS dejó vacíos— una pasada LLM acotada sobre claude-haiku-4-5 de
Anthropic. El data de la respuesta está garantizado que vuelve con la
forma exacta que pediste. Es la respuesta de EnConvert a Firecrawl
/extract.
Aquí tienes la llamada útil más pequeña. Envía una URL y un esquema plano
{field: description}, y recibe de vuelta los campos extraídos:
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_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 respuesta lleva un resultado por URL, el data extraído y qué nivel lo
produjo:
{
"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": []
}
Conviene señalarlo desde el principio.
/v2/distilles un endpoint V2 con su propio contador de cuota,distill_operations, donde una operación equivale a una URL destilada. Los planes de conversión V1 no incluyen ninguna operación de distill: necesitas un plan que incluya V2 para llamarlo. Distill solo factura las URL que se completan, así que un fallo de renderizado de EnConvert nunca te cuesta una unidad. Consulta la visión general de inteligencia web V2 para ver cómo se relacionan los contadores, y la página de precios para las asignaciones por nivel.
Endpoints
| Method | Path | Propósito |
|---|---|---|
POST |
/v2/distill |
Destila una lista de URL explícita, o descubre primero las URL de un sitio y destila cada una, contra un único esquema. |
Content-Type: application/json.
A diferencia de perceive, distill es un único endpoint síncrono: no hay un GET de refetch separado ni una ruta de lote asíncrono. Cada URL se renderiza secuencialmente a través del singleton compartido de Chrome headless y todo el conjunto de resultados vuelve en una sola respuesta.
Autenticación
Autentícate con una clave privada en la cabecera X-API-Key para llamadas
servidor a servidor. Esta es la ruta que usan los ejemplos de abajo.
X-API-Key: sk_live_your_private_key
Las claves públicas con un token bearer JWT también funcionan, usando el
mismo flujo que cualquier otro endpoint: genera un token con tu clave pk_
y luego envíalo como Authorization: Bearer <token>. El flujo completo,
incluyendo el bloqueo de dominio y la renovación de token, está en la guía
de autenticación.
Cada clave API lleva una lista de endpoints permitidos. Si /v2/distill no
está en la lista de la clave, la petición se rechaza con 403.
Cómo funciona distill
Una petición destila una lista de URL contra un único esquema. El flujo es el mismo para cada URL:
- Resuelve la lista de URL. Con
urls, la lista es exactamente la que enviaste (deduplicada, conservando el orden). Condiscover_from, distill ejecuta primero discover sobre la URL semilla —analizando el sitemap, rastreando, o ambos— y luego destila las URL descubiertas hastamax_pages. - Renderiza. Cada URL se renderiza una vez en Chrome headless a través
del mismo pipeline de captura que impulsa
perceive. El renderizado se examina contra SSRF
y, si
respect_robots=true, se comprueba contra elrobots.txtdel sitio. No se suben artefactos al almacenamiento: distill solo necesita el DOM renderizado. - Pasada 1 — CSS (gratuita). Si proporcionaste un
css_schema, el extractor CSS se ejecuta sobre el HTML renderizado y rellena cada campo direccionable por selector con coste LLM cero. Esta pasada está acotada a 10 segundos; al agotarse el tiempo, la URL cae a la pasada LLM con una advertencia. - Pasada 2 — LLM (acotada, solo cuando es necesaria). Distill recopila
los campos del esquema que la pasada CSS dejó faltantes o vacíos y
escala solo esos campos a
claude-haiku-4-5, bajo topes de presupuesto estrictos por llamada y por periodo. Si tu plan no tiene nivel LLM, la página fue marcada como bloqueada, o se alcanza un tope de presupuesto, la pasada LLM se omite y los campos faltantes vuelven comonullcon una advertencia. - Normaliza. El resultado combinado se reestructura exactamente a las
claves de tu esquema: los escalares faltantes se vuelven
null, los arrays faltantes se vuelven[], y cualquier clave extra se descarta. La garantía de forma se mantiene sin importar lo que produjeron CSS o el LLM.
El contador de cuota se incrementa en uno solo después de que una URL se
completa. Los fallos de renderizado (rechazo por SSRF, bloqueo por robots,
un renderizado caído) producen una fila de resultado failed y no cuestan
cuota.
Parámetros de la petición
Debes proporcionar exactamente uno entre urls o discover_from, más
un schema. Enviar ambos, o ninguno, es un 422.
Origen — URL explícitas
| Parameter | Type | Default | Descripción |
|---|---|---|---|
urls |
string[] |
— | URL explícitas a destilar. Cada una debe empezar por http:// o https:// y tener como máximo 2.048 caracteres. Máximo 50 URL por petición (MAX_DISTILL_URLS). Mutuamente excluyente con discover_from. |
Origen — descubrir y luego destilar
| Parameter | Type | Default | Descripción |
|---|---|---|---|
discover_from |
object |
— | Descubre primero las URL de un sitio y luego destila cada una. Mutuamente excluyente con urls. Requiere el flag de plan discover_enabled (de lo contrario 402). |
discover_from.url |
string |
— | URL semilla. Debe empezar por http:// o https://. Máximo 2.048 caracteres. |
discover_from.mode |
string |
hybrid |
sitemap, crawl, o hybrid. Los mismos modos que el endpoint discover. |
discover_from.max_pages |
integer |
10 |
Tope de URL descubiertas y destiladas. 1–50: cada una es un renderizado completo, así que está acotado por MAX_DISTILL_URLS. |
Esquema (obligatorio)
| Parameter | Type | Default | Descripción |
|---|---|---|---|
schema |
object |
— | La forma de salida, enviada bajo la clave JSON schema. Puede ser un objeto JSON-Schema ({"type": "object", "properties": {...}}) o un mapa plano y permisivo {field: description}. Máximo 200 propiedades de nivel superior. El data de la respuesta está garantizado que coincide con esta forma. Un esquema estructuralmente inválido es un 422. Obligatorio. |
El esquema es el contrato. Si envías un objeto JSON-Schema, distill lee sus
properties; si envías un mapa plano, cada clave nombra un campo y cada
valor es la descripción que se le entrega al LLM. En cualquier caso, data
vuelve con exactamente las claves de nivel superior del esquema.
Esquema CSS (opcional)
Proporciona un css_schema para responder campos gratis antes de cualquier
llamada al LLM. Sin él, cada campo cae directamente a la pasada LLM.
| Parameter | Type | Default | Descripción |
|---|---|---|---|
css_schema.baseSelector |
string |
— | Selector CSS para el contenedor repetido; se extrae un registro por coincidencia. 1–1.024 caracteres. Obligatorio cuando css_schema está presente. |
css_schema.fields |
CssField[] |
— | 1–128 definiciones de campo leídas de cada contenedor. Obligatorio. |
css_schema.name |
string |
"distill" |
Etiqueta opcional para el esquema. Máximo 128 caracteres. |
css_schema.target_field |
string |
inferido | Qué propiedad de salida de nivel superior rellenan los registros CSS: una propiedad de tipo array recibe la lista completa de registros, una propiedad escalar/objeto recibe el primer registro. Cuando se omite, distill la infiere si el esquema tiene exactamente una propiedad de tipo array. Máximo 128 caracteres. |
Cada entrada en fields es un CssField:
| Field | Type | Default | Descripción |
|---|---|---|---|
name |
string |
— | Clave de salida para este campo. 1–128 caracteres. Obligatorio. |
type |
string |
— | Uno de text, attribute, html, regex, nested, list, nested_list. Obligatorio. |
selector |
string |
null |
Subselector CSS. Opcional para los tipos hoja (text/attribute/html/regex); obligatorio para nested/list/nested_list. Máximo 1.024 caracteres. |
attribute |
string |
null |
Nombre del atributo a leer. Obligatorio cuando type es attribute. Máximo 128 caracteres. |
pattern |
string |
null |
Patrón regex. Obligatorio cuando type es regex. Se compila en el edge; un patrón con grupos anidados re-cuantificados (una forma ReDoS como (a+)+) se rechaza con 422. Máximo 1.024 caracteres. |
default |
any | null |
Valor cuando el selector no coincide con nada. |
transform |
string |
null |
Uno de lowercase, uppercase, strip. |
fields |
CssField[] |
null |
Campos hijos, para nested/list/nested_list. Máximo 64 hijos; profundidad total de anidamiento máxima 5. |
Conviene señalarlo: el tipo de campo computed de Crawl4AI no se acepta
deliberadamente. Su forma de expresión ejecuta eval sobre la entrada del
llamante, y su forma invocable no puede cruzar un límite JSON, así que
distill enumera solo los siete tipos seguros de arriba.
Controles de renderizado
Distill solo renderiza el DOM, así que expone un pequeño subconjunto de las opciones de renderizado de perceive.
| Parameter | Type | Default | Descripción |
|---|---|---|---|
wait_for |
string |
null |
Espera tras la navegación a un selector CSS o una expresión JS ("js:window.dataReady === true"). Máximo 1.024 caracteres. |
wait_timeout_ms |
integer |
30000 |
Cuánto tiempo puede esperar wait_for, en milisegundos. 0–60.000. |
headers |
object |
null |
Cabeceras de petición personalizadas para el renderizado. |
cookies |
array |
null |
Cookies a inyectar antes de la navegación. |
respect_robots |
boolean |
false |
Cuando es true, una URL no permitida por el robots.txt del sitio se rechaza y el resultado de esa URL se marca como failed. |
Respuesta
POST /v2/distill devuelve un DistillResponse:
| Field | Type | Descripción |
|---|---|---|
operation_id |
string |
ID opaco (dst_...). Cítalo al soporte. |
total |
integer |
Número de URL procesadas (filas en results). |
completed |
integer |
URL que se renderizaron y produjeron un objeto data. |
failed |
integer |
URL cuyo renderizado fue rechazado o se cayó. |
results |
object[] |
Un DistillItemResult por URL: ver abajo. |
total_cost_cents |
number |
Suma del coste LLM por URL en toda la petición, en céntimos. |
warnings |
string[] |
Notas a nivel de petición (p. ej. cuota agotada a mitad de la lista, fallos del rastreo de discover). |
Cada entrada en results es un DistillItemResult:
| Field | Type | Descripción |
|---|---|---|
url |
string |
La URL que enviaste (o descubierta). |
url_final |
string |
La URL tras las redirecciones. Se omite en una fila failed. |
status |
string |
completed o failed. |
data |
object |
Los datos extraídos, normalizados exactamente a las claves de tu esquema. null en una fila failed. |
extraction_tier |
string |
css (solo CSS), llm (solo LLM), mixed (ambos contribuyeron), o none (no se encontró nada). |
fields_from_css |
integer |
Recuento de campos que rellenó la pasada CSS. |
fields_from_llm |
integer |
Recuento de campos que rellenó la pasada LLM. |
render_quality |
number |
0.0–1.0. Las puntuaciones bajas señalan desafíos anti-bot o muros de inicio de sesión. |
tokens |
object |
Tokens LLM usados {input, output}. Cero salvo que se ejecutara la pasada LLM. |
cost_cents |
number |
Coste LLM en céntimos para esta URL. Cero salvo que se ejecutara la pasada LLM. |
error |
string |
Establecido solo cuando status es failed. Un mensaje genérico: el detalle interno del renderizado queda del lado del servidor. |
warnings |
string[] |
Notas por URL: un timeout de CSS, una pasada LLM omitida, un tope de presupuesto alcanzado. |
Para ser claros: la respuesta no lleva URL de descarga firmadas ni
artefactos almacenados. Distill devuelve el data estructurado en línea y
nada más: si también quieres el Markdown de la página, el HTML, una captura
de pantalla o un PDF, para eso está perceive.
El modelo de coste de dos pasadas
La pasada CSS es gratuita. La pasada LLM cuesta dinero, así que distill la dispara de la forma más acotada posible y la limita desde varios frentes.
Escala solo los campos faltantes. Tras la pasada CSS, distill calcula
qué campos del esquema siguen vacíos: un escalar que volvió como
null/"", un array que volvió vacío, o un array cuyos elementos carecen
de un subcampo declarado. Solo esos nombres de campo entran en un esquema
reducido para la llamada LLM, lo que mantiene el prompt y el coste al
mínimo.
Omite la pasada LLM por completo cuando se cumple cualquiera de estos, devolviendo el resultado solo-CSS con una advertencia en lugar de gastar de más:
- Tu plan no tiene nivel LLM (
llm_extraction_enabledmás unagent_model_tierdistinto denone). - El
render_qualityde la página la marcó como bloqueada por protección anti-bot. - Se alcanza el presupuesto LLM por petición para esta llamada, o se alcanza el tope de presupuesto por periodo.
Los topes de presupuesto están estratificados:
| Cap | Value | Alcance |
|---|---|---|
| Por llamada | $0.05 (PER_REQUEST_CAP_CENTS) |
Coste proyectado en el peor caso de una llamada LLM. Si se supera → omitida antes de cualquier I/O de red. |
| Por petición | $0.50 (_REQUEST_LLM_BUDGET_CENTS) |
Gasto LLM total en todas las URL de una llamada a /v2/distill. Las URL restantes devuelven solo-CSS. |
| Escalaciones por petición | 50 (_MAX_LLM_ESCALATIONS) |
Como máximo una llamada LLM por URL, con tope estricto. |
| Por periodo | $5 por defecto, $20 en Pro y superiores | ch_usage_periods.llm_cost_cents para tu periodo de facturación (usage.reserve_llm_budget). |
Nota. El presupuesto por periodo se reserva atómicamente antes de la llamada y se ajusta al coste real después, de modo que las llamadas concurrentes no pueden, en conjunto, sobrepasar el tope. Un proyecto sin una fila de periodo de uso activa falla de forma cerrada: el gasto que EnConvert no puede contabilizar es gasto que no realiza. Cuando se alcanza un tope, los campos afectados vuelven como
nullcon una advertencia; la petición aun así tiene éxito.
Cuando la pasada LLM sí se ejecuta, extraction_tier informa llm o
mixed, y tokens y cost_cents informan lo que costó. Cuando no se
ejecuta, ambos son cero.
Mapear registros CSS a tu esquema
El caso común es una página de listado: una fila repetida, y un esquema de
salida con una propiedad de tipo array para alojar las filas. Dale a
distill un css_schema cuyo baseSelector coincida con la fila y cuyos
fields lean las columnas, y rellenará el array gratis:
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_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"}
]
}
}'
Cómo aterrizan los registros en el esquema:
target_fieldapuntando a una propiedad de tipo array → esa propiedad recibe la lista completa de registros.target_fieldapuntando a una propiedad escalar/objeto → recibe el primer registro.target_fieldomitido, el esquema tiene exactamente una propiedad de tipo array → distill la infiere y rellena esa propiedad.- En otro caso → el primer registro se trata como un único objeto plano y sus claves coincidentes se elevan al nivel superior.
Si CSS rellena el array pero algunos elementos carecen de un subcampo
declarado —pongamos que sku falta en la mitad de las tarjetas— distill
escala products a la pasada LLM para rellenar los huecos. Ese es el
diferenciador de las dos pasadas frente a un scraper sencillo:
estructurado donde puede serlo, respaldado por el modelo donde tiene que
serlo.
Ejemplos de código
curl — esquema plano, solo-LLM
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_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 — descubrir y luego destilar
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_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_live_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_live_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);
Restricciones del plan
Las operaciones de distill se miden en su propio contador
distill_operations, separado de las conversiones V1 y del contador de
perceive. Una operación equivale a una URL
destilada, y solo se cuentan las URL que se completan. Los planes de
conversión V1 no incluyen ninguna cuota de distill: necesitas un plan que
incluya V2.
La pasada LLM dirigida por esquema (claude-haiku-4-5) es una restricción
de plan adicional: solo se ejecuta cuando tu plan lleva
llm_extraction_enabled y un nivel de modelo de agente distinto de none.
Sin ello, distill devuelve resultados solo-CSS y anota los campos omitidos
en warnings. El presupuesto LLM por periodo es de $5 en el nivel por
defecto y de $20 en Pro y superiores.
Usar discover_from requiere además el flag de plan discover_enabled: de
lo contrario la petición se rechaza con 402, de modo que un plan solo de
distill no puede llegar a discover gratis enrutando por aquí.
Las asignaciones de distill_operations por nivel y los planes que
incluyen el nivel LLM están en la página de precios.
Respuestas de error
| Status | Condición |
|---|---|
400 Bad Request |
Una URL (o la semilla de discover_from) resuelve a una dirección privada, de loopback o link-local, no tiene hostname, o de otro modo no pasa el examen SSRF. Se lanza por URL durante el renderizado. |
401 Unauthorized |
Clave API / token JWT ausente o inválido. |
402 Payment Required |
Distill no está en tu plan actual, tu cuota mensual de distill_operations está agotada, o se envió discover_from sin el flag de plan discover_enabled. |
403 Forbidden |
/v2/distill no está en los endpoints permitidos de la clave API. |
422 Unprocessable Entity |
Falta schema, ambos o ninguno de urls/discover_from, un esquema estructuralmente inválido, más de 200 propiedades de esquema, un CssField inválido (le falta attribute/pattern/fields para su tipo, un regex no compilable o propenso a ReDoS), o anidamiento de campos CSS de más de 5 de profundidad. |
500 Internal Server Error |
La orquestación falló de forma inesperada. El mensaje incluye el operation_id para citarlo al soporte. |
Unas pocas notas sobre estados que conviene tener claras: una sola URL cuyo
renderizado es rechazado por SSRF expone un 400 solo cuando es la semilla
de una petición discover_from; para una lista urls explícita, un rechazo
de renderizado por URL se convierte en una fila de resultado failed en
lugar de hacer fallar toda la petición. Un tope de presupuesto LLM nunca es
un error: degrada a un resultado solo-CSS con una advertencia. La
referencia completa de códigos de estado está en la guía de códigos de
error.
Límites
| Límite | Value |
|---|---|
URL por petición (urls) |
50 (MAX_DISTILL_URLS) |
discover_from.max_pages |
1–50 |
| Longitud de URL | 2.048 caracteres |
| Propiedades de nivel superior del esquema | 200 (MAX_SCHEMA_PROPERTIES) |
css_schema.fields |
1–128 |
Hijos de CssField.fields |
64 |
| Profundidad de anidamiento de campos CSS | 5 (MAX_CSS_FIELD_DEPTH) |
Longitud de wait_for |
1.024 caracteres |
wait_timeout_ms |
0–60.000 ms |
| Timeout de la pasada CSS | 10 segundos por URL |
| Tope LLM por llamada | $0.05 |
| Tope LLM por petición | $0.50 |
| Escalaciones LLM por petición | 50 |
| Tope LLM por periodo | $5 por defecto / $20 Pro+ |
distill_operations mensuales |
Depende del plan: ver precios |