---
seo_title: API de Web Scraping: URL a Markdown, Captura y PDF | EnConvert
meta_desc: POST /v2/perceive renderiza una página JavaScript en Chrome headless y devuelve Markdown, capturas, PDF y datos estructurados en una sola llamada API.
keywords: api de web scraping a markdown, convertir url a markdown api, capturar pantalla de pagina web api, renderizar javascript para scraping api, html a markdown api, extraer datos estructurados de una web api, api scraping para llm, web scraping por lotes api
---

# API de Web Scraping para Markdown, Capturas de Pantalla y Datos Estructurados

`POST /v2/perceive` es la API de web scraping de EnConvert: renderiza una
URL una vez en un navegador headless real, con JavaScript ejecutado y
contenido de carga diferida (lazy load) ya cargado, y te devuelve todos
los outputs que pidas a partir de ese único render: Markdown limpio (por
defecto solo el contenido principal, sin el chrome del sitio), HTML
limpio o en bruto, una captura de pantalla, un PDF, el inventario de
enlaces e imágenes, y datos estructurados (metadatos de la página, JSON-LD,
encabezados, tablas). Los outputs de archivo vuelven como URLs de
descarga pre-firmadas de corta duración, el bloque estructurado va
inline, y los lotes de más de 10 URLs se ejecutan de forma asíncrona
detrás de un `job_id` que se consulta por polling. Una sola solicitud
reemplaza toda una pila de llamadas separadas: url-to-markdown,
url-to-screenshot, url-to-pdf, más tu propio scraping.

Este es el llamado más pequeño y útil. Envía una URL y recibe Markdown
limpio junto con los metadatos estructurados de la página:

```bash
curl -X POST https://api.enconvert.com/v2/perceive \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pricing",
    "outputs": ["markdown", "structured"]
  }'
```

La respuesta incluye una URL de descarga pre-firmada para el archivo
Markdown y el bloque estructurado inline:

```json
{
    "operation_id": "per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
    "status": "completed",
    "url": "https://example.com/pricing",
    "url_final": "https://example.com/pricing",
    "content_hash": "9f2b8c1a...d4e5",
    "render_quality": 0.93,
    "cache_hit": false,
    "outputs": {
        "markdown": {
            "url": "https://spaces.example.com/...signed...",
            "object_key": "env/files/4127/v2-perceive/per_3f9a..._markdown.md",
            "size_bytes": 8421,
            "content_type": "text/markdown; charset=utf-8",
            "expires_in": 900
        }
    },
    "structured": {
        "metadata": {
            "title": "Pricing",
            "description": "Simple, usage-based pricing."
        },
        "structured_data": [
            {"@type": "Product", "name": "Studio", "offers": {"price": "99"}}
        ]
    },
    "extraction_tier": "heuristic",
    "tokens": {"input": 0, "output": 0},
    "cost_cents": 0.0,
    "duration_ms": 6230,
    "warnings": []
}
```

---

## Endpoints

| Método | Ruta | Propósito |
|--------|------|---------|
| `POST` | `/v2/perceive` | Percibe una única URL y devuelve los outputs solicitados. |
| `GET` | `/v2/perceive/{operation_id}` | Vuelve a obtener una operación pasada con URLs de descarga recién firmadas. |
| `POST` | `/v2/perceive/batch` | Percibe hasta 1,000 URLs que comparten un mismo conjunto de opciones. |
| `GET` | `/v2/perceive/batch/{job_id}` | Consulta el estado y los resultados por URL de un lote. |
| `DELETE` | `/v2/perceive/batch/{job_id}` | Cancela un lote en ejecución. |

**Content-Type:** `application/json` en cada `POST`.

---

## Autenticación

Autentícate con una clave privada en el encabezado `X-API-Key` para
llamadas de servidor a servidor. Este es el flujo que usan los ejemplos
a continuación.

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

Las claves públicas con un token JWT bearer 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, incluido el bloqueo por dominio y la renovación de tokens,
está en [la guía de autenticación](/es/docs/authentication.md).

Cada clave de API tiene una lista blanca de endpoints permitidos. Si
`/v2/perceive` no está en la lista de la clave, la solicitud se rechaza
con `403`.

---

## Cómo funciona perceive

Una solicitud dispara un render de navegador a través de un singleton
compartido de Chrome headless, y luego materializa cada output a partir
de ese render. Nunca pagas por la misma página dos veces en una sola
llamada.

