Перейти к основному содержимому
БЛОГ

Markdown для ИИ-агентов без Cloudflare: nginx, Nuxt, Next.js

Как отдавать Markdown ИИ-агентам без Cloudflare: map в nginx для Accept: text/markdown, код для Nuxt и Next.js, Vary: Accept и curl-скрипт на 9 проверок.

Автор: IMozzОбновлено 2026-10-20
Markdown для ИИ-агентов без Cloudflare — aiSiteReady

Отдавать Markdown ИИ-агентам на собственном стеке можно, если взять на себя три задачи, которые у Cloudflare решает управляемая функция. Это выбор варианта по Accept: text/markdown, подготовка Markdown и привязка каждого кэша к варианту. Первую и третью в nginx закрывают несколько строк, обработчик в Nuxt или Next.js справляется с первой и второй, а матрица curl-проверок подтверждает все три.

Это сборник рецептов к статье Markdown для ИИ-агентов: content negotiation, где разобраны причины, паттерны отдачи и теория CDN. Здесь вы найдёте рабочие конфиги, настоящее Nuxt-middleware aiSiteReady, способы конвертации и регрессионный скрипт. Тема относится к слою контента в стеке готовности к ИИ-агентам, предполагает, что факты уже лежат в серверно отрендеренном HTML (как сделать JavaScript-сайт читаемым), и закрывает то, на что указывает llms.txt.

Ключевые выводы

  • Управляемая функция Cloudflare конвертирует только HTML, на подходящих тарифах, и ограничивает ответ origin размером 2 МБ (документация Cloudflare).
  • Своя реализация — это три задачи: выбор варианта с учётом q, источник Markdown и ключ кэша, в который входит вариант.
  • nginx может классифицировать Accept через map, но не сравнивает q-значения, поэтому рецепт намеренно консервативен (документация nginx).
  • Собственное Nuxt-middleware aiSiteReady вышло 13 июня 2026 года: настоящая логика q-значений, только GET (на октябрь 2026 года), Markdown строится из тех же источников, что и HTML.
  • В Next.js 16 соглашение о файле middleware переименовали в proxy (документация Next.js).

Что делает Markdown for Agents от Cloudflare и что вы заменяете?

Функция конвертирует ваш HTML в Markdown на edge Cloudflare, когда клиент присылает Accept: text/markdown, и на origin ничего менять не нужно. Cloudflare анонсировал её 12 февраля 2026 года (блог Cloudflare). На справочной странице перечислены тарифы Pro, Business и Enterprise, а также клиенты SSL for SaaS, без доплаты; включается в дашборде или через API. Заявленные ограничения: конвертируется только HTML, а ответ origin не может превышать 2 МБ (2 097 152 байта) (документация Cloudflare).

Что делает CloudflareЧто строите вы
Определяет Accept: text/markdownПроверку с учётом q: в map nginx или в коде приложения
Конвертирует HTML на edgeЭкспорт при сборке или шаг HTML-в-Markdown
Ставит text/markdown; charset=utf-8 и добавляет Accept в VaryТе же заголовки на обоих вариантах
Добавляет x-markdown-tokens и x-original-tokensНеобязательно и в любом случае приблизительно
Добавляет Content-Signal: ai-train=yes, search=yes, ai-input=yes, если origin его не задалОсознанное значение или никакого

Последняя строка важна: управляемый вариант по умолчанию разрешает обучение и сохраняет ваше значение, если вы его задали. Если отдаёте Markdown сами, никто не напишет эту строку за вас, так что выбирайте сами (предпочтения по использованию контента). Поведение CDN разобрано в прошлой статье; здесь речь только о кэше, который вы держите сами.

Как отдавать Markdown из nginx?

