API para Extraer Datos Estructurados de un Sitio Web#
POST /v2/distill es una API para extraer datos estructurados de sitios
web: extrae campos de una o más URLs para que coincidan con un esquema
que tú proporcionas, ya sea un objeto JSON-Schema o un mapa plano
{field: description}. Ejecuta un motor de dos pasadas: primero una
pasada CSS gratuita (JsonCssExtractionStrategy de Crawl4AI, controlada
por tus selectores), y luego una pasada limitada asistida por LLM
para los campos que la pasada CSS dejó vacíos. La respuesta data
está garantizada a devolverse exactamente
en la forma que pediste. Será la respuesta de EnConvert a /extract de
Firecrawl.
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_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 trae un resultado por URL, los data extraídos, y qué nivel
los 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": []
}
Endpoints#
| Método | Ruta | Propósito |
|---|---|---|
POST |
/v2/distill |
Ejecuta distill sobre una lista explícita de URLs, o usa discover para descubrir primero las URLs de un sitio y aplica distill a cada una, contra el mismo esquema. |
Content-Type: application/json.
A diferencia de perceive, distill es un único endpoint síncrono: no hay una ruta separada de reobtención GET ni un camino asíncrono por lotes. Cada URL se renderiza secuencialmente a través del singleton compartido de Chrome headless y el conjunto completo de resultados vuelve en una sola respuesta.
Autenticación#
Autentícate con una clave privada en el encabezado X-API-Key para
llamadas servidor a servidor. Este es el camino que usan los ejemplos de
abajo.
X-API-Key: sk_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 tokens,
está en la guía de autenticación.
Cada clave de API lleva una lista de endpoints permitidos. Si
/v2/distill no está en la lista de la clave, la solicitud se rechaza
con 403.
Cómo funciona distill#
Una solicitud aplica distill a una lista de URLs contra un esquema. El flujo es el mismo para cada URL:
- Resuelve la lista de URLs. Con
urls, la lista es exactamente lo que enviaste (deduplicada, con el orden preservado). Condiscover_from, distill ejecuta discover primero sobre la URL semilla (analizando el sitemap, rastreando, o ambas cosas) y luego aplica distill a las URLs 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 filtra contra
SSRF y, si
respect_robots=true, se verifica contra elrobots.txtdel sitio. No se sube ningún artefacto al almacenamiento, porque distill solo necesita el DOM renderizado. - Pasada 1: CSS (gratis). Si proporcionaste un
css_schema, el extractor CSS se ejecuta sobre el HTML renderizado y llena cada campo direccionable por selector a costo cero de LLM. Esta pasada está limitada a 10 segundos; si se agota el tiempo, la URL cae a la pasada LLM con una advertencia. - Pasada 2: LLM (limitada, solo cuando hace falta). Distill
recopila los campos del esquema que la pasada CSS dejó faltantes o
vacíos y escala solo esos campos a una pasada asistida por LLM,
bajo topes de presupuesto estrictos por llamada y por período. 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 como
nullcon una advertencia. - Normaliza. El resultado combinado se remodela para que coincida
exactamente con 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 haya producido CSS o el LLM.
Se factura una op por URL, y solo después de que esa URL se
completa. Los fallos de renderizado (rechazo por SSRF, bloqueo de
robots, un renderizado fallido) producen una fila de resultado failed
y no cuestan ops.
Parámetros de la solicitud#
Debes proporcionar exactamente uno de urls o discover_from, más
un schema. Enviar ambos, o ninguno, produce un 422.
Origen: URLs explícitas#
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
urls |
string[] |
-- | URLs explícitas para aplicar distill. Cada una debe comenzar con http:// o https:// y tener como máximo 2,048 caracteres. Máximo 50 URLs por solicitud (MAX_DISTILL_URLS). Mutuamente excluyente con discover_from. |
Origen: descubrir y luego aplicar distill#
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
discover_from |
object |
-- | Descubre primero las URLs de un sitio, y luego aplica distill a cada una. Mutuamente excluyente con urls. Requiere el flag de plan discover_enabled (de lo contrario 402). |
discover_from.url |
string |
-- | URL semilla. Debe comenzar con 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 URLs descubiertas y procesadas con distill, de 1 a 50. Cada una es un renderizado completo, así que está acotado por MAX_DISTILL_URLS. |
Esquema o prompt#
Proporciona o bien un schema (la forma de salida que quieres) o
bien un prompt (una descripción en lenguaje natural de qué extraer).
Se requiere exactamente uno; si envías ambos, gana schema.
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
schema |
object |
null |
La forma de salida, enviada bajo la clave JSON schema. Puede ser un objeto JSON-Schema ({"type": "object", "properties": {...}}) o un mapa plano y flexible {field: description}. Máximo 200 propiedades de nivel superior. La data de la respuesta está garantizada a coincidir con esta forma. Un esquema estructuralmente inválido produce un 422. |
prompt |
string |
null |
Una descripción en lenguaje natural de qué extraer. Cuando se envía sin un schema, distill sintetiza a partir de ella el esquema de extracción (un solo modelo) y luego ejecuta el motor normal de dos pasadas. Máximo 2,000 caracteres. |
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 pasa al LLM. De cualquier forma,
data vuelve con exactamente las claves de nivel superior del esquema.
Modo solo-prompt. Si envías un prompt en lugar de un schema,
distill primero sintetiza un esquema de campos a partir de tu prompt, y
luego extrae contra él. Los campos sintetizados se devuelven como eco en
la respuesta como synthesized_schema. El modo solo-prompt usa el nivel
de extracción LLM y requiere un plan que lo incluya; sin uno, distill
devuelve una advertencia clara en lugar de adivinar.
Esquema CSS (opcional)#
Proporciona un css_schema para responder campos gratis antes de
cualquier llamada al LLM. Sin él, todos los campos caen directo a la
pasada LLM.
| Parámetro | Tipo | Predeterminado | 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 llenan los registros de CSS: una propiedad de tipo array recibe la lista completa de registros, una propiedad escalar/objeto recibe el primer registro. Cuando se omite, distill lo infiere si el esquema tiene exactamente una propiedad de tipo array. Máximo 128 caracteres. |
Cada entrada en fields es un CssField:
| Campo | Tipo | Predeterminado | 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 |
Sub-selector CSS. Opcional para 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 anidación máximo 5. |
Vale la pena señalar: el tipo de campo computed de Crawl4AI
deliberadamente no se acepta. Su forma de expresión ejecuta eval sobre
la entrada del llamador, 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.
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
wait_for |
string |
null |
Espera después de la navegación un selector CSS o una expresión JS ("js:window.dataReady === true"). Máximo 1,024 caracteres. |
wait_timeout_ms |
integer |
30000 |
Cuánto puede esperar wait_for, en milisegundos. 0–60,000. |
headers |
object |
null |
Encabezados de solicitud personalizados 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:
| Campo | Tipo | Descripción |
|---|---|---|
operation_id |
string |
ID opaco (dst_...). Cítalo al contactar a soporte. |
total |
integer |
Número de URLs procesadas (filas en results). |
completed |
integer |
URLs que se renderizaron y produjeron un objeto data. |
failed |
integer |
URLs cuyo renderizado fue rechazado o falló. |
results |
object[] |
Un DistillItemResult por URL, descrito abajo. |
total_cost_cents |
number |
Suma del costo de LLM por URL en toda la solicitud, en centavos. |
synthesized_schema |
object |
Presente solo en el modo solo-prompt: el esquema que se sintetizó a partir de tu prompt y se usó para la extracción. |
warnings |
string[] |
Notas a nivel de solicitud (p. ej. cuota de ops agotada a mitad de la lista, fallos de rastreo de discover). |
Cada entrada en results es un DistillItemResult:
| Campo | Tipo | Descripción |
|---|---|---|
url |
string |
La URL que enviaste (o que se descubrió). |
url_final |
string |
La URL después de redirecciones. Se omite en una fila failed. |
status |
string |
completed o failed. |
data |
object |
Los datos extraídos, normalizados a exactamente 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 |
Cantidad de campos que llenó la pasada CSS. |
fields_from_llm |
integer |
Cantidad de campos que llenó la pasada LLM. |
render_quality |
number |
0.0–1.0. Puntuaciones bajas señalan desafíos anti-bot o muros de inicio de sesión. |
tokens |
object |
Tokens de LLM usados {input, output}. Cero a menos que se haya ejecutado la pasada LLM. |
cost_cents |
number |
Costo de LLM en centavos para esta URL. Cero a menos que se haya ejecutado la pasada LLM. |
error |
string |
Se establece solo cuando status es failed. Un mensaje genérico, porque el detalle interno del renderizado se 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 directos: la respuesta no lleva URLs de descarga firmadas ni
artefactos almacenados. Distill devuelve la data estructurada en línea
y nada más. Si además quieres el Markdown de la página, el HTML, una
captura de pantalla, o un PDF, para eso está
perceive.
El modelo de costo de dos pasadas#
La pasada CSS es gratis. La pasada LLM cuesta dinero, así que distill la dispara lo más acotada posible y la limita desde varios frentes.
Escala solo los campos faltantes. Después de 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 sub-campo declarado. Solo esos nombres de campo entran en
un esquema reducido para la llamada al LLM, lo que mantiene el prompt y
el costo al mínimo.
Omite la pasada LLM por completo cuando se cumple cualquiera de estas condiciones, devolviendo el resultado solo-CSS con una advertencia en lugar de sobregastar:
- 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 de LLM por solicitud para esta llamada, o se alcanza el tope de presupuesto por período.
Los topes de presupuesto están en capas:
| Tope | Valor | Alcance |
|---|---|---|
| Por llamada | $0.05 (PER_REQUEST_CAP_CENTS) |
Costo proyectado en el peor caso de una llamada al LLM. Por encima → se omite antes de cualquier I/O de red. |
| Por solicitud | $0.50 (_REQUEST_LLM_BUDGET_CENTS) |
Gasto total de LLM en todas las URLs de una llamada a /v2/distill. Las URLs restantes vuelven solo-CSS. |
| Escalaciones por solicitud | 50 (_MAX_LLM_ESCALATIONS) |
Como máximo una llamada al LLM por URL, con tope estricto. |
| Por período | Tu saldo mensual de créditos de IA: $5 / $15 / $40 otorgados al mes en Indie / Studio / Production, los créditos no usados se acumulan | ch_usage_periods.llm_cost_cents contra los créditos otorgados del período (usage.reserve_llm_budget). |
Nota. El presupuesto por período se reserva de forma atómica antes de la llamada y se ajusta al costo real después, así que las llamadas concurrentes no pueden superar el tope en conjunto. Un proyecto sin una fila de período de uso activa falla de forma segura, porque 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 solicitud igual se completa con éxito.
Cuando la pasada LLM sí se ejecuta, extraction_tier reporta llm o
mixed, y tokens y cost_cents reportan lo que costó. Cuando no se
ejecuta, ambos son cero.
Cómo se mapean los registros de 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 contener las filas. Dale
a distill un css_schema cuyo baseSelector coincida con la fila y
cuyos fields lean las columnas, y llenará el array gratis:
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"}
]
}
}'
Cómo aterrizan los registros en el esquema:
target_fieldestablecido en una propiedad de tipo array → esa propiedad recibe la lista completa de registros.target_fieldestablecido en una propiedad escalar/objeto → recibe el primer registro.target_fieldomitido, el esquema tiene exactamente una propiedad de tipo array → distill lo infiere y llena esa propiedad.- De lo contrario → el primer registro se trata como un único objeto plano y sus claves coincidentes se elevan al nivel superior.
Si CSS llena el array pero algunos elementos carecen de un sub-campo
declarado (digamos que sku falta en la mitad de las tarjetas),
distill escala products a la pasada LLM para llenar los vacíos. Ese es
el diferenciador de dos pasadas frente a un scraper plano: estructurado
donde se pueda, respaldado por modelo donde haga falta.
Ejemplos de código#
curl: esquema plano, solo LLM#
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: descubrir y luego aplicar 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);
Respuestas de error#
| Estado | 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 otra forma falla el filtro SSRF. Se genera por URL durante el renderizado. |
401 Unauthorized |
Clave de API / token JWT faltante o inválido. |
402 Payment Required |
Distill no está en tu plan actual, tu cuota mensual de ops está agotada, o discover_from se envió sin el flag de plan discover_enabled. |
403 Forbidden |
/v2/distill no está entre los endpoints permitidos de la clave de API. |
422 Unprocessable Entity |
Falta schema, se enviaron ambos o ninguno de urls/discover_from, un esquema estructuralmente inválido, más de 200 propiedades de esquema, un CssField inválido (falta attribute/pattern/fields para su tipo, un regex no compilable o propenso a ReDoS), o anidación de campos CSS más profunda que 5. |
500 Internal Server Error |
La orquestación falló inesperadamente. El mensaje incluye el operation_id para citar a soporte. |
Algunas notas de estado que vale la pena tener claras: una sola URL cuyo
renderizado es rechazado por SSRF muestra un 400 solo cuando es la
semilla de una solicitud discover_from; para una lista explícita de
urls, un rechazo de renderizado por URL se convierte en una fila de
resultado failed en lugar de hacer fallar toda la solicitud. Un tope
de presupuesto de LLM nunca es un error, porque se 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 | Valor |
|---|---|
URLs por solicitud (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 anidación 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 por llamada de LLM | $0.05 |
| Tope por solicitud de LLM | $0.50 |
| Escalaciones de LLM por solicitud | 50 |
| Tope por período de LLM | Saldo mensual de créditos de IA ($5 / $15 / $40 según el nivel; los créditos no usados se acumulan) |
| Ops mensuales (compartidas entre todos los endpoints, 1 por URL completada) | 500 / 3.000 / 15.000 / 50.000 según el nivel; ver precios |
Preguntas frecuentes#
¿Cómo extraigo datos estructurados de un sitio web con una API REST?#
Envía POST /v2/distill con urls (hasta 50 por solicitud) y un
schema, ya sea un objeto JSON-Schema o un mapa plano
{field: description}. La data de la respuesta vuelve normalizada a
exactamente las claves de nivel superior de tu esquema.
¿Puedo extraer un sitio web hacia un esquema JSON sin escribir selectores CSS?#
Sí. css_schema es opcional, y sin él todos los campos caen directo a
la pasada asistida por LLM. Proporcionar un css_schema
llena gratis los campos direccionables por selector y escala solo los
campos que la pasada CSS dejó vacíos.
¿Cuánto cuesta la pasada de extracción LLM, y cómo se limita?#
Los topes de presupuesto están en capas: $0.05 por llamada al LLM, $0.50
por solicitud a /v2/distill, como máximo 50 escalaciones por
solicitud, y por período tu saldo mensual de créditos de IA ($5 / $15 /
$40 en Indie / Studio / Production; los créditos no usados se
acumulan). La extracción con LLM consume créditos, no ops. Alcanzar un tope nunca hace fallar la
solicitud: los campos afectados vuelven como null con una
advertencia.
¿Por qué algunos campos aparecen como null en mi respuesta de distill?#
Los escalares faltantes se normalizan a null (y los arrays faltantes a
[]) para preservar la garantía de forma. La pasada LLM se omite (con
una advertencia) cuando tu plan no tiene nivel LLM, el render_quality
de la página la marcó como bloqueada, o se alcanzó un tope de
presupuesto.
¿Puedo rastrear un sitio completo y extraer el mismo esquema de cada página?#
Sí. Envía discover_from con una url semilla, un mode (sitemap,
crawl, o hybrid), y max_pages (1–50) en lugar de urls. Requiere
el flag de plan discover_enabled; sin él la solicitud se rechaza con
402.