1. **Render.** La página se obtiene a través de un fallback automático de
   múltiples motores: primero una huella TLS de navegador real y rápida,
   escalando a Chrome headless cuando la página está bloqueada o necesita
   JavaScript, y una vez más a un render reforzado con técnicas de sigilo
   (stealth) cuando una página sigue pareciendo bloqueada por protección
   anti-bot, de modo que más páginas del mundo real devuelven contenido
   utilizable. En el navegador, se descartan los banners de cookies, se
   hace scroll en la página para disparar el contenido de carga diferida,
   se gestionan los encabezados fijos (sticky), y se da tiempo a que las
   imágenes carguen. Es el mismo pipeline de captura que impulsa [el
   endpoint url-to-pdf](/es/docs/endpoints/convert/web-pages/url-to-pdf.md).
2. **Materializar.** A partir del DOM renderizado, perceive construye
   todo lo que hayas indicado en `outputs`: Markdown, HTML limpio/en
   bruto, enlaces, imágenes, una captura de pantalla, un PDF. El DOM se
   normaliza antes para que el Markdown refleje lo que ve un lector: los
   fences de código conservan su lenguaje, los enlaces de tarjeta su
   estructura, y los elementos de interfaz se eliminan bajo
   `only_main_content`. Consulta [Calidad del Markdown](#calidad-del-markdown).
3. **Extraer.** Si solicitaste el output `structured`, perceive ejecuta
   una pasada heurística para obtener metadatos de la página, JSON-LD,
   encabezados y tablas. Si además envías un `schema` y tu plan incluye
   el nivel LLM, una pasada asistida por LLM completa el schema
   cuando la pasada heurística se queda corta.
4. **Puntuar.** Un puntaje de calidad de render (0.0–1.0) distingue un
   render real de uno fallido. Los puntajes por debajo de 0.40 indican
   un render fallido: una página anti-bot, un muro de inicio de sesión,
   una página de error HTTP, un soft 404 o un cascarón vacío. Consulta
   `deductions` para conocer el motivo y `status_code` para el estado
   del servidor de origen.

Los outputs binarios y de texto (Markdown, HTML, capturas de pantalla,
PDFs, el JSON de enlaces e imágenes) se suben al almacenamiento y se
devuelven como **URLs pre-firmadas** que expiran a los 15 minutos. El
bloque `structured` se devuelve inline en el JSON. Vuelve a obtener
cualquier operación con `GET /v2/perceive/{operation_id}` para conseguir
un nuevo conjunto de URLs firmadas.

---

## Parámetros de la solicitud

La validación es estricta: una clave de solicitud que el schema no
conoce se rechaza con `422` nombrando el campo afectado. Las claves
desconocidas nunca se ignoran silenciosamente. Cada cuerpo `422` incluye
además un array `errors` de nivel superior con mensajes legibles por
humanos, junto a la lista `detail` legible por máquinas.

### Básicos

| Parámetro | Tipo | Valor por defecto | Descripción |
|-----------|------|---------|-------------|
| `url` | `string` | - | La página a percibir. Debe comenzar con `http://` o `https://`. Máximo 2,048 caracteres. Obligatorio. |
| `outputs` | `string[]` | `["markdown", "structured"]` | Qué outputs producir. Consulta [Salidas](#outputs). |
| `extract` | `string[]` | `[]` | Qué campos estructurados extraer cuando `structured` está en `outputs`. Consulta [Extracción estructurada](#extraccion-estructurada). |
| `schema` | `object` | `null` | Un JSON schema que describe los campos que quieres extraer. Activa el nivel de extracción LLM en los planes que lo incluyen. |
| `only_main_content` | `boolean` | `true` | Elimina el chrome del sitio (navegación, encabezado, pie de página, barras laterales, banners de cookies, nodos ocultos) y los elementos de interfaz (botones, tiras de pestañas, widgets «¿Te ha resultado útil esta página?», etiquetas solo para lectores de pantalla, migas de pan) del output `markdown` y del extract `main_content`, protegido por una guarda de fidelidad: si la limpieza eliminara demasiado contenido real, se devuelve la página completa y se añade una advertencia. Las URLs de imagen se renderizan como su texto alt (la lista completa de imágenes sigue disponible vía `outputs: ["images"]`). Define `false` para la página completa, sin eliminar nada. Consulta [Calidad del Markdown](#calidad-del-markdown). |
| `truncate_data_arrays` | `boolean` | sin definir | Colapsa series largas de literales numéricos (vectores de embeddings en bruto, volcados de tensores impresos en celdas de salida de notebooks) a una muestra inicial más un recuento, p. ej. `... [truncated 1520 of 1536 values]`. Sin definir sigue a `only_main_content`: activo cuando la página se está depurando, inactivo cuando pediste la página tal cual. Define `true` o `false` para controlarlo explícitamente. |
| `allow_degraded` | `boolean` | `false` | Devuelve el render incluso cuando es un desafío anti-bot o una página de bloqueo sin contenido de página. Por defecto, un render así falla con `502` en lugar de entregar el texto de la interstitial como si fuera la página. |
| `direct_download` | `boolean` | `false` | Devuelve los bytes del artefacto directamente como cuerpo de la respuesta HTTP en lugar de un sobre JSON. Requiere exactamente un output que produzca artefacto. Solo para solicitudes de una URL, ya que el endpoint de lotes lo rechaza con `422`. Consulta [Descarga directa](#descarga-directa). |
| `cache_mode` | `string` | `"enabled"` | `enabled`, `bypass`, o `refresh`. Consulta [Caché](#cache). |

### Salidas {: #outputs }

`outputs` acepta cualquier combinación de estos nombres:

| Output | Se devuelve como | Qué obtienes |
|--------|-------------|--------------|
| `markdown` | URL firmada | Markdown limpio de la página. Con `only_main_content` (por defecto `true`) se elimina el chrome del sitio, como navegación, encabezado, pie de página, barras laterales, banners de cookies y nodos ocultos, detrás de una guarda de fidelidad, y las URLs de imagen se renderizan como su texto alt. Los bloques de código conservan su lenguaje en el fence (` ```python `) en ambos modos. Define `only_main_content: false` para la página completa. Consulta [Calidad del Markdown](#calidad-del-markdown). |
| `html_cleaned` | URL firmada | El HTML renderizado con scripts, estilos y elementos de relleno (boilerplate) eliminados. |
| `html_raw` | URL firmada | El HTML renderizado completo, tal como lo produjo el navegador. |
| `screenshot` | URL firmada | Un PNG del viewport en el tamaño de viewport solicitado (o el predeterminado). |
| `screenshot_full_page` | URL firmada | Un PNG de página completa que captura todo el alto del scroll. |
| `pdf` | URL firmada | Un PDF de la página. Admite toda la superficie de `pdf_options` (ver abajo). |
| `links` | URL firmada | Un array JSON con todos los enlaces encontrados, con URLs absolutas y texto de anclaje. |
| `images` | URL firmada | Un array JSON con todas las imágenes, con `src` absoluto y texto `alt`. |
| `structured` | JSON inline | Datos estructurados extraídos de la página (el campo `structured` de la respuesta). |

### Calidad del Markdown

Antes de convertir la página, el DOM renderizado se normaliza para que
el Markdown refleje lo que ve un lector y no cómo se construyó la
página. Esto se ejecuta en cada render, así que el resultado no depende
de qué estrategia de extracción gane para una página concreta.

Se aplica siempre, en ambos modos de `only_main_content`:

- **Los fences de código conservan su lenguaje.** El lenguaje se lee de
  la convención que use el sitio (`class="language-python"`,
  `data-lang`, un atributo `language` desnudo o un wrapper del
  resaltador) y se normaliza, de modo que llega ` ```python ` en lugar
  de un fence pelado.
- **Los enlaces de tarjeta siguen siendo legibles.** Un enlace que
  envuelve un encabezado y una descripción se convierte en un título
  enlazado seguido de su descripción, en lugar de un único enlace
  amontonado como
  `[DatabaseSupabase provides a full Postgres database...]`. La URL de
  destino se conserva.
- **Los encabezados se mantienen en una sola línea.** Un encabezado
  cuyo texto vive dentro de un elemento anidado ya no emite un `##`
  pelado con el texto varado debajo.
- **Los elementos adyacentes ya no se concatenan.** Los layouts que
  separan sus elementos con CSS en vez de con espacios producían
  `YesNo` y `EvaluationDeploymentProduction`; ahora se leen como
  palabras separadas.
- **Se eliminan los caracteres invisibles**: espacios de ancho cero
  usados como etiquetas de anclaje, guiones suaves y glifos del Área de
  Uso Privado de las fuentes de iconos, que llegan como tokens no
  imprimibles.
- **Se descartan los elementos vacíos**: elementos `<i>` que solo
  contenían un icono y se renderizaban como un `__` suelto, y enlaces
  cuya etiqueta está vacía.

Además, con `only_main_content: true`:

- **Se eliminan los controles de interfaz**: botones, tiras de
  pestañas, pistas de atajos de teclado, acciones «Copy page» / «On
  this page» y widgets de valoración «¿Te ha resultado útil esta
  página? Sí/No». Un control que contiene contenido real (una pregunta
  de FAQ, el cuerpo de una tarjeta clicable) se conserva.
- **Se elimina el texto destinado solo a lectores de pantalla**:
  enlaces de salto y las etiquetas «Section titled ...» que muchos
  temas de documentación añaden a cada encabezado.
- **Se respeta el contenido que el sitio declara como no-contenido**:
  bloques marcados con `data-nosnippet`, `data-pagefind-ignore` o
  `data-noindex`, salvo que contengan encabezados o código.
- **Se colapsan los bloques duplicados**: los diseños responsive que
  envían una copia de escritorio y otra móvil de la misma barra, y los
  carruseles que pre-renderizan cada fotograma, aparecen una sola vez.
- **Se descartan las migas de pan y las etiquetas de antetítulo
  situadas sobre el título de la página.**

El contenido diferido se conserva *deliberadamente*: un panel de
pestaña inactivo dentro de la región de contenido guarda un ejemplo de
código real (el ejemplo de Python en una pestaña, el de JavaScript en
otra), de modo que ambos llegan al Markdown y no solo la pestaña que
por casualidad estuviera seleccionada en el momento del render.

### Renderizado y espera

| Parámetro | Tipo | Valor por defecto | Descripción |
|-----------|------|---------|-------------|
| `viewport` | `object` | `1920 x 1080` | `{"width": <int>, "height": <int>}`. Ancho 320–3840, alto 240–2160. |
| `mobile` | `boolean` | `false` | Renderiza en un viewport móvil (390 x 844) a menos que se defina `viewport` explícitamente. |
| `wait_for` | `string` | `null` | Espera tras la navegación un selector CSS (`".price"` o `"css:.price"`) o una expresión JS (`"js:window.dataReady === true"`). |
| `wait_timeout_ms` | `integer` | `30000` | Cuánto puede esperar `wait_for`, en milisegundos. 0–60,000. Un timeout se degrada a advertencia; la página se captura tal cual. |
| `js_code` | `string` | `null` | JavaScript para ejecutar en la página tras la navegación. Máximo 20,000 caracteres. Un error se convierte en advertencia, no en fallo. |
| `block_resources` | `string[]` | `[]` | Tipos de recurso a abortar antes de que carguen. Cualquiera de `image`, `media`, `font`, `stylesheet`, `script`, `xhr`, `fetch`, `websocket`, `manifest`, `other`. Útil para renders más rápidos, solo de texto. |
| `respect_robots` | `boolean` | `false` | Cuando es `true`, una URL no permitida por el `robots.txt` del sitio se rechaza con `403`. |
| `pdf_options` | `object` | `null` | Formato de página, márgenes, encabezados, pies de página, escala y orientación para el output `pdf`. Mismo objeto que [url-to-pdf](/es/docs/endpoints/convert/web-pages/url-to-pdf.md). Sin `pdf_options`, perceive produce una única página continua, idéntica byte a byte a la de V1 url-to-pdf. |

### Solicitudes autenticadas y personalizadas

| Parámetro | Tipo | Valor por defecto | Descripción |
|-----------|------|---------|-------------|
| `auth` | `object` | `null` | HTTP Basic Auth para la página de destino: `{"username": "...", "password": "..."}`. |
| `cookies` | `array` | `null` | Cookies a inyectar antes de la navegación. Máximo 50. Cada una necesita `name`, `value`, y `domain` o `url`. |
| `headers` | `object` | `null` | Encabezados de solicitud personalizados. Máximo 20. Nombres bloqueados: `host`, `content-length`, `transfer-encoding`, `connection`, `upgrade`, `te`, `trailer`. |

<div class="alert alert-warning">
<strong>Reservado, aún no disponible.</strong> <code>proxy_url</code> (Production+),
<code>geolocation</code> y <code>action_chain</code> son aceptados por el
schema de la solicitud pero hoy devuelven <code>422</code>. Llegarán en una
versión posterior; enviarlos ahora te indica exactamente qué opción no está
lista en lugar de ignorarla silenciosamente.
</div>

---

## Extracción estructurada

Cuando `structured` está en `outputs`, la lista `extract` controla qué
campos extrae perceive. Si no pides nada, se usa por defecto `metadata`
y `structured_data`.

| Valor de `extract` | Campo en `structured` | Estado |
|-----------------|-----------------------|--------|
| `metadata` | `metadata` | Activo |
| `structured_data` | `structured_data` (JSON-LD) | Activo |
| `headings` | `headings` | Activo |
| `tables` | `tables` | Activo |
| `main_content` | `main_content` (texto, limitado a 50,000 caracteres) | Activo |
| `all` | se expande a todos los campos activos anteriores | Activo |
| `prices` | - | Aún no disponible: devuelve una advertencia, se omite |
| `contacts` | - | Aún no disponible: devuelve una advertencia, se omite |
| `technologies` | - | Aún no disponible: devuelve una advertencia, se omite |

Para ser claros: `prices`, `contacts` y `technologies` son nombres
reservados. Si solicitas uno hoy, no da error: el nombre cae en el array
`warnings` y se elimina de `structured`.

### Extracción guiada por schema

Envía un `schema` para extraer campos específicos hacia
`structured.extracted`:

```json
{
    "url": "https://example.com/product/widget",
    "outputs": ["markdown", "structured"],
    "schema": {
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "price": {"type": "number"},
            "in_stock": {"type": "boolean"}
        }
    }
}
```

El nivel de extracción asistida por LLM completa el schema,
y solo se activa cuando se cumplen **todas** estas condiciones: enviaste
un `schema`, tu plan incluye el nivel LLM (Indie en adelante), la
página no fue puntuada como bloqueada, y la pasada heurística dejó
campos del schema vacíos. Cuando se ejecuta, `extraction_tier` es
`"llm"`, y `tokens` y `cost_cents` reportan lo que costó esa extracción;
en caso contrario, `extraction_tier` es `"heuristic"` y ambos son cero.

> **Nota.** La extracción por schema tiene un límite estricto para
> proteger tu factura: una única extracción está limitada por solicitud,
> y el gasto del proyecto consume tu saldo mensual de créditos de IA
> ($5 / $15 / $40 al mes en Indie / Studio / Production; los créditos
> no usados se acumulan). La extracción con LLM consume créditos, no
> ops. Si se alcanza un límite o se agota el saldo, perceive devuelve
> el resultado heurístico con una nota en `warnings` en lugar de gastar
> de más. En un plan sin el nivel
> LLM, solo obtienes datos `structured` heurísticos.

---

## Respuesta {: #response }

Tanto `POST /v2/perceive` como `GET /v2/perceive/{operation_id}`
devuelven el mismo objeto.

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `operation_id` | `string` | ID opaco (`per_...`). Úsalo con el endpoint GET y cítalo al contactar a soporte. |
| `status` | `string` | `queued`, `processing`, `completed`, o `failed`. |
| `url` | `string` | La URL que enviaste. |
| `url_final` | `string` | La URL después de las redirecciones. |
| `content_hash` | `string` | SHA-256 de la página renderizada. Determina la caché de 1 hora. |
| `render_quality` | `number` | 0.0–1.0. Los puntajes por debajo de 0.40 indican un render fallido: una página anti-bot, un muro de inicio de sesión, una página de error HTTP, un soft 404 o un cascarón vacío. Consulta `deductions` para conocer el motivo y `status_code` para el estado del servidor de origen. |
| `status_code` | `integer` | Estado HTTP de la respuesta final del documento principal (p. ej. `200`, `404`). `null` cuando se desconoce. |
| `deductions` | `object` | Deducciones de calidad de render con nombre que se aplicaron, p. ej. `{"http_error": 0.7}`, `{"soft_404": 0.65}`, `{"login_wall": 0.65}`. Vacío en un render limpio. |
| `options_echo` | `object` | Eco de las opciones de la solicitud que el servidor aplicó. Los secretos se reducen a booleanos (`auth_provided`, `cookies_provided`, `headers_provided`, `js_code_provided`, `schema_provided`, `pdf_options_provided`). Las opciones simples (`outputs`, `only_main_content`, `truncate_data_arrays`, `allow_degraded`, `extract`, `cache_mode`, `mobile`, `respect_robots`, `direct_download`, `wait_for`, `wait_timeout_ms`, `viewport`, `block_resources`) se devuelven tal como se aplicaron. `truncate_data_arrays` se devuelve como el booleano **resuelto**, de modo que aunque lo dejes sin definir sabrás en qué sentido se resolvió. |
| `cache_hit` | `boolean` | `true` cuando el resultado vino de la caché en lugar de un render nuevo. |
| `outputs` | `object` | Mapa de nombre de output a `{url, object_key, size_bytes, content_type, expires_in}`. Las URLs firmadas expiran en 900 segundos. |
| `structured` | `object` | Datos estructurados inline, presentes cuando se solicitó `structured`. |
| `extraction_tier` | `string` | `heuristic`, `css`, o `llm`. |
| `tokens` | `object` | Tokens LLM usados en formato `{input, output}`. Cero a menos que se haya ejecutado el nivel LLM. |
| `cost_cents` | `number` | Costo LLM en centavos para esta operación. Cero a menos que se haya ejecutado el nivel LLM. |
| `duration_ms` | `integer` | Tiempo de render de extremo a extremo. |
| `error` | `string` | Solo se define cuando `status` es `failed`. |
| `warnings` | `string[]` | Notas no fatales: un timeout de `wait_for`, un extract omitido, una marca de página bloqueada, un fallback de `only_main_content` a la página completa, un aviso de que se truncaron arrays numéricos largos. |

---

## Recuperar una operación {: #retrieve-an-operation }

Las URLs firmadas expiran a los 15 minutos. Para descargar un output más
tarde, vuelve a obtener la operación. Perceive vuelve a firmar cada URL
a partir de las claves de objeto almacenadas. No se produce ningún
re-render, así que esto no consume ops.

```bash
curl https://api.enconvert.com/v2/perceive/per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
  -H "X-API-Key: sk_your_private_key"
```

Un ID de operación desconocido, o uno que pertenece a otro proyecto,
devuelve `404`. La existencia nunca se filtra entre proyectos.

---

## Descarga directa

Por defecto, cada output de archivo vuelve como una URL pre-firmada que
obtienes en una segunda solicitud. Define `direct_download: true` en el
POST para saltarte el sobre: el cuerpo de la respuesta HTTP **es** los
bytes del artefacto, sin JSON, sin URL firmada y sin segunda descarga.
La solicitud debe producir exactamente un output que genere artefacto
(`outputs: ["markdown"]`, `outputs: ["pdf"]`, …), de lo contrario se
rechaza con `400`. Los metadatos que habrían ido en el JSON viajan en
cambio en encabezados de respuesta: `Content-Disposition`,
`X-Operation-Id`, `X-Object-Key`, `X-Cache-Hit`, `X-Render-Quality`,
`X-Source-Status-Code`, `X-Content-Hash` y `X-Warnings-Count`.

```bash
curl -X POST https://api.enconvert.com/v2/perceive \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/blog/post",
    "outputs": ["markdown"],
    "direct_download": true
  }' \
  -o post.md
```

Los endpoints GET transmiten los artefactos almacenados de la misma
manera:

- `GET /v2/perceive/{operation_id}?direct_download=true&output=markdown`
  transmite un artefacto de una operación pasada. `output` es
  obligatorio cuando la operación produjo más de un artefacto. Un
  artefacto que superó la ventana de retención de tu plan responde
  `410`.
- `GET /v2/perceive/batch/{job_id}?direct_download=true` transmite el
  ZIP del lote. Vale para lotes con `output_mode: "zip"` cuyo archivo ya
  está listo; de lo contrario responde `400`.

`direct_download` es solo para URLs individuales: `POST
/v2/perceive/batch` lo rechaza con `422`. Define `output_mode` como
`"zip"` y descarga el archivo comprimido. Consulta [Percepción por
lotes](#batch-perception).

---

## Percepción por lotes {: #batch-perception }

`POST /v2/perceive/batch` percibe una lista de URLs que comparten un
mismo bloque `options`. Cada URL se renderiza a través del mismo
pipeline que una llamada individual y genera su propia fila de
operación.

```bash
curl -X POST https://api.enconvert.com/v2/perceive/batch \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "urls": [
      "https://example.com/a",
      "https://example.com/b",
      "https://example.com/c"
    ],
    "options": {"outputs": ["markdown"]},
    "output_mode": "manifest"
  }'
```

Los lotes de 10 URLs o menos se ejecutan inline y responden `200` con
todos los resultados completados. Los lotes más grandes responden `202`
con un `job_id`; las URLs se procesan una a la vez y las consultas por
polling para obtener los resultados:

```bash
curl https://api.enconvert.com/v2/perceive/batch/{job_id} \
  -H "X-API-Key: sk_your_private_key"
```

La respuesta del lote reporta el progreso agregado y lleva un resultado
completo de perceive por cada URL una vez renderizada:

```json
{
    "job_id": "bat_8c1a...",
    "status": "partial",
    "output_mode": "manifest",
    "total": 3,
    "completed": 2,
    "failed": 1,
    "pending": 0,
    "items": [
        {"operation_id": "per_...", "status": "completed", "url": "https://example.com/a"},
        {"operation_id": "per_...", "status": "completed", "url": "https://example.com/b"},
        {"operation_id": "per_...", "status": "failed", "url": "https://example.com/c"}
    ]
}
```

`status` es `queued`, `processing`, `completed`, `failed`, `partial`
(algunas URLs tuvieron éxito, otras fallaron), o `canceled`. Define
`output_mode` como `zip` para agrupar todos los artefactos en un único
ZIP, devuelto en el campo `zip` cuando el lote termina.

### Durable y reanudable

Los lotes son resistentes a reinicios. Si el servicio se reinicia
mientras un lote está en curso, el lote **se reanuda automáticamente** y
solo vuelve a renderizar las URLs que no habían terminado, así que las
URLs ya completadas conservan sus artefactos. Nunca necesitas reenviar un lote
por culpa de un reinicio.

### Cancelar un lote

`DELETE /v2/perceive/batch/{job_id}` cancela un lote en ejecución. El
worker se detiene entre URLs, así que las URLs ya renderizadas conservan
sus resultados y el resto queda sin iniciar. La llamada es idempotente,
así que cancelar un lote que ya terminó simplemente devuelve su estado
actual, y el `status` del lote pasa a `canceled`.

```bash
curl -X DELETE https://api.enconvert.com/v2/perceive/batch/{job_id} \
  -H "X-API-Key: sk_your_private_key"
```

---

## Caché

`cache_mode` controla cómo perceive trata su caché de resultados de 1
hora, indexada por tu proyecto, la URL y las opciones de la solicitud
que afectan al render.

| `cache_mode` | Comportamiento |
|--------------|-----------|
| `enabled` (por defecto) | Devuelve un resultado en caché cuando una solicitud idéntica se renderizó en la última hora. `cache_hit` es `true`, `cost_cents` es `0`. |
| `bypass` | Omite la caché y renderiza de nuevo. |
| `refresh` | Renderiza de nuevo y reemplaza la entrada en caché. |

Vale la pena aclarar: un acierto de caché igual cuenta como una
op contra tu cuota mensual de ops. La cuota mide
operaciones, no renders de navegador, así que la caché te ahorra tiempo
de render, no ops.

---

## Ejemplos de código

### curl: Solo Markdown

```bash
curl -X POST https://api.enconvert.com/v2/perceive \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/blog/post",
    "outputs": ["markdown"]
  }'
```

### curl: Markdown más datos estructurados

```bash
curl -X POST https://api.enconvert.com/v2/perceive \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/pricing",
    "outputs": ["markdown", "structured"],
    "extract": ["metadata", "structured_data", "tables"]
  }'
```

### curl: Outputs completos más PDF

```bash
curl -X POST https://api.enconvert.com/v2/perceive \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/report",
    "outputs": ["markdown", "html_cleaned", "screenshot_full_page", "pdf"],
    "pdf_options": {"format": "A4", "print_background": true}
  }'
```

### Python

```python
import requests

response = requests.post(
    "https://api.enconvert.com/v2/perceive",
    headers={"X-API-Key": "sk_your_private_key"},
    json={
        "url": "https://example.com/pricing",
        "outputs": ["markdown", "structured"],
        "extract": ["metadata", "tables"],
    },
)
response.raise_for_status()
data = response.json()

# Download the Markdown artifact from its signed URL
markdown_url = data["outputs"]["markdown"]["url"]
markdown_text = requests.get(markdown_url).text

print(data["structured"])
print(markdown_text)
```

### Node.js

```javascript
const res = await fetch("https://api.enconvert.com/v2/perceive", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-API-Key": "sk_your_private_key"
    },
    body: JSON.stringify({
        url: "https://example.com/pricing",
        outputs: ["markdown", "structured"],
        extract: ["metadata", "tables"]
    })
});

const data = await res.json();

// Download the Markdown artifact from its signed URL
const markdownText = await fetch(data.outputs.markdown.url).then(r => r.text());

console.log(data.structured);
console.log(markdownText);
```

Si llamas a EnConvert desde Claude, Cursor u otro cliente MCP, la misma
capacidad se expone como la herramienta `perceive_url`. Consulta [la
página del servidor MCP](/es/mcp.md).

---

## Respuestas de error

| Estado | Condición |
|--------|-----------|
| `400 Bad Request` | La URL no es `http(s)`, lleva credenciales embebidas, o resuelve a una dirección privada, loopback, o link-local (protección SSRF). |
| `400 Bad Request` | `auth` inválido (falta `username`/`password`), `cookies` (no es un array, supera 50 entradas, faltan campos), o `headers` (no es un objeto, supera 20 entradas, nombre bloqueado). |
| `401 Unauthorized` | Falta la clave de API / token JWT, o es inválida. |
| `402 Payment Required` | Perceive no está en tu plan actual, o tu cuota mensual de ops está agotada. |
| `403 Forbidden` | `/v2/perceive` no está en los endpoints permitidos de la clave de API. |
| `403 Forbidden` | Los lotes no están disponibles en tu plan, o el tamaño del lote supera el límite de tu plan. |
| `403 Forbidden` | `respect_robots=true` y el `robots.txt` del sitio no permite la URL. |
| `404 Not Found` | `operation_id` o `job_id` desconocido, o perteneciente a otro proyecto. |
| `422 Unprocessable Entity` | Falló la validación de la solicitud (enum inválido en `outputs`/`extract`, `wait_timeout_ms` fuera de rango, viewport fuera de límites, una clave de solicitud desconocida). |
| `422 Unprocessable Entity` | Se envió `proxy_url`, `geolocation`, o `action_chain`. Los tres están reservados para una versión posterior. |
| `500 Internal Server Error` | Falló el render. El mensaje incluye el `operation_id` para citar a soporte. |
| `502 Bad Gateway` | Todos los motores fueron bloqueados y el origen sirvió un desafío anti-bot sin contenido de página detrás. Reinténtalo más tarde, o envía `allow_degraded: true` para recibir la página del desafío tal cual. |

Las claves de solicitud desconocidas se rechazan con un `422` que nombra
el campo, en `/v2/perceive`, `/v2/perceive/batch`, `/v2/discover` y
`/v2/lookup` por igual. Nunca se ignoran silenciosamente. Cada cuerpo
`422` incluye un array `errors` de nivel superior con mensajes legibles
por humanos, junto a la lista `detail` en bruto.

La referencia completa de códigos de estado está en [la guía de códigos
de error](/es/docs/reference/errors.md).

---

## Límites

| Límite | Valor |
|-------|-------|
| Longitud de URL | 2,048 caracteres |
| `wait_timeout_ms` | 0–60,000 ms |
| Longitud de `js_code` | 20,000 caracteres |
| Ancho de viewport | 320–3,840 px |
| Alto de viewport | 240–2,160 px |
| Cookies por solicitud | 50 |
| Encabezados personalizados por solicitud | 20 |
| Extract `main_content` | 50,000 caracteres |
| URLs de lote por solicitud | 1,000 (límite del schema) |
| Umbral de lote inline | 10 URLs (los lotes más grandes se ejecutan de forma asíncrona) |
| TTL de la caché de resultados | 1 hora |
| Expiración de URL firmada | 15 minutos |
| Ops mensuales (compartidas entre todos los endpoints) | 500 / 3.000 / 15.000 / 50.000 según el nivel; consulta [precios](/es/pricing.md) |

---

## Preguntas frecuentes

### ¿Cómo convierto una página web a Markdown con una API REST?

Envía `POST /v2/perceive` con `{"url": "...", "outputs": ["markdown"]}`. La página se renderiza en Chrome headless y la respuesta lleva una URL de descarga pre-firmada para el archivo Markdown. Por defecto, `only_main_content` elimina el chrome del sitio para que recibas el artículo, no la navegación; define `"only_main_content": false` para la página completa, o añade `"direct_download": true` para recibir los bytes del Markdown directamente en el cuerpo de la respuesta.

### ¿Puedo obtener una captura de pantalla y Markdown del mismo render?

Sí. `outputs` acepta cualquier combinación, así que `["markdown", "screenshot"]` (o `screenshot_full_page` para todo el alto del scroll) produce ambos a partir de un único render de navegador. Nunca pagas por la misma página dos veces en una llamada.

### ¿/v2/perceive renderiza páginas JavaScript?

Sí. Cada solicitud ejecuta un render real en Chrome headless: se descartan los banners de cookies, se hace scroll en la página para disparar el contenido de carga diferida, y puedes controlar la página antes de la captura con `wait_for` (un selector CSS o una expresión JS), `js_code`, y `block_resources`.

### ¿Por qué dejó de funcionar mi URL de descarga firmada?

Las URLs firmadas expiran a los 15 minutos (`expires_in: 900`). Vuelve a obtener la operación con `GET /v2/perceive/{operation_id}` para conseguir URLs recién firmadas. No se produce ningún re-render y no se consumen ops.

### ¿Un resultado en caché sigue contando contra mi cuota?

Sí. Un acierto de caché factura una op, porque la cuota mensual mide operaciones, no renders de navegador. Define `cache_mode` como `bypass` para omitir la caché de 1 hora, o `refresh` para renderizar de nuevo y reemplazar la entrada en caché.