Сверните заголовок Accept в один из двух вариантов через map, а затем по этому варианту выбирайте файл или ключ кэша. map работает в контексте http и перебирает регулярные выражения по порядку, побеждает первое совпадение (документация nginx). Сравнивать q-значения он не умеет, поэтому правило консервативно: Markdown, только если text/markdown указан первым без параметров, кроме q=1. Браузеры, */*, отсутствующий заголовок, text/markdown;q=0 и даже text/markdown; charset=utf-8 получают HTML.

map $http_accept $variant {
    default "html";
    # В кавычках: в шаблоне есть "}" и ";" (правило из документации модуля rewrite nginx).
    "~*^\s*text/markdown\s*(;\s*q=1(\.0{0,3})?\s*)?(,|$)" "md";
}

Путь запроса в nginx: map по Accept даёт запросам с text/markdown на первом месте вариант md, а всем остальным html, у каждого свой ключ кэша и Vary: Accept.

Оба рецепта я проверил 3 октября 2026 года в официальном образе nginx:alpine (nginx 1.31): nginx -t не нашёл ошибок, а скрипт ниже успешно прошёл на рецепте A.

Рецепт A: Markdown-файлы рядом со страницами

Если сборка кладёт index.html и index.md в каждый каталог, nginx отдаёт нужный файл. Поместите это внутрь http, рядом с map выше.

gzip on;
gzip_vary on;
gzip_types text/markdown;               # text/html сжимается всегда; Markdown нужно перечислить

map $variant $index_file {
    md      "index.md";
    default "index.html";
}

server {
    listen 80;
    server_name example.com;
    root /var/www/site;

    # /guide/ превращается в /guide/index.md или /guide/index.html. Если index.md
    # нет, отдаётся HTML-страница, так что согласуемый URL не даёт 404. Ресурсы
    # проходят здесь без изменений: без Vary на CSS, JS и картинках.
    location / {
        index $index_file index.html;
        try_files $uri $uri/ =404;
    }

    # Markdown-файл: запрошен напрямую или попал сюда через redirect от index.
    location ~ \.md$ {
        types { }                                   # здесь mime.types не нужен
        default_type text/markdown;                 # голый тип, чтобы совпал gzip_types
        charset utf-8;
        charset_types text/markdown;                # charset действует только на эти типы
        add_header Vary Accept always;
    }

    # HTML-страницы: Vary и ссылка на Markdown-двойника.
    location ~ ^(?<dir>/(?:.*/)?)index\.html$ {
        add_header Vary Accept always;
        add_header Link "<${dir}index.md>; rel=\"alternate\"; type=\"text/markdown\"" always;
    }
}

Рецепт держится на четырёх деталях:

  1. Директива index принимает переменные и работает через внутренний redirect, поэтому запрос обрабатывается заново в том location, который подходит новому URI (документация nginx). Именно поэтому тип выставляет location для Markdown.
  2. В стандартном mime.types нет записи для md (проверено в репозитории nginx), так что types { }, default_type text/markdown, charset utf-8 и charset_types text/markdown задают тип и charset в виде, который gzip_types по-прежнему распознаёт. Если вписать charset прямо в default_type, gzip для Markdown молча отключится.
  3. add_header наследуется только тогда, когда на текущем уровне нет своих директив, поэтому заголовки уровня server, например HSTS, придётся повторить в этих двух location (документация nginx). always сохраняет заголовки на 404, если index.md нет. Если index.md есть не во всех каталогах, уберите строку Link или отдавайте её только там, где файл существует.
  4. gzip_vary on добавляет вторую строку Vary с Accept-Encoding. Две строки Vary допустимы, потому что Vary — поле-список и получатель вправе их объединить (RFC 9110); именно такая пара приходит из продакшена aiSiteReady.

Рецепт B: кэш перед приложением

Когда nginx стоит перед Nuxt, Next.js или Express, оба варианта выдаёт приложение, а nginx лишь кэширует их по отдельности. Используйте это для страниц; у JSON API должен быть свой Accept.

