Les robots d’indexation basés sur l’IA vont venir explorer vos pages, que vous soyez prêt ou non. Aujourd’hui, ils traitent le code HTML comme n’importe quel navigateur, et ils consacrent des ressources processeur à supprimer votre barre de navigation et votre pied de page pour trouver l’article qui se trouve en dessous. Cet article présente un petit service Fastly Compute qui trouve un juste milieu : les requêtes normales continuent d’accéder à votre site, tandis que les agents reçoivent une version Markdown épurée du même contenu.
Nous pouvons facilement y parvenir avec environ 200 lignes de JavaScript, que vous trouverez dans le référentiel ici. Vous pouvez parcourir la section consacrée au pipeline pour vous faire une idée, ou bien cloner et déployer le code si vous souhaitez y arriver plus rapidement.
Pourquoi c’est important
Notre propre rapport de recherche sur la sécurité a révélé que les robots sont à l’origine de 49 % des requêtes. La grande majorité de ce trafic est indésirable, et l’IA vérifiée ne représente qu’une infime partie de ce qui reste, mais cette infime partie a un impact commercial disproportionné. Une seule requête provenant de GPTBot, PerplexityBot ou ChatGPT-User ne correspond pas à un seul utilisateur. Cela signifie que chaque utilisateur réel finira par consulter votre contenu via un grand modèle linguistique plutôt que directement sur votre site. Il vaut la peine de consacrer un peu d’efforts techniques pour offrir une expérience optimale.
Le problème, quand on fournit du code HTML à ces robots d’indexation, c’est qu’ils n’en veulent pas. Les pipelines d’entraînement des grands modèles de langage (LLM) et les systèmes de recherche fonctionnent avec du texte. Ainsi, lorsqu’un robot d’indexation récupère la documentation de vos produits et doit la transformer en réponses, le code HTML représente pour lui une charge inutile. Il doit être analysé, épuré des éléments standardisés, débarrassé des pixels de suivi et des éléments d’interface, puis converti en texte brut. Une partie de ce nettoyage entraîne des pertes, en particulier pour les tableaux, les blocs de code et les notes de bas de page, qui apparaissent souvent déformés dans les résumés en aval.
Markdown contourne la plupart de ces problèmes, car c’est le langage que ces pipelines existants prennent déjà en charge nativement. De plus, il est compact : un article type ne représente plus que 20 à 30 % de la taille de son équivalent HTML, ce qui signifie une consommation de bande passante réduite et moins de ressources consacrées à la structure au détriment de vos idées.
Le problème, c’est que tout réécrire pour prendre en charge Markdown dès la source n’est pas une solution réaliste pour la plupart des équipes, et ce n’est d’ailleurs pas ce que vous souhaitez. Les navigateurs ont toujours besoin du HTML. Ce qu’il vous faut, c’est une transformation qui s’applique au chemin de requête, qui ne ralentisse pas le système et qui se mette en cache efficacement, afin de ne pas avoir à effectuer deux fois le même travail.
Ce que nous construisons
Un petit service JavaScript hébergé sur Fastly Compute, placé en amont de votre serveur d’origine, qui effectue trois opérations différentes selon l’utilisateur qui en fait la requête :
Une requête normale effectuée via un navigateur récupère le code HTML, qui est transmis tel quel par le serveur d’origine.
Un agent utilisateur de robot d’indexation basé sur l’IA (nous en détectons 17 par défaut) ou une requête avec l’en-tête
« Accept: text/markdown »reçoit une version Markdown de la même page.Une requête explicite
/md/<path>renvoie toujours Markdown. Utile pour le débogage, les outils internes et les équipes de contenu qui souhaitent vérifier ponctuellement ce que voient les robots d'indexation.
Voici à quoi ressemble le résultat d’une requête vers /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 | Des titres épurés, un véritable tableau Markdown, un frontmatter YAML qu’un pipeline en aval peut analyser sans recourir à des méthodes heuristiques. La barre de navigation, le pied de page, les articles connexes, les invites à l’inscription à la newsletter et les scripts intégrés sont tous supprimés.
La pile
Quatre éléments suffisent pour tout faire :
Fastly Compute exécute l'ensemble en WebAssembly, à proximité de l'utilisateur. Nous utilisons le SDK JavaScript (
@fastly/js-compute).linkedom analyse le HTML d’origine en un DOM. Il s’agit d’une implémentation légère, conforme aux normes, qui se compile facilement en WASM, contrairement à jsdom, qui intègre de nombreux mécanismes spécifiques à Node.
Defuddle extrait le contenu principal. Il s’agit d’un nouvel extracteur développé par l’équipe d’Obsidian Web Clipper, spécialement conçu pour le Markdown destiné aux agents. Il gère les particularités propres à chaque site (des extracteurs spécifiques à chaque site pour les publications connues), normalise les blocs de code et les notes de bas de page en HTML cohérent, et recourt à un système de notation heuristique lorsque cela s’avère nécessaire.
Turndown parcourt le DOM extrait et génère du Markdown. Nous ajoutons le plugin GFM pour les tableaux et le texte barré, ainsi qu’une petite règle personnalisée pour gérer une particularité de Linkedom (plus d’informations à ce sujet ci-dessous).
De plus, fastly:cache propose SimpleCache pour la mise en cache en périphérie, sans aucune autre dépendance.
Le pipeline de conversion
Tout ce qui transforme HTML en Markdown se trouve dans un seul fichier, 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`;
} Le processus est linéaire : analysez avec linkedom, transmettez le Document à Defuddle, laissez Defuddle procéder à l’extraction et à la normalisation, puis réanalysez une nouvelle fois le code HTML généré à l’aide de linkedom afin que Turndown dispose d’un véritable nœud DOM à parcourir. Cette deuxième analyse peut sembler superflue, mais elle est importante et nous verrons pourquoi dans un instant.
L’assistant buildFrontmatter extrait le titre, la description, l’auteur et la date de publication des métadonnées de Defuddle, et utilise les balises <meta> standard à défaut si Defuddle ne les fournit pas. Nous générons également l’URL canonique, afin que tout élément utilisant ce Markdown puisse renvoyer vers la page d’origine.
Le piège du nœud DOM qui n’est pas une chaîne
Si vous consultez la documentation de Defuddle, vous remarquerez une option markdown: true qui semble pouvoir faire tout ce que Turndown fait pour nous. C’est le cas dans Node, mais pas dans Compute.
La raison : l’étape Markdown intégrée à Defuddle appelle turndownService.turndown(htmlString). Turndown, lorsqu’on lui fournit une chaîne de caractères, l’analyse en interne en appelant document.implementation.createHTMLDocument. La durée d’exécution de Compute JS est SpiderMonkey, avec linkedom fournissant le DOM, et linkedom n’expose pas document.implementation. Turndown lève une exception, Defuddle l’intercepte, et vous obtenez un message d’erreur tel que « Conversion partielle terminée avec des erreurs », suivi du code HTML brut.
En transmettant un nœud DOM à Turndown, on contourne complètement cet analyseur. Il parcourt la structure que nous lui fournissons. C’est pour cette raison que le deuxième appel à parseHTML est nécessaire.
La règle du tableau
Encore une petite particularité de linkedom : la propriété HTMLTableElement.rows n’est pas renseigné. La règle de traitement des tableaux du plugin GFM vérifie la valeur de node.rows[0] pour déterminer s’il faut convertir la table ou l’ignorer, et comme rows n’est pas défini, tous les tableaux sont convertis en texte brut.
La solution consiste à enregistrer une petite règle personnalisée après 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’) fonctionne là où .rows ne fonctionne pas. Comme notre règle personnalisée est enregistrée en dernier, Turndown la privilégie par rapport à la règle par défaut de GFM. Quelques lignes supplémentaires suffisent pour enregistrer n’importe quelle page contenant un tableau.
Routage et négociation de contenu
Le gestionnaire de récupération Compute se trouve dans src/index.js. a couche de routage complète compte environ 50 lignes :
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' });
} Quatre étapes à suivre. Les chemins de santé et de débogage sont fournis localement. Le préfixe /md/<path> impose le format Markdown, indépendamment des en-têtes. Ensuite, nous examinons la requête : si elle provient d’un robot d’indexation IA connu ou si elle demande explicitement le format Markdown, nous procédons à la conversion. Sinon, la requête est transmise directement à l’origine.
La détection du robot d’indexation est une petite liste dans src/agents.js, qui comprend 17 modèles d’agent utilisateur couvrant les plus courants : GPTBot, ChatGPT-User, ClaudeBot, anthropic-ai, PerplexityBot, GoogleOther, cohere-ai, etc. Il s’agit d’une correspondance de sous-chaînes insensible à la casse. Les agents évoluent, veuillez donc considérer cette liste comme un point de départ et la réduire ou l’étendre en fonction de ce qui apparaît réellement dans vos journaux.
Mise en cache
La conversion Markdown prend quelques centaines de millisecondes lors d’une première requête, principalement en raison du scoring par Defuddle. Cela ne pose pas de problème pour la première requête du robot d’indexation, mais cela devient pénible à la centième. SimpleCache réduit ce processus à une seule ligne de code :
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
} Cinq minutes constituent une valeur par défaut raisonnable pour la plupart des sites de contenu ; il suffit de l’adapter en fonction de la fréquence de vos publications. Le cache étant géré par point de présence (POP), vous obtiendrez une première réponse non mise en cache par région lors de la première requête, puis des réponses mises en cache par la suite.
Nous définissons également les en-têtes Vary: Accept, User-Agent dans la réponse. Tous les caches en aval (le vôtre, celui du robot d’indexation) respecteront le même processus de négociation de contenu que nous.
Tester localement
Le convertisseur est une fonction pure : il prend du code HTML en entrée et génère du Markdown en sortie. Il est donc très facile de le tester avec Node.js standard, sans nécessiter de durée d’exécution 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*\|/);
}); Intégrez une poignée de fichiers de test représentatifs dans test/fixtures/ (un article de blog, une page de documentation contenant des tableaux, un article d’actualité avec du contenu standard), puis vérifiez les propriétés qui vous intéressent. Notre référentiel associé en propose trois. npm test s’exécute en environ 200 ms, ce qui signifie que vous pouvez itérer sur les anomalies d’extraction sans avoir à recompiler le WASM.
Pour le pipeline complet en périphérie, fastly compute serve exécute Viceroy (l’émulateur Compute local de Fastly) sur 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 Dans le fichier fastly.toml, attribuez à [local_server.backends.origin] l’origine que vous souhaitez mettre en proxy, et vous obtenez une boucle de bout en bout opérationnelle.
Déploiement
Les deux mêmes commandes que pour n’importe quel autre service Compute :
npm run build # compile to bin/main.wasm
fastly compute deploy Lors de la première exécution, le système vous invite à créer un service et à configurer votre backend d’origine en production. Une fois cette étape franchie, vous disposez d’un endpoint Compute accessible à <service>.edgecompute.app. Vous pouvez y rediriger un domaine personnalisé ou le placer derrière votre service Fastly existant en tant que configuration de protection, selon ce qui convient le mieux à votre topologie.
Ce qui se passe réellement sur le réseau
Pour une requête de GPTBot à /blog/my-article :
Compute reçoit la requête. L’agent utilisateur correspond à
GPTBot→ rediriger vers le chemin de conversion.Vérification de SimpleCache pour
html-2-md:/blog/my-post. Pas de résultat.Récupération du code HTML depuis l’origine (le
back-endd’origine déclaré dansfastly.toml).Analyse avec Linkedom → exécution de Defuddle → nouvelle analyse → Turndown → frontmatter.
Stockage dans SimpleCache avec un temps de vie (TTL) de 5 minutes. Renvoi.
Réponse :
Content-Type: text/markdown;charset=utf-8,Vary: Accept,User-Agent,X-Markdown-Tokens: <estimate>.
Pour un navigateur classique accédant à la même URL au même moment, l’étape 2 est entièrement ignorée. Il récupère le code HTML directement auprès du serveur d’origine, comme d’habitude.
Et maintenant ?
Voici quelques pistes à explorer une fois que le système est opérationnel :
Comptage des jetons : notre heuristique (longueur / 4) constitue une approximation grossière de la tokenisation de type GPT. Si vous souhaitez obtenir un décompte précis, remplacez-la par un véritable tokeniseur. Il existe des versions de tiktoken compatibles WASM qui fonctionnent dans Compute.
Réécriture des liens : le résultat actuel conserve les URL relatives de la source, ce qui signifie qu’un robot d’indexation doit les résoudre par rapport à l’URL de la requête. Vous pouvez réécrire les liens relatifs en liens absolus dans le résultat de Defuddle avant que Turndown ne l’exécute.
Extracteurs par site : Defuddle prend en charge les extracteurs personnalisés pour les sites présentant une structure atypique. Si vous utilisez un proxy pour une publication ou un site de documentation spécifique, la création d’un extracteur sur mesure permet d’obtenir un résultat bien plus précis que les heuristiques génériques.
Streaming : pour les articles très longs, l’implémentation actuelle met en mémoire tampon l’intégralité du corps avant d’envoyer la réponse. Le streaming de la conversion permettrait de réduire le temps de chargement du premier octet (TTFB). C’est plus complexe (Defuddle a besoin du document complet pour l’évaluation), mais cela reste faisable en segmentant le document aux limites des sections.
Limitation du débit par agent : si vous souhaitez prendre en charge GPTBot tout en limitant le débit d’un bot plus actif, associez ce service à notre offre Edge Rate Limiting.
Pour conclure
Fournir du contenu au format Markdown aux agents IA fait partie de ces petites initiatives qui peuvent avoir un impact considérable. Cela permet d’alléger la charge de travail des agents, mais aussi de préserver votre bande passante (et, au final, vos résultats financiers). Compute est particulièrement adapté à cette tâche, car le traitement est effectué à proximité de la requête, peut être mis en cache et ne prend que quelques millisecondes. Ce qu’il vous faut, c’est une transformation qui s’applique au chemin de requête, qui ne ralentisse pas le système et qui se mette en cache efficacement, afin de ne pas avoir à effectuer deux fois le même travail.
N’hésitez pas à cloner le service ici. Si vous développez une fonctionnalité intéressante à partir de cette base (un compteur de jetons, un extracteur personnalisé, un outil de réécriture de liens), n’hésitez pas à nous en faire part.

