La plataforma de edge cloud de Fastly

Volver al blog

Síguenos y suscríbete

Sólo disponible en inglés

Por el momento, esta página solo está disponible en inglés. Lamentamos las molestias. Vuelva a visitar esta página más tarde.

Ofrece a los agentes de IA el Markdown que realmente quieren

Jonathan Speek

Manager, Gestión de Productos de Plataforma, Fastly

Los rastreadores de IA te van a pedir tus páginas, estés preparado para ello o no. Hoy en día obtienen HTML, igual que cualquier navegador, y gastan recursos de CPU eliminando la navegación y el pie de página para encontrar el artículo que hay debajo. Este artículo te guía a través de un pequeño servicio de Fastly Compute que ofrece una solución intermedia: las peticiones normales siguen recibiendo tu sitio, mientras que los agentes obtienen una versión limpia en Markdown del mismo contenido.

Podemos lograrlo fácilmente con unas 200 líneas de JavaScript, que puedes encontrar en este repositorio. Puedes echar un vistazo a la sección de la canalización para ver cómo funciona, o clonarla y desplegarla si quieres llegar más rápido.

Por qué esto importa

Según nuestra propia investigación de seguridad, los bots representan el 49 % de las peticiones. La gran mayoría es tráfico no deseado, y la IA verificada es solo una pequeña parte de lo que queda, pero esa pequeña parte tiene un impacto comercial desmesurado. Una sola visita de GPTBot, PerplexityBot o ChatGPT-User no es un usuario. Son todos los usuarios reales que, al final, verán tu contenido a través de un modelo de lenguaje de gran tamaño (LLM) en lugar de en tu sitio web. Vale la pena dedicar un poco de ingeniería a conseguir que esa experiencia sea la adecuada.

El problema de entregar HTML a esos rastreadores es que no lo quieren. Las canalizaciones de entrenamiento de los LLM y los sistemas de recuperación funcionan con texto. Así que cuando un rastreador extrae la documentación de tu producto y necesita convertirla en respuestas, el HTML les genera gastos generales. Hay que analizarlo, eliminar las plantillas repetitivas, depurarlo de píxeles de seguimiento y elementos de interfaz de menú, y convertirlo en texto plano. Parte de esa limpieza conlleva pérdidas, especialmente en tablas, bloques de código y notas al pie, que a menudo aparecen distorsionados en los resúmenes posteriores.

Markdown evita la mayor parte de ese problema, ya que es el lenguaje que esas canalizaciones ya utilizan de forma nativa. Además, es compacto: un artículo típico se comprime hasta el 20-30 % de su tamaño en HTML, lo que significa menos ancho de banda y menos tokens consumidos en la estructura en lugar de en tus ideas.

El problema es que reescribirlo todo para ofrecer Markdown desde el origen no es realista para la mayoría de los equipos, y de todos modos no te interesa hacerlo. Los navegadores siguen necesitando el HTML. Lo ideal es una transformación que se ejecute en la ruta de la petición, no ralentice el proceso y se almacene bien en caché para que no tengas que pagar dos veces por el mismo trabajo.

Lo que estamos creando

Un pequeño servicio de JavaScript en Fastly Compute que se sitúa delante de tu origen y hace tres cosas según quién lo solicite:

  • Una petición normal del navegador recibe HTML, que se transmite desde el origen sin modificaciones.

  • Un agente de usuario de un rastreador de IA (detectamos 17 de ellos por defecto) o una petición con Accept: text/markdown recibe una versión en Markdown de la misma página.

  • Una petición explícita /md/<path> siempre devuelve Markdown. Útil para la depuración, herramientas internas y equipos de contenido que quieran comprobar al azar lo que ven los rastreadores.

Así es como se ve el resultado de una petición a /md/blog/rate-limits:

---
title: "Rate limits — API docs"
description: "How rate limits work, per-tier quotas, and the headers to inspect."
author: "Platform team"
date: "2026-03-02T00:00:00Z"
url: "https://example.com/docs/rate-limits"
source: "https://your-site.edgecompute.app/md/blog/rate-limits"
---

# Rate limits

Every API key is subject to a request budget per minute and per day...

## Quotas by tier

| Tier | Requests / min | Requests / day |
| --- | --- | --- |
| Free | 60 | 10,000 |
| Pro | 600 | 500,000 |
| Enterprise | Custom | Custom |