proxy_cache_path /var/cache/nginx/pages levels=1:2 keys_zone=pages:10m
                 max_size=512m inactive=30m use_temp_path=off;

map $variant $origin_accept {
    md      "text/markdown";
    default "text/html";
}

upstream app {
    server 127.0.0.1:3000;
    keepalive 16;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://app;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header Accept $origin_accept;     # origin видит два значения, а не сотни

        proxy_cache pages;
        proxy_cache_key "$scheme://$host$request_uri|$variant";
        proxy_cache_valid 200 5m;
        proxy_cache_valid 404 1m;
        proxy_ignore_headers Vary;                  # ключ по $variant строим сами

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

Зачем игнорировать Vary? Начиная с nginx 1.7.7 он кэширует ответ «с учётом соответствующих полей заголовка запроса» (документация nginx), и каждая отдельная строка Accept от браузеров получила бы свою запись. Ключ по $variant оставляет две.

Цена: если origin варьируется ещё и по Cookie или Accept-Language, добавьте их в ключ или не кэшируйте эти пути. nginx пропускает Vary приложения как есть, так что приложение должно слать Vary: Accept на согласуемых страницах, а не на ресурсах. Ключ по умолчанию, $scheme$proxy_host$request_uri, вообще не содержит варианта; строка proxy_cache_key и есть ваша защита от отравления кэша из прошлой статьи.

Как это делает сервер на Nuxt или Nitro?

Одним серверным middleware, которое отвечает только на явный запрос Markdown, а всё остальное передаёт HTML-рендереру. Это настоящий код aiSiteReady, добавленный 13 июня 2026 года: сначала маркетинговые страницы, в тот же вечер статьи блога. Код начинается с проверок.

// frontend/server/middleware/markdown-negotiation.ts (фрагмент, октябрь 2026)
export default defineEventHandler(async (event) => {
  if (!isGetOrHead(event)) { // HEAD согласуется точно так же, как GET
    return;
  }
  if (!prefersMarkdown(getRequestHeader(event, "accept"))) {
    return;
  }
  const pathname = getRequestURL(event).pathname;
  // ...определить страницу, собрать Markdown, вернуть sendMarkdown(event, markdown)
});

Если ничего не вернуть, запрос уходит дальше: h3 вызывает обработчики по порядку, пока один не вернёт ответ (документация h3). Документация Nuxt описывает middleware как «только смотреть» (документация Nuxt), поэтому возврат значения считайте поведением h3, а не обещанием Nuxt, и закрепите его матрицей ниже. Ответ собирается в одном месте:

function sendMarkdown(event: H3Event, markdown: string): string {
  setResponseHeader(event, "content-type", "text/markdown; charset=utf-8");
  setResponseHeader(event, "vary", "Accept");
  setResponseHeader(event, "cache-control", "public, s-maxage=300, stale-while-revalidate=86400");
  return bodyForMethod(event, markdown); // HEAD: пустое тело, Content-Length как у GET
}

Три решения стоит перенять:

  1. HTML здесь не конвертируется. Маркетинговые страницы собираются из того же i18n-каталога и той же структуры страниц, что и Vue-компоненты, а @nuxt/content хранит статью блога как разобранное дерево, которое небольшой сериализатор записывает обратно в Markdown. Оба представления строятся из одного источника, поэтому они не расходятся.
  2. Всё, на что middleware не может ответить, уходит дальше: неизвестный slug получает честный 404 от рендерера, а сбой запроса к контенту отдаёт HTML, а не 500.
  3. Vary для HTML-варианта берётся из route rules, так как SSR формирует этот ответ уже после того, как middleware уступило дорогу.
// frontend/nuxt.config.ts (фрагмент: правило маршрута, общее для маркетинговых страниц и статей блога)
headers: {
  "Cache-Control": "public, s-maxage=300, stale-while-revalidate=86400",
  "Vary": "Cookie, Accept-Language, Accept",
},

Ветка для HEAD тоже из первых рук. Первая версия проверяла только GET и отправляла запросы HEAD в HTML-рендерер: 2 октября 2026 года curl -I -H 'Accept: text/markdown' к статье aiSiteReady вернул заголовки text/html, а тот же запрос через GET — text/markdown; charset=utf-8. RFC 9110 говорит, что на HEAD серверу следует отвечать теми заголовками, что дал бы GET (RFC 9110), поэтому проверка теперь пропускает HEAD, а bodyForMethod убирает только тело. Если копируете, выставляйте Content-Length сами: Node не добавляет его в ответ на HEAD, если обработчик не задал его явно.

Как согласовывать Markdown в Next.js?

Используйте файл proxy, чтобы переписывать (rewrite) запросы Markdown на route handler, и добавляйте Vary: Accept к каждому ответу, который он обрабатывает. В Next.js 16.0.0 соглашение о файле middleware объявили устаревшим и переименовали в proxy. NextResponse.rewrite отдаёт другой маршрут под исходным URL, а заголовки ответа выставляются на возвращаемом NextResponse (документация Next.js). В Next.js 15 и более ранних версиях назовите файл middleware.ts, а функцию middleware.

// proxy.ts (корень проекта, рядом с app/)
import { NextResponse, type NextRequest } from "next/server";
import { prefersMarkdown } from "./lib/prefers-markdown";   // функция из раздела про q-значения
import markdownPaths from "./generated/markdown-paths.json"; // создаётся при сборке

const hasMarkdown = new Set<string>(markdownPaths);          // ["/docs/intro", ...]

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const wants =
    (request.method === "GET" || request.method === "HEAD") &&
    prefersMarkdown(request.headers.get("accept")) &&
    hasMarkdown.has(pathname);

  const response = wants
    ? NextResponse.rewrite(new URL(`/api/md${pathname}`, request.url))
    : NextResponse.next();
  response.headers.append("Vary", "Accept");   // append: Next.js может сам задавать значения Vary
  return response;
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"],
};

