KI-Crawler werden Ihre Seiten anfordern, ob Sie darauf vorbereitet sind oder nicht. Heute erhalten sie HTML, genau wie jeder Browser, und sie verbrauchen CPU-Zyklen, um Ihre Navigation und Fußzeile auszublenden und so den Artikel darunter zu finden. In diesem Post wird ein kleiner Fastly Compute-Service vorgestellt, der einen Mittelweg bietet: Normale Anfragen erhalten immer noch Ihre Website, während Agenten eine saubere Markdown-Version desselben Inhalts erhalten.
Das können wir problemlos mit etwa 200 JavaScript-Zeilen erreichen, die Sie im Repository hier finden können. Sie können den Abschnitt zur Pipeline überfliegen, um sich einen Überblick zu verschaffen, oder die Pipeline klonen und bereitstellen, wenn Sie schneller das gewünschte Ergebnis erzielen möchten.
Warum das wichtig ist
Unsere eigene Sicherheitsstudie ergab, dass Bots 49 % der Anfragen ausmachen. Der überwiegende Teil ist unerwünschter Traffic, und verifizierte KI macht nur einen Bruchteil dessen aus, was übrig bleibt, aber dieser Bruchteil hat große Auswirkungen auf das Geschäft. Ein einzelner Treffer von GPTBot, PerplexityBot oder ChatGPT-User stellt nicht einen einzelnen Nutzer dar. Es sind alle echten Nutzer, die Ihre Inhalte letztendlich über ein Large Language Model statt auf Ihrer Website sehen werden. Um dieses Erlebnis optimal zu gestalten, lohnt sich ein kleiner technischer Aufwand.
Das Problem beim Ausliefern von HTML an diese Crawler: Sie wollen es nicht. LLM-Trainingspipelines und Abrufsysteme arbeiten mit Text. Wenn also ein Crawler Ihre Produktdokumentation abruft und sie in Antworten umwandeln muss, ist HTML für ihn ein Overhead. Der Text muss analysiert, von Standardtexten befreit, von Tracking-Pixeln und Menü-Elementen bereinigt und in reinen Text umgewandelt werden. Einige dieser Bereinigungen sind verlustbehaftet, vor allem Tabellen, Codeblöcke und Fußnoten, die in nachgelagerten Zusammenfassungen oft nicht richtig angezeigt werden.
Markdown umgeht das meiste davon, da es von diesen bestehenden Pipelines bereits nativ unterstützt wird. Und es ist klein: Ein typischer Artikel wird auf 20–30 % seiner HTML-Größe komprimiert, was weniger Bandbreite und weniger Tokens bedeutet, die für Ihre Struktur statt für Ihre Ideen verbraucht werden.
Der Haken ist, dass es für die meisten Teams nicht realistisch ist, alles umzuschreiben, um Markdown direkt am Ursprung bereitzustellen, und das ist für Sie auch gar nicht erstrebenswert. Browser benötigen weiterhin HTML. Was Sie wollen, ist eine Transformation, die auf dem Anfragepfad ausgeführt wird, die Dinge nicht verlangsamt und gut zwischengespeichert wird, sodass Sie nicht zweimal für die gleiche Arbeit bezahlen.
Was wir entwickeln
Wir entwickeln einen kleinen JavaScript-Service auf Fastly Compute, der vor Ihrem Ursprung sitzt und drei Dinge tut, je nachdem, wer fragt:
Eine normale Browseranfrage erhält HTML, das unverändert durch den Origin-Server geleitet wird.
Ein KI-Crawler-User-Agent (wir erkennen standardmäßig 17 davon) oder eine Anfrage mit
Accept: text/markdownerhält eine Markdown-Version derselben Seite.Eine explizite
/md/<path>-Anfrage gibt immer Markdown zurück. Nützlich für Debugging, interne Tools und Content-Teams, die überprüfen möchten, was Crawler sehen.
So sieht die Ausgabe für eine Anfrage an /md/blog/rate-limits aus:
---
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 | Saubere Überschriften, eine echte Markdown-Tabelle, YAML-Frontmatter, die eine nachgelagerte Pipeline ohne Heuristiken parsen kann. Navigationsleiste, Fußzeile, verwandte Artikel, Newsletter-Hinweise und Inline-Skripte wurden komplett entfernt.
Der Stack
Vier Teile erledigen die ganze Arbeit:
Fastly Compute führt das Ganze als WebAssembly aus, also nah am Nutzer. Wir verwenden das JavaScript SDK (
@fastly/js-compute).Linkedom parst den Quell-HTML-Code in ein DOM. Dabei handelt es sich um eine leichtgewichtige, standardnahe Implementierung, die sauber zu WASM kompiliert, im Gegensatz zu jsdom, das viele Node-spezifische Komponenten einbezieht.
Defuddle extrahiert den Hauptinhalt. Dieser neue Extraktor stammt vom Team des Obsidian Web Clipper und wurde speziell für Markdown-Inhalte entwickelt, die für Agenten bestimmt sind. Es bewältigt seitenbezogene Besonderheiten (seitenbezogene Extraktoren für bekannte Publikationen), standardisiert Codeblöcke und Fußnoten zu konsistentem HTML und greift bei Bedarf auf eine heuristische Bewertung zurück.
Turndown durchläuft das extrahierte DOM und gibt Markdown aus. Wir fügen das GFM-Plugin für Tabellen und Durchstreichungen hinzu sowie eine kleine benutzerdefinierte Regel, um eine Eigenart von linkedom zu beheben (mehr dazu weiter unten).
Außerdem verwenden wir SimpleCache von fastly:cache für das Edge-Caching ohne weitere Abhängigkeiten.
Die Conversion-Pipeline
Alles, was HTML in Markdown umwandelt, befindet sich in einer Datei, 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`;
} Der Ablauf ist linear: Das Dokument wird mit linkedom geparst, an Defuddle übergeben, wo die Extraktion und Standardisierung erfolgt, und anschließend wird die HTML-Ausgabe noch einmal mit linkedom geparst, damit Turndown einen echten DOM-Knoten zum Durchlaufen erhält. Dieses zweite Parsen erscheint überflüssig, aber es ist wichtig. Wir werden gleich darauf eingehen, warum.
Der buildFrontmatter-Helfer zieht Titel, Beschreibung, Autor und Veröffentlichungsdatum aus den Metadaten von Defuddle und greift auf Standard-<meta>-Tags zurück, wenn Defuddle diese nicht hat. Wir geben auch die kanonische URL aus, damit alles, was diese Markdown-Datei verwendet, auf die ursprüngliche Seite zurückverweisen kann.
Das DOM-node-not-string-Problem
Wenn Sie die Dokumentation von Defuddle lesen, werden Sie eine Option markdown: true bemerken, die anscheinend alles tun soll, was Turndown für uns tut. In Node funktioniert es, aber in Compute nicht.
Der Grund: Der integrierte Markdown-Schritt von Defuddle ruft turndownService.turndown(htmlString) auf. Turndown parst einen String intern, indem er document.implementation.createHTMLDocument aufruft. Die Compute JS-Laufzeitumgebung ist SpiderMonkey, wobei Linkedom das DOM bereitstellt, und Linkedom stellt document.implementation nicht zur Verfügung. Turndown erzeugt einen Fehler, Defuddle fängt diesen Fehler ab, und Sie erhalten eine Fallback-Meldung wie „Teilweise Konvertierung mit Fehlern abgeschlossen“, an die der rohe HTML-Code angehängt ist.
Wenn Sie Turndown an einen DOM-Knoten übergeben, wird dieser Parser vollständig umgangen. Es durchläuft den Baum, den wir ihm vorgeben. Deshalb gibt es den zweiten parseHTML-Aufruf.
Die Tabellenregel
Noch eine Linkedom-Eigenheit: HTMLTableElement.rows ist nicht gefüllt. Die Tabellenregel des GFM-Plugins prüft node.rows[0], um zu entscheiden, ob die Tabelle konvertiert oder übersprungen werden soll, und da rows nicht definiert ist, wird jede Tabelle zu flachem Text.
Die Lösung ist eine kleine nutzerdefinierte Regel, die nach GFM registriert wird:
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') funktioniert, wo .rows nicht funktioniert. Da unsere benutzerdefinierte Regel zuletzt registriert wird, wählt Turndown diese anstelle der GFM-Standardregel aus. Diese paar zusätzlichen Codezeilen retten jede Seite mit einer Tabelle.
Routing und Inhaltsverhandlung
Der Compute-Fetch-Handler befindet sich in src/index.js. Der gesamte Routing-Layer umfasst etwa 50 Zeilen:
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' });
} Vier Entscheidungspunkte, der Reihe nach. Zustands- und Debug-Routen werden lokal bereitgestellt. Das Präfix /md/<path> erzwingt Markdown unabhängig vom Header. Anschließend prüfen wir die Anfrage: Wenn sie von einem bekannten KI-Crawler stammt oder explizit Markdown anfordert, führen wir eine Konvertierung durch. Andernfalls erfolgt eine direkte Weiterleitung zum Origin.
Die Crawler-Erkennung ist eine kleine Liste in src/agents.js mit 17 User-Agent-Mustern, die die gängigsten abdecken: GPTBot, ChatGPT-User, ClaudeBot, anthropic-ai, PerplexityBot, GoogleOther, cohere-ai und so weiter. Es handelt sich um einen Teilstring-Treffer ohne Berücksichtigung der Groß-/Kleinschreibung. Agenten entwickeln sich weiter. Betrachten Sie die Liste daher als Ausgangspunkt und kürzen oder erweitern Sie sie basierend auf dem, was tatsächlich in Ihren Logs auftaucht.
Caching
Die Markdown-Conversion dauert bei einer Kaltanfrage einige hundert Millisekunden, der größte Teil davon entfällt auf die Bewertung durch Defuddle. Das mag beim ersten Crawler-Treffer noch in Ordnung sein, beim hundertsten Mal ist es belastend. SimpleCache macht daraus eine Einzeiler-Lösung:
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
} Fünf Minuten sind für die meisten Content-Websites ein vernünftiger Standardwert; passen Sie ihn einfach an Ihre Veröffentlichungsfrequenz an. Der Cache ist pro POP angelegt, daher sehen Sie bei der ersten Anfrage eine kalte Conversion pro Region, danach werden die zwischengespeicherten Antworten angezeigt.
Wir haben außerdem Vary: Accept, User Agent für die Antwort eingestellt. Alle nachgelagerten Caches (Ihre eigenen, die des Crawlers) werden die gleiche Inhaltsverhandlung respektieren wie wir.
Lokale Tests
Der Konverter ist eine reine Funktion: HTML rein, Markdown raus. Dadurch ist das Testen mit einfachem Node trivial, eine Compute-Laufzeitumgebung ist nicht erforderlich:
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*\|/);
}); Fügen Sie eine Handvoll repräsentativer Testdateien in test/fixtures/ ein (z. B. einen Blogbeitrag, eine Dokumentationsseite mit Tabellen, einen Nachrichtenartikel mit Standardtext) und überprüfen Sie die Eigenschaften, die Ihnen wichtig sind. Unser Begleit-Repository enthält drei. Der npm-Test wird in etwa 200 ms durchgeführt, was bedeutet, dass Sie bei Extraktionsproblemen iterieren können, ohne WASM neu zu erstellen.
Für die vollständige Edge-Pipeline startet Fastly Compute Serve Viceroy (den lokalen Compute-Emulator von Fastly) auf 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 Setzen Sie den Eintrag [local_server.Backend.origin] in fastly.toml auf den gewünschten Ursprung, den Sie per Proxy weiterleiten möchten, und schon haben Sie eine funktionierende End-to-End-Schleife.
Bereitstellen
Die gleichen zwei Befehle wie bei jedem anderen Compute-Service:
npm run build # compile to bin/main.wasm
fastly compute deploy Beim ersten Ausführen werden Sie aufgefordert, einen Service zu erstellen und Ihr Produktions-Origin-Backend zu konfigurieren. Danach haben Sie einen Compute-Endpunkt, der auf <service>.edgecompute.app reagiert. Richten Sie eine benutzerdefinierte Domain darauf ein oder verwenden Sie Ihren bestehenden Fastly-Service als Shielding-Konfiguration davor – je nachdem, was besser zu Ihrer Topologie passt.
Was tatsächlich in der Leitung passiert
Für eine Anfrage von GPTBot an /blog/my-post:
Compute erhält die Anfrage. Der User Agent stimmt mit
GPTBotüberein → es erfolgt eine Weiterleitung zum Conversion-Pfad.Überprüfen von SimpleCache auf
html-2-md:/blog/my-post. Fehler.Abruchen von HTML vom Origin-Backend (das
Origin-Backend, das infastly.tomldeklariert ist).Parsen mit linkedom → Ausführen von Defuddle → erneutes Parsen → Turndown → frontmatter.
Speichern In SimpleCache mit einer TTL von 5 Minuten. Antwort ausgeben.
Antwort:
Content-Type: text/markdown;charset=utf-8,Vary: Accept,User-Agent,X-Markdown-Tokens: <estimate>.
Für einen regulären Browser, der zur gleichen Zeit die gleiche URL aufruft, wird Schritt 2 vollständig übersprungen. Er erhält HTML direkt vom Origin-Server, wie immer.
Wohin führen wir von hier aus
Ein paar Hinweise, die es wert sind, in Betracht gezogen zu werden, sobald es läuft:
Tokenzählung: Unsere Heuristik (Länge / 4) ist eine grobe Annäherung an GPT-ähnliche Tokenisierung. Wenn Ihnen eine korrekte Buchführung wichtig ist, sollten Sie einen echten Tokenizer verwenden. Es gibt WASM-kompatible tiktoken-Builds, die in Compute funktionieren.
Linkneuschreibung: Die aktuelle Ausgabe behält relative URLs vom Ursprung bei, was bedeutet, dass ein Crawler sie gegen die Anfrage-URL auflösen muss. Sie können relative Links innerhalb des Defuddle-Ergebnisses in absolute Links umwandeln, bevor Turndown es ausführt.
Website-spezifische Extraktoren: Defuddle unterstützt benutzerdefinierte Extraktoren für Websites mit ungewöhnlicher Struktur. Wenn Sie eine bestimmte Publikation oder Dokumentationsseite als Proxy verwenden, liefert das Schreiben eines einmaligen Extraktors eine wesentlich sauberere Ausgabe als die generischen Heuristiken.
Streaming: Bei sehr langen Artikeln puffert die aktuelle Implementierung den gesamten Text, bevor die Antwort ausgegeben wird. Durch das Streaming der Conversion würde sich die TTFB verkürzen. Das ist zwar etwas komplizierter (Defuddle möchte das gesamte Dokument bewerten), aber durch Aufteilung an den Abschnittsgrenzen machbar.
Rate Limiting nach Agent: Wenn Sie GPTBot bedienen, aber einen lauteren Bot drosseln möchten, kombinieren Sie diesen Service mit unserem Edge Rate Limiting.
Zusammenfassung
Die Bereitstellung von Markdown für KI-Agenten ist eine dieser kleinen Bemühungen, die eine große Wirkung haben können. Sie respektiert den Workload des Agenten, aber auch Ihre Bandbreite (und letztendlich Ihr Geschäftsergebnis). Compute ist dafür gut geeignet, weil die Arbeit nahe an der Anfrage erfolgt, cachefähig ist und in Millisekunden gemessen wird. Was Sie wollen, ist eine Transformation, die auf dem Anfragepfad ausgeführt wird, die Dinge nicht verlangsamt und gut zwischengespeichert wird, sodass Sie nicht zweimal für die gleiche Arbeit bezahlen.
Gerne können Sie den Service hier klonen. Wenn Sie darauf aufbauend etwas Interessantes entwickeln (z. B. einen Token-Zähler, einen Custom Extraktor, einen Link-Rewriter), würden wir gerne davon hören.