Encabezados limpios, una tabla Markdown auténtica, un frontmatter YAML que una canalización posterior puede analizar sin heurísticas. La navegación, el pie de página, los artículos relacionados, los formularios de suscripción al boletín y los scripts en línea se eliminan por completo.

El stack

Cuatro piezas hacen todo el trabajo:

  • Fastly Compute ejecuta todo como WebAssembly, cerca del usuario. Utilizamos el SDK de JavaScript (@fastly/js-compute).

  • linkedom analiza el HTML edge y lo convierte en un DOM. Es una implementación ligera y cercana a los estándares que se compila limpiamente a WASM, a diferencia de jsdom, que incorpora mucha maquinaria específica de Node.

  • Defuddle extrae el contenido principal. Es un extractor más reciente del equipo de Obsidian Web Clipper, creado específicamente para Markdown orientado a agentes. Maneja peculiaridades específicas de cada sitio (extractores por sitio para publicaciones conocidas), estandariza bloques de código y notas al pie en HTML coherente, y recurre a la puntuación heurística cuando es necesario.

  • Turndown recorre el DOM extraído y genera Markdown. Añadimos el complemento GFM para tablas y tachado, además de una pequeña regla personalizada para gestionar una peculiaridad de linkedom (de la que hablaremos más adelante).

Además de SimpleCache de fastly:cache para el almacenamiento en caché en el edge, sin otras dependencias.

La canalización de conversión

Todo lo que convierte HTML en Markdown vive en un solo archivo, src/converter.js:

import Defuddle from 'defuddle';
import { parseHTML } from 'linkedom';
import TurndownService from 'turndown';
import { gfm } from '@joplin/turndown-plugin-gfm';

const turndown = new TurndownService({
  headingStyle: 'atx',
  codeBlockStyle: 'fenced',
  bulletListMarker: '-',
});
turndown.use(gfm);

export function htmlToMarkdown(html, sourceUrl) {
  const { document } = parseHTML(html);

  const result = new Defuddle(document, { url: sourceUrl }).parse();
  const articleDoc = parseHTML(result?.content || '').document;
  const markdown = turndown.turndown(articleDoc.documentElement).trim();

  if (!markdown) {
    throw new Error('Could not extract readable content from page');
  }

  const frontmatter = buildFrontmatter(result, document, sourceUrl);
  return `${frontmatter}\n\n${markdown}\n`;
}

La canalización es lineal: se analiza con linkedom, se pasa el documento a Defuddle, se deja que Defuddle haga su extracción y estandarización, y luego se vuelve a analizar su salida HTML con linkedom una vez más para que Turndown tenga un nodo DOM real que recorrer. Ese segundo análisis parece redundante, pero es importante y enseguida veremos por qué.

El helper buildFrontmatter extrae el título, la descripción, el autor y la fecha de publicación de los metadatos de Defuddle, recurriendo a las etiquetas estándar <meta> cuando Defuddle no las tiene. También generamos la URL canónica, para que cualquier cosa que consuma este Markdown pueda remitir a la página original.

La trampa del «nodo DOM, no cadena»

Si lees la documentación de Defuddle, verás una opción markdown: true que parece que debería hacer todo lo que Turndown hace por nosotros. Lo hace en Node, pero no en Compute.

La razón: el paso de Markdown integrado de Defuddle llama a turndownService.turndown(htmlString). Turndown, cuando recibe una cadena, la analiza internamente llamando a document.implementation.createHTMLDocument. El tiempo de ejecución de Compute JS es SpiderMonkey con linkedom proporcionando el DOM, y linkedom no expone document.implementation. Turndown lanza un error, Defuddle lo absorbe, y te aparece un mensaje de error como «Conversión parcial completada con errores» con el HTML sin procesar adjunto.

Pasarle a Turndown un nodo DOM evita por completo ese analizador. Recorre el árbol que le damos. Por eso está ahí la segunda llamada a parseHTML.

La regla de la tabla

Otra peculiaridad de linkedom: HTMLTableElement.rows no se rellena. La regla de tabla del complemento GFM comprueba node.rows[0] para decidir si convertir la tabla u omitirla, y como rows no está definido, todas las tablas se convierten en texto plano.

La solución es una pequeña regla personalizada registrada después de GFM:

turndown.addRule('linkedom-table', {
  filter: (node) => node.nodeName === 'TABLE',
  replacement: (_content, node) => {
    const rows = Array.from(node.querySelectorAll('tr'));
    if (!rows.length) return '';
    const cells = (tr) =>
      Array.from(tr.querySelectorAll('th, td')).map((c) =>
        c.textContent.replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'),
      );
    const header = cells(rows[0]);
    const body = rows.slice(1).map(cells);
    const sep = header.map(() => '---');
    const fmt = (row) => `| ${row.join(' | ')} |`;
    return `\n\n${[fmt(header), fmt(sep), ...body.map(fmt)].join('\n')}\n\n`;
  },
});

querySelectorAll('tr') funciona donde .rows no lo hace. Como nuestra regla personalizada se registra en último lugar, Turndown la elige en lugar de la predeterminada de GFM. Unas pocas líneas adicionales que salvan cualquier página con una tabla.

Enrutamiento y negociación de contenido

El handler de fetch de Compute está en src/index.js. Toda la capa de enrutamiento tiene unas 50 líneas:

async function handleRequest(event) {
  const req = event.request;
  const url = new URL(req.url);

  if (url.pathname === '/health') return jsonResponse({ status: 'ok' });
  if (url.pathname === '/__html-2-md__') return landingResponse();

  if (url.pathname.startsWith('/md/') || url.pathname === '/md') {
    const originPath = url.pathname.replace(/^\/md/, '') || '/';
    return await convertAndRespond(req, url, originPath);
  }

  const ua = req.headers.get('User-Agent') || '';
  const accept = req.headers.get('Accept') || '';

  if (isAiCrawler(ua) || wantsMarkdown(accept)) {
    return await convertAndRespond(req, url, url.pathname);
  }

  return fetch(req, { backend: 'origin' });
}

Cuatro puntos de decisión, en orden. Las rutas de estado y depuración se sirven localmente. Un prefijo /md/<path> fuerza el uso de Markdown independientemente de los encabezados. Después de eso, se analiza la petición: si viene de un rastreador de IA conocido o pide explícitamente Markdown, la convertimos. De lo contrario, se pasa directamente al origen.

La detección de rastreadores es una pequeña lista en src/agents.js, con 17 patrones de user-agent que cubren los más habituales: GPTBot, ChatGPT-User, ClaudeBot, anthropic-ai, PerplexityBot, GoogleOther, cohere-ai, etc. Se trata de una coincidencia de subcadenas que no distingue entre mayúsculas y minúsculas. Los agentes evolucionan, así que considera la lista como un punto de partida y recórtala o amplíala según lo que realmente aparezca en tus registros.

Almacenamiento en caché

La conversión a Markdown tarda unos cientos de milisegundos en una petición en frío, la mayor parte del tiempo en la puntuación de Defuddle. Eso está bien para la primera visita del rastreador, pero resultado pesado para la centésima. SimpleCache lo convierte en una sola línea:

const cacheKey = `html-2-md:${originUrl.pathname}${originUrl.search}`;
const cached = SimpleCache.get(cacheKey);

if (cached) {
  body = await cached.text();
} else {
  body = await fetchAndConvert(originUrl, url);
  SimpleCache.set(cacheKey, body, CACHE_TTL); // 5 minutes
}

Cinco minutos es un valor predeterminado razonable para la mayoría de los sitios de contenido, solo tienes que ajustarlo a la frecuencia con la que publicas. La caché es por POP, así que verás una conversión en frío por región en la primera petición, y después respuestas en caché.

También configuramos Vary: Accept, User-Agent en la respuesta. Cualquier caché posterior (la tuya, la del rastreador) respetará la misma negociación de contenido que nosotros.

Probar localmente

El conversor es una función pura: entra HTML y sale Markdown. Eso hace que sea muy fácil probarlo con Node sin más, sin necesidad de un entorno de ejecución de Compute:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { htmlToMarkdown } from '../src/converter.js';