Целью rewrite служит Route Handler из прошлой статьи. Манифест времени сборки не даёт странице без Markdown-двойника превратиться в 404 для агентов: она уходит в HTML. matcher исключает api, ресурсы _next и всё, у чего есть расширение файла.

Оговорка: aiSiteReady работает на Nuxt, поэтому в отличие от предыдущего раздела этот фрагмент я в продакшене не запускал; он следует документированному API. Next.js умеет сопоставлять и заголовок запроса внутри rewrites(), но регулярное выражение по заголовку не взвесит q-значения, а для Vary нужно отдельное правило headers() (документация Next.js). Прогоните матрицу на своей сборке, прежде чем доверять любому из вариантов.

Конвертировать при сборке или на лету?

Генерируйте Markdown из того же источника, что и HTML, при сборке, если получается; HTML на лету конвертируйте, только когда отрендеренный HTML — единственный источник правды. Результат сборки можно проверить до деплоя, он дёшево кэшируется, а всё, что сгенерировано из источника, не может разойтись со страницей. aiSiteReady держится золотой середины: он рендерит Markdown из источника контента на каждый запрос, без конвертации HTML.

Конвертация на лету — это конвейер: получить HTML, убрать обвязку, сконвертировать, исправить ссылки, закэшировать по URL и варианту. Среднюю часть закрывают три настоящие библиотеки.

  • Turndown конвертирует HTML в Markdown в Node и в браузере. По умолчанию он делает setext-заголовки и блоки кода с отступом, поэтому задайте headingStyle: "atx" и codeBlockStyle: "fenced", а навигацию уберите через remove().
  • rehype-remark превращает синтаксическое дерево HTML в дерево Markdown в экосистеме unified: rehype-parse, rehype-remark, затем remark-stringify.
  • Mozilla Readability вытаскивает основной контент страницы, так что навигация, футер и cookie-баннер до конвертера не доходят. Ей нужен DOM, например jsdom.
