I crawler IA chiederanno le tue pagine, che tu sia pronto o no. Oggi ricevono HTML, come qualsiasi browser, e spendono cicli di CPU a eliminare la navigazione e il piè di pagina per trovare l'articolo sottostante. Questo post illustra un piccolo servizio Fastly Compute che trova un punto d'incontro: le richieste normali continuano a ricevere il tuo sito, mentre gli agenti ricevono una versione Markdown pulita degli stessi contenuti.
Possiamo farlo facilmente con circa 200 righe di JavaScript, che puoi trovare nel repo qui. Puoi scorrere rapidamente la sezione della pipeline per vederne la struttura, oppure clonare e distribuire se vuoi arrivarci più velocemente.
Perché è importante
Il nostro report di ricerca sulla sicurezza ha rilevato che i bot rappresentano il 49% delle richiesta. La stragrande maggioranza è traffico indesiderato e l'IA verificata è solo una piccola parte di ciò che resta, ma quella piccola parte ha un impatto aziendale sproporzionato. Un singolo hit da GPTBot, PerplexityBot o ChatGPT-User non è un utente. Sono tutti gli utenti reali che alla fine vedranno i tuoi contenuti attraverso un modello linguistico di grandi dimensioni invece che sul tuo sito. Fare bene questa esperienza vale un po' di lavoro di engineering.
Il problema di servire HTML a quei crawler: non lo vogliono. Le pipeline di addestramento LLM e i sistemi di retrieval operano sul testo. Quindi, quando un crawler estrae la documentazione del prodotto e deve trasformarla in risposte, l'HTML rappresenta costi generali per lui. Deve essere analizzato, privato del boilerplate, ripulito dai pixel di tracciamento e dagli elementi del menu, e convertito in testo normale. Parte di questa pulizia comporta una perdita di informazioni, soprattutto per tabelle, blocchi di codice e note a piè di pagina, che spesso compaiono deformati nei riepiloghi a valle.
Markdown evita gran parte di tutto questo, è ciò che quelle pipeline esistenti già supportano in modo nativo. Ed è piccolo: un articolo tipico si comprime al 20-30% delle sue dimensioni HTML, il che significa meno larghezza di banda e meno token sprecati per la struttura invece che per le idee.
Il problema è che riscrivere tutto per servire Markdown all'origine non è realistico per la maggior parte dei team, e comunque non vorresti farlo. I browser hanno ancora bisogno dell'HTML. Quello che vuoi è una trasformazione che viene eseguita sul percorso della richiesta, non rallenta le prestazioni e usa bene la cache, così non paghi due volte per lo stesso lavoro.
Cosa stiamo creando
Un piccolo servizio JavaScript su Fastly Compute che si trova davanti alla tua origine e fa tre cose in base a chi lo richiede:
Una normale richiesta del browser riceve HTML, trasmesso dall'origine senza modifiche.
Un User-Agent di crawler IA (ne rileviamo 17 per impostazione predefinita) o una richiesta con
Accept: text/markdownriceve una versione Markdown della stessa pagina.Una richiesta
/md/<path>esplicita restituisce sempre Markdown. Utile per il debug, gli strumenti interni e i team dei contenuti che vogliono verificare rapidamente ciò che vedono i crawler.
Ecco come appare l’output per una richiesta 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 | Intestazioni pulite, una vera tabella Markdown, frontmatter YAML che una pipeline downstream può analizzare senza euristiche. La barra di navigazione, il piè di pagina, gli articoli correlati, i prompt di iscrizione alla newsletter e gli script inline vengono tutti rimossi.
Lo stack
Quattro elementi fanno tutto il lavoro:
Fastly Compute esegue l'intera operazione come WebAssembly, vicino all'utente. Utilizziamo l’SDK JavaScript (
@fastly/js-compute).linkedom analizza l’HTML di origine in un DOM. È un’implementazione leggera, adiacente agli Standard, che viene compilata in modo pulito in WASM, a differenza di jsdom, che include molti componenti specifici di Node.
Defuddle estrae il contenuto principale. È un estrattore più recente del team di Obsidian Web Clipper, creato appositamente per il Markdown destinato agli agenti. Gestisce le particolarità specifiche dei siti (estrattori per sito per pubblicazioni note), standardizza i blocchi di codice e le note a piè di pagina in HTML coerente e, quando necessario, ricorre a un punteggio euristico.
Turndown percorre il DOM estratto ed emette Markdown. Aggiungiamo il plug-in GFM per le tabelle e il barrato, oltre a una piccola regola personalizzata per gestire una particolarità di linkedom (più dettagli sotto).
In più fastly:cache SimpleCache per il caching edge, senza altre dipendenze.
La pipeline di conversione
Tutto ciò che trasforma HTML in Markdown si trova in un file, 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 pipeline è lineare: analizzare con linkedom, passare il Document a Defuddle, lasciare che Defuddle esegua l’estrazione e la standardizzazione, quindi analizzare di nuovo il suo output HTML tramite linkedom ancora una volta in modo che Turndown abbia un nodo DOM reale da percorrere. Quella seconda analisi sembra ridondante, ma è importante e tra un attimo vedremo perché.
L’helper buildFrontmatter recupera titolo, descrizione, autore e data di pubblicazione dai metadati di Defuddle, ricorrendo ai tag <meta> Standard quando Defuddle non li contiene. Emettiamo anche l'URL canonico, così qualsiasi elemento che utilizza questo Markdown può rimandare alla pagina originale.
L’insidia di DOM-node-not-stringa
Se leggi la documentazione di Defuddle, noterai un’opzione markdown: true che sembra fare tutto ciò che Turndown fa per noi. In Node sì, ma in Compute no.
Il motivo: il passaggio Markdown integrato di Defuddle chiama turndownService.turndown(htmlString). Turndown, data una stringa, la analizza internamente chiamando document.implementation.createHTMLDocument. Il runtime di Compute JS è SpiderMonkey con linkedom che fornisce il DOM, e linkedom non espone document.implementation. Turndown genera un’eccezione, Defuddle intercetta l’eccezione e viene visualizzato un messaggio di fallback come "conversione partial completata con errori" con l’HTML non elaborato aggiunto.
Passare a Turndown un nodo DOM evita completamente quel parser. Percorre l'albero che gli forniamo. Ecco perché c’è la seconda chiamata a parseHTML.
La regola della tabella
Un’altra stranezza di linkedom: HTMLTableElement.rows non viene popolato. La regola della tabella del plug-in GFM controlla node.rows[0] per decidere se convertire la tabella o ignorarla e, poiché rows non è definito, ogni tabella diventa testo appiattito.
La correzione è una piccola regola personalizzata registrata dopo 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') funziona dove .rows non. Poiché la nostra regola personalizzata viene registrata per ultima, Turndown la sceglie al posto di quella predefinita di GFM. Qualche riga in più che salva qualsiasi pagina con una tabella.
Instradamento e negoziazione del contenuto
Il gestore fetch di Compute si trova in src/index.js. L'intero livello di routing è di circa 50 righe:
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' });
} Quattro punti decisionali, in ordine. Le route di integrità e debug vengono servite localmente. Il prefisso /md/<path> forza Markdown indipendentemente dalle intestazioni. Dopo, esaminiamo la richiesta: se proviene da un crawler IA noto o richiede esplicitamente Markdown, convertiamo. Altrimenti, un passaggio diretto all’origine.
Il rilevamento del crawler è un piccolo elenco in src/agents.js, 17 pattern di User-Agent che coprono quelli più diffusi: GPTBot, ChatGPT-User, ClaudeBot, anthropic-ai, PerplexityBot, GoogleOther, cohere-ai e così via. Si tratta di una corrispondenza di sottostringa senza distinzione tra maiuscole e minuscole. Gli agenti si evolvono, quindi considera l'elenco come un punto di partenza e sfoltiscilo o amplialo in base a ciò che compare effettivamente nei tuoi Log.
Caching
La conversione Markdown richiede alcune centinaia di millisecondi in una richiesta a freddo, per la maggior parte nel punteggio di Defuddle. Va bene per il primo passaggio del crawler, ma è doloroso al centesimo. SimpleCache lo trasforma in una sola riga:
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
} Cinque minuti sono un valore predefinito ragionevole per la maggior parte dei siti di contenuti, basta adattarlo alla frequenza con cui pubblichi. La cache è per point of presence, quindi vedrai una conversione a freddo per regione alla prima richiesta, poi risposte dalla cache.
Abbiamo anche impostato Vary: Accept, User-Agent nella risposta. Qualsiasi cache downstream (la tua, quella del crawler) rispetterà la stessa negoziazione dei contenuti che rispettiamo noi.
Test in locale
Il convertitore è una funzione pura, HTML in ingresso, Markdown in uscita. Questo rende banale eseguire test con Node semplice, senza bisogno del runtime 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*\|/);
}); Inserisci una manciata di fixture rappresentative in test/fixtures/ (un post di blog, una pagina della documentazione con tabelle, un articolo di notizie con testo standard) e verifica le proprietà che ti interessano. Il nostro repository complementare ne include tre. npm test viene eseguito in circa 200 ms, il che significa che puoi iterare sulle particolarità dell’estrazione senza ricompilare WASM.
Per la pipeline edge "full", fastly compute serve avvia Viceroy (l'emulatore locale di Compute di Fastly) su 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 Indica [local_server.backends.origin] in fastly.toml con qualsiasi origine a cui vuoi fare da proxy, e avrai un loop end-to-end funzionante.
distribuire
Gli stessi due comandi di qualsiasi altro servizio Compute:
npm run build # compile to bin/main.wasm
fastly compute deploy Al primo avvio ti viene chiesto di creare un servizio e configurare il backend di origine di produzione. Dopo di che, avrai un endpoint Compute che risponderà all’indirizzo <service>.edgecompute.app. Indirizza un dominio personalizzato verso di esso oppure mettilo davanti al tuo servizio Fastly esistente come configurazione di schermatura, a seconda di ciò che si adatta alla tua topologia.
Cosa sta realmente accadendo sul filo
Per una richiesta da GPTBot a /blog/my-post:
Compute riceve la richiesta. User-Agent corrisponde a
GPTBot→ instrada al percorso di conversione.Controlla SimpleCache per
html-2-md:/blog/my-post. Mancato.Recupera l’HTML dall’origine (il backend
origindichiarato infastly.toml).Analizzare con linkedom → eseguire Defuddle → analizzare di nuovo → Turndown → frontmatter.
Archivia in SimpleCache con Time-To-Live di 5 minuti. Indietro.
risposta:
Content-Type: text/markdown;charset=utf-8,Vary: Accept,User-Agent,X-Markdown-Tokens: <estimate>.
Per un browser normale che raggiunge lo stesso URL nello stesso momento, il passaggio 2 viene saltato completamente. Ricevono HTML direttamente dall'origine, come sempre.
Da dove andare avanti
Alcune direzioni da considerare una volta che è in esecuzione:
Conteggio dei token: La nostra euristica (lunghezza / 4) è un'approssimazione approssimativa della tokenizzazione in stile GPT. Se ti interessa una contabilità accurata, usa un tokenizer reale. Esistono build di tiktoken compatibili con WASM che funzionano in Compute.
Riscrittura dei link: L'output corrente preserva gli URL relativi dall'origine, il che significa che un crawler deve risolverli rispetto all'URL della richiesta. Puoi riscrivere i link relativi come assoluti all'interno del risultato di Defuddle prima che Turndown lo elabori.
Estrattori per sito: Defuddle supporta estrattori personalizzati per siti con una struttura insolita. Se stai facendo da proxy per una pubblicazione specifica o un sito di documentazione, scrivere un estrattore ad hoc produce un output molto più pulito rispetto alle euristiche generiche.
streaming: Per articoli molto lunghi, l'implementazione attuale memorizza nel buffer l'intero corpo prima di emettere la risposta. Lo streaming della conversione ridurrebbe il Time to First Byte. È più complesso (Defuddle vuole il documento "full" per assegnare un punteggio) ma fattibile suddividendo in blocchi in corrispondenza dei confini di sezione.
Limitazione della velocità per agente: Se vuoi servire GPTBot ma limitare un bot più rumoroso, abbina questo servizio alla nostra offerta Edge Rate Limiting.
In conclusione
Fornire Markdown agli agenti di IA è uno di quei piccoli sforzi che possono avere un impatto sproporzionato. Rispetta il carico di lavoro dell’agente, ma anche la tua larghezza di banda (e, in definitiva, il tuo risultato finale). Compute è adatto a questo perché il lavoro è vicino alla richiesta, memorizzabile nella cache e misurato in millisecondi. Quello che vuoi è una trasformazione che venga eseguita sul percorso della richiesta, non rallenti le prestazioni e funzioni bene con la cache, così non paghi due volte lo stesso lavoro.
Puoi clonare il servizio qui. Se costruisci qualcosa di interessante basandoti su questo (un contatore di token, un estrattore personalizzato, un riscrittore di link), ci farebbe piacere saperlo.