test('docs page: preserves tables and nested lists', async () => {
  const html = await readFile('test/fixtures/docs-page.html', 'utf8');
  const md = htmlToMarkdown(html, 'https://example.com/docs/rate-limits');

  assert.match(md, /# Rate limits/);
  assert.match(md, /\|\s*Tier\s*\|/);  // markdown table header
  assert.match(md, /\|\s*Free\s*\|\s*60\s*\|/);
});

Coloca unos cuantos ejemplos representativos en test/fixtures/ (un artículo de blog, una página de documentación con tablas, un artículo de noticias con texto estándar) y comprueba las propiedades que te interesen. Nuestro repositorio complementario incluye tres. npm test se ejecuta en unos 200 ms, lo que significa que puedes iterar sobre las peculiaridades de la extracción sin necesidad de recompilar WASM.

Para la canalización completa en el edge, fastly compute serve inicia Viceroy (el emulador local de Compute de Fastly) en 127.0.0.1:7676:

curl -s "http://127.0.0.1:7676/" -H "Accept: text/markdown" | head -30
curl -s "http://127.0.0.1:7676/" -H "User-Agent: GPTBot/1.0" | head -30
curl -s "http://127.0.0.1:7676/md/blog/my-post" | head -30
curl -sI "http://127.0.0.1:7676/"   # confirm HTML pass-through

Apunta [local_server.backends.origin] en fastly.toml al origen del que quieras hacer proxy, y tendrás un bucle de principio a fin en funcionamiento.

Desplegando

Los mismos dos comandos que con cualquier otro servicio de Compute:

npm run build        # compile to bin/main.wasm
fastly compute deploy

La primera ejecución te pedirá que crees un servicio y configures tu backend de origen de producción. Después de eso, tendrás un punto de conexión de Compute que responderá en <service>.edgecompute.app. Apunta un dominio personalizado a él, o ponlo delante de tu servicio Fastly existente como configuración de protección, lo que mejor se adapte a tu topología.

¿Qué ocurre realmente en la red?

Para una petición de GPTBot a /blog/my-post:

  1. Compute recibe la petición. User-Agent coincide con GPTBot → se redirige a la ruta de conversión.

  2. Se comprueba en SimpleCache si hay una entrada para html-2-md:/blog/my-post. Miss.

  3. Se recupera el HTML de origen (el backend de origen declarado en fastly.toml).

  4. Se analiza con linkedom → se ejecuta Defuddle → se vuelve a analizar → Turndown → frontmatter.

  5. Se almacena en SimpleCache con un TTL de 5 minutos. Se devuelve.

  6. Respuesta: Content-Type: text/markdown; charset=utf-8, Vary: Accept, User-Agent, X-Markdown-Tokens: <estimate>.

Para un navegador normal que acceda a la misma URL al mismo tiempo, el paso 2 se omite por completo. Recibe el HTML directamente del origen, como siempre.

Qué hacer a partir de aquí

Algunas ideas que vale la pena considerar una vez que se esté ejecutando:

Recuento de tokens: nuestra heurística (length / 4) es una estimación aproximada de la tokenización al estilo GPT. Si te importa la precisión, cámbiala por un tokenizador real. Hay versiones de tiktoken compatibles con WASM que funcionan en Compute.

Reescritura de enlaces: la salida actual conserva las URL relativas de origen, lo que significa que un rastreador tiene que resolverlas en función de la URL de la petición. Puedes reescribir los enlaces relativos a absolutos dentro del resultado de Defuddle antes de que Turndown lo ejecute.

Extractores por sitio: Defuddle admite extractores personalizados para sitios con una estructura inusual. Si estás haciendo proxy para una publicación específica o un sitio de documentación, escribir un extractor específico produce una salida mucho más limpia que las heurísticas genéricas.

Streaming: para artículos muy largos, la implementación actual almacena en búfer todo el cuerpo antes de emitir la respuesta. El streaming de la conversión reduciría el TTFB. Es más complejo (Defuddle necesita el documento completo para puntuar), pero factible fragmentándolo por límites de sección.

Limitación de volumen por agente: si quieres dar servicio a GPTBot pero limitar un bot más ruidoso, combina este servicio con nuestra oferta de productos de limitación de volumen en el edge.

Conclusión

Entregar Markdown a los agentes de IA es uno de esos pequeños esfuerzos que pueden tener un impacto enorme. Respeta la carga de trabajo del agente, pero también tu ancho de banda (y, en última instancia, tu resultado final). Compute es una buena opción para esto porque el trabajo está cerca de la petición, es almacenable en caché y se mide en milisegundos. Lo ideal es una transformación que se ejecute en la ruta de la petición, no ralentice el proceso y se almacene bien en caché para que no tengas que pagar dos veces por el mismo trabajo.

No dudes en clonar el servicio aquí. Si creas algo interesante a partir de esto (un contador de tokens, un extractor personalizado, un reescritor de enlaces), nos encantaría que nos lo contaras.

¿Listo para empezar?

Ponte en contacto con nosotros