import TurndownService from "turndown";

const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
turndown.remove(["script", "style", "nav", "footer", "form"]);

export const htmlToMarkdown = (html: string) => turndown.turndown(html);

// Грубая оценка размера для логов или заголовка с числом токенов. Это не токенизатор.
export const estimateTokens = (text: string) => Math.ceil(text.length / 4);

Любое число токенов — приближение. Cloudflare называет свои заголовки оценкой, а документация Anthropic называет оценкой собственный подсчёт и отмечает, что новый токенизатор даёт примерно на 30 процентов больше токенов на тот же ввод, чем прежние модели (документация Anthropic). Деление числа символов на четыре — это ориентир, а не обещание. Конвертация на лету к тому же наследует ограничения HTML: если факты подгружаются клиентским JavaScript, ни один конвертер их не найдёт.

Какие ошибки с q-значениями ломают content negotiation?

Почти все они происходят от того, что Accept воспринимают как строку для поиска, а не как список диапазонов с весами. RFC 9110 определяет вес так: по умолчанию 1, при 0,001 тип наименее предпочтителен, а 0 означает «неприемлемо» (RFC 9110).

ОшибкаПример AcceptПравильный ответ
Поиск подстрокиtext/markdown;q=0HTML: ноль значит отказ
Wildcard принят за согласие*/* (значение curl по умолчанию)HTML
Строки браузеровtext/html,…,*/*;q=0.8HTML
Markdown с меньшим весомtext/markdown;q=0.5, text/html;q=0.9HTML
Регистр и пробелыText/Markdown ; q=1Markdown
Ничего не подходитapplication/jsonHTML по политике или 406
Равные весаtext/html, text/markdownНа ваше усмотрение; проверьте
Токен в VaryVary: Accept-EncodingЭто не Accept: сравнивайте токены целиком

Медиатипы нечувствительны к регистру, запрос без Accept не выражает предпочтений, а когда ничего не подходит, сервер может ответить 406 или проигнорировать заголовок (RFC 9110). Для URL страницы, которую открывают и браузеры, безопаснее вернуть HTML. Ничья тоже вопрос политики, раз RFC не задаёт правила для равных весов: middleware aiSiteReady при любой ничьей отдаёт Markdown, а правило nginx следует порядку в списке, поэтому text/markdown, text/html получает Markdown, а text/html, text/markdown — HTML.

Вот правило в виде кода: у text/markdown вес должен быть больше нуля и не меньше веса text/html и wildcard. Я сравнил эту сжатую версию с той, что работает в aiSiteReady, на 200 000 случайных строк Accept и расхождений не нашёл.

export function prefersMarkdown(accept: string | null | undefined): boolean {
  if (!accept) return false;
  const best = new Map<string, number>();            // медиатип -> наибольший указанный q
  for (const part of accept.split(",")) {
    const [type, ...params] = part.split(";").map((s) => s.trim());
    if (!type) continue;
    let q = 1;
    for (const param of params) {
      const [name, value] = param.split("=").map((s) => s.trim());
      const n = Number.parseFloat(value ?? "");
      if (name?.toLowerCase() === "q" && !Number.isNaN(n)) q = Math.min(1, Math.max(0, n));
    }
    const key = type.toLowerCase();
    best.set(key, Math.max(best.get(key) ?? 0, q));
  }
  const md = best.get("text/markdown") ?? 0;
  return md > 0 && md >= (best.get("text/html") ?? 0) && md >= (best.get("*/*") ?? 0);
}

text/* игнорируется намеренно, поэтому клиент, приславший только text/*, получит HTML. Но клиент, у которого text/* весит больше text/markdown, всё равно получит Markdown; если нужно строгое поведение по RFC 9110, сравнивайте и с text/*.

Как проверить согласование по Accept через curl?

Запросите один URL с фиксированным набором заголовков Accept и проверьте статус, Content-Type и токены Vary каждого ответа. Используйте GET, а не curl -I, и проверяйте публичный URL, чтобы кэш по дороге тоже участвовал в тесте.

Accept запросаОжидаемый Content-TypeСтатусVary
text/markdowntext/markdown; charset=utf-8200есть Accept
text/markdown, text/html;q=0.8text/markdown; charset=utf-8200есть Accept
text/markdown,*/*;q=0.1 (сканер aiSiteReady)text/markdown; charset=utf-8200есть Accept
Строка браузера, */* или без заголовкаtext/html200есть Accept
text/markdown;q=0text/html200есть Accept
text/markdown;q=0.5, text/html;q=0.9text/html200есть Accept
application/jsontext/html (политика fallback)200есть Accept
Любой, на несуществующем URLне Markdown404есть Accept (приложение); статический сервер может его не ставить

Скрипт покрывает эти строки девятью проверками Accept. Он сравнивает Vary по токенам целиком, потому что в Accept-Encoding есть буквы Accept, и предупреждает, когда HEAD расходится с GET. Передавайте итоговый URL, например /guide/ для рецепта A: скрипт не следует редиректам, а для несуществующего URL проверяет только статус.

#!/usr/bin/env bash
# использование: ./check-negotiation.sh https://example.com/guide/ [https://example.com/no-such-page]
set -u
URL="${1:?usage: $0 URL [MISSING_URL]}"; MISSING="${2:-}"
BODY="$(mktemp)"; trap 'rm -f "$BODY"' EXIT; fails=0

vary_has_accept() { tr ',' '\n' | sed 's/^ *//; s/ *$//' | grep -qix 'accept'; }

check() {  # метка | значение Accept ("" — не слать) | ожидаемый медиатип
  local label="$1" accept="$2" want="$3" hdrs status ctype problems=""
  local args=(-sS -D - -o "$BODY")
  if [ -n "$accept" ]; then args+=(-H "Accept: $accept"); else args+=(-H 'Accept:'); fi
  hdrs="$(curl "${args[@]}" "$URL" | tr -d '\r')" || { echo "FAIL  $label: request failed"; fails=$((fails+1)); return; }
  status="$(printf '%s\n' "$hdrs" | head -1 | awk '{print $2}')"
  ctype="$(printf '%s\n' "$hdrs" | grep -i '^content-type:' | head -1 | sed 's/^[^:]*: *//' | tr 'A-Z' 'a-z')"
  [ "$status" = 200 ] || problems="$problems status=$status"
  case "$ctype" in "$want"*) ;; *) problems="$problems content-type=[$ctype]" ;; esac
  printf '%s\n' "$hdrs" | grep -i '^vary:' | sed 's/^[^:]*: *//' | vary_has_accept \
    || problems="$problems missing-Vary-Accept"
  if [ "$want" = "text/markdown" ]; then
    case "$ctype" in *charset=utf-8*) ;; *) problems="$problems missing-charset" ;; esac
    head -c 2000 "$BODY" | grep -qiE '<!doctype|<html' && problems="$problems body-is-html"
  fi
  if [ -z "$problems" ]; then echo "ok    $label"; else echo "FAIL  $label:$problems"; fails=$((fails+1)); fi
}

check 'markdown only'        'text/markdown'                         text/markdown
check 'markdown, html q=0.8' 'text/markdown, text/html;q=0.8'        text/markdown
check 'aiSiteReady scanner'  'text/markdown,*/*;q=0.1'               text/markdown
check 'browser'              'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' text/html
check 'curl default */*'     '*/*'                                   text/html
check 'no Accept header'     ''                                      text/html
check 'markdown refused'     'text/markdown;q=0'                     text/html
check 'html preferred'       'text/markdown;q=0.5, text/html;q=0.9'  text/html
check 'json only'            'application/json'                      text/html

head_type="$(curl -sS -I -H 'Accept: text/markdown' "$URL" | tr -d '\r' | grep -i '^content-type:' | head -1 | sed 's/^[^:]*: *//')"
case "$head_type" in text/markdown*) echo "ok    HEAD mirrors GET" ;; *) echo "WARN  HEAD returned [$head_type]" ;; esac

if [ -n "$MISSING" ]; then
  code="$(curl -sS -o /dev/null -w '%{http_code}' -H 'Accept: text/markdown' "$MISSING")"
  case "$code" in 404|410) echo "ok    missing page -> $code" ;; *) echo "FAIL  missing page -> $code"; fails=$((fails+1)) ;; esac
fi
[ "$fails" -eq 0 ] && echo PASS || { echo "$fails check(s) failed"; exit 1; }

Я прогнал его на локальном сервере в трёх сломанных режимах. Проверка подстроки по Accept проваливает проверки q=0 и «html preferred»; сервер, отдающий только Vary: Accept-Encoding, проваливает все проверки; обработчик только для GET проходит с предупреждением про HEAD. На статье aiSiteReady 2 октября 2026 года прошли все девять проверок Accept, несуществующая статья вернула 404, а строка про HEAD выдала предупреждение. После исправления HEAD эта строка выглядит как ok HEAD mirrors GET.

Какой подход выбрать?

Если вы уже проксируете через Cloudflare и ваш HTML подходит, включите управляемую функцию; на своём сервере экспортируйте Markdown при сборке, согласуйте вариант в nginx и публикуйте .md-соседей в придачу. В остальном выбирайте по тому, кому принадлежит сервер и откуда берётся Markdown.

ВариантВыбирайте, когдаЧто остаётся вам
Управляемая конвертация (Cloudflare)Вы уже проксируете через него на подходящем тарифеРешение про Content-Signal
Экспорт при сборке плюс map в nginxСтатический или Markdown-first сайт на вашем сервереШаг сборки, ключ кэша
Рендер из источника на каждый запрос (aiSiteReady)Источник лежит в CMS или @nuxt/contentСериализатор, Vary, ключ кэша
На лету в приложенииОтрендеренный HTML — единственный источникКонвертер, тесты, CPU
Только .md-соседиСтатический хостинг, неизвестный CDNДубли URL

Четыре способа отдачи Markdown (управляемый edge, экспорт при сборке с map в nginx, на лету в приложении, .md-соседи) по усилиям, свежести, безопасности кэша и подвоху.

Для контентного сайта на собственном сервере эта связка из Markdown при сборке, согласования в nginx и .md-соседей — мой выбор по умолчанию. Соседи нужны, чтобы клиент, который никогда не шлёт Accept: text/markdown, всё равно мог забрать файл. Предложение llms.txt допускает page.md или page.html.md, а для URL каталогов index.md или index.html.md (llmstxt.org). Запасная проба aiSiteReady делает один запрос: page.md для URL страницы, index.html.md для URL каталога.

Как проверка Markdown в aiSiteReady оценивает страницу?

Проверка markdown_negotiation в aiSiteReady запрашивает страницу так, как это сделал бы агент, и оценивает ответ как PASS, PARTIAL, FAIL или UNKNOWN, с доказательствами. У неё высокая серьёзность и вес 2,0 внутри категории Доступность контента, а эта категория даёт 25% оценки (40% в профиле Контентный сайт). Канонический URL запрашивается с Accept: text/markdown,*/*;q=0.1 через SSRF-безопасный fetcher, JavaScript не выполняется. Только если этот запрос не прошёл, проверка запрашивает .md-URL в стиле llms.txt.

  • PASS: Content-Type ровно text/markdown, тело похоже на Markdown, и есть Vary: Accept либо объявленный Link: rel="alternate"; type="text/markdown". Соседний URL с Markdown проходит и без Vary.
  • PARTIAL: устаревший тип text/x-markdown, Markdown без Vary и Link, Link: rel="alternate"; type="text/markdown" на ответе, который не является Markdown (цель ссылки сканер не запрашивает), либо HTML с Vary: Accept. Любой другой признак Markdown, например похожее на Markdown тело с другим типом, тоже даёт PARTIAL.
  • FAIL: ни в одном из ответов нет признаков Markdown либо ответ не 2xx. Исключение: 403 и 429 дают UNKNOWN, потому что блокировка бота — не вердикт; так же трактуются таймауты и тела больше 2 МБ.

«Похоже на Markdown» означает минимум два признака (заголовок, ссылка, список, блок кода, отсутствие HTML-тегов) и не больше трёх HTML-тегов (до четверти строк, если ответ отдан как text/markdown).

Эта эвристика сыграла против нас: когда блог начал отдавать text/markdown, одна техническая статья оценилась как не-Markdown из-за 32 тегоподобных строк, и все они были в примерах кода. Исправление сканера в тот же вечер, 13 июня 2026 года, научило его игнорировать теги внутри fenced- и inline-кода; теги в прозе по-прежнему считаются.

Точные веса описаны на странице методологии; проверка Markdown стоит рядом с llms.txt в слое доступности контента из статьи что значит готовность к ИИ-агентам. Запустите бесплатное сканирование, чтобы увидеть, отдаёт ли ваш сервер настоящий Markdown на Accept: text/markdown, какого заголовка не хватает и что исправить первым, — на английском, украинском или русском.

Если коротко: Markdown для агентов на своём сервере — это три задачи, а не платформа. Выберите вариант по Accept с учётом q, подготовьте Markdown из источника, который у вас уже есть, и сделайте так, чтобы каждый кэш между вами и агентом знал: у одного URL два представления.

У IMozz 20 лет в разработке ПО, а последний год он строит продукты с помощью LLM. Он развивает aiSiteReady, сканер только для чтения, который проверяет, может ли ИИ-агент прочитать сайт. Сканер серверно рендерит собственный контент как рабочий пример.

Частые вопросы

Может ли nginx отдавать Markdown ИИ-агентам на том же URL?
Да. Директива map по заголовку Accept выбирает вариант, вариант попадает в выбор файла или в ключ proxy-кэша, а каждый согласованный ответ несёт Vary: Accept. nginx не умеет сравнивать q-значения, поэтому map консервативен: Markdown, только если text/markdown указан первым без параметров, кроме q=1. Всё остальное, включая браузеры и голый wildcard, получает HTML.
Работает ли Markdown for Agents от Cloudflare на любом тарифе?
В документации Cloudflare перечислены тарифы Pro, Business и Enterprise, а также клиенты SSL for SaaS, без доплаты; тариф Free там не назван. Функция конвертирует только HTML, а ответ origin не может превышать 2 МБ. Если вы не укладываетесь в эти условия или не используете Cloudflare, Markdown придётся готовить самостоятельно.
Достаточно ли проверять Accept поиском подстроки?
Нет. Проверка на подстроку text/markdown срабатывает и на text/markdown;q=0, что по RFC 9110 означает «неприемлемо», и на запросы, где у HTML вес выше. Разбирайте диапазоны и веса: у text/markdown вес должен быть больше нуля и не ниже, чем у text/html и у wildcard.
Должны ли HEAD-запросы возвращать те же заголовки для Markdown?
В идеале да. RFC 9110 говорит, что серверу следует (SHOULD) отправлять на HEAD те же поля заголовка, что и на GET. Middleware самого aiSiteReady поначалу согласовывало только GET: 2 октября 2026 года curl -I с Accept: text/markdown показывал там HTML-заголовки, а с тех пор оно отвечает на HEAD заголовками Markdown. Тесты согласования запускайте с GET, а расхождение по HEAD считайте предупреждением, а не ошибкой.