ИИ-акселератор сайтов на Rust: прокси, который переписывает HTML на лету

Веб Штурм
  • Rust
  • edge
  • Core Web Vitals
  • GEO
  • llms.txt
  • accessibility
  • PageSpeed
  • MODX

Весной мы вручную подняли мобильный PageSpeed сайта на MODX с 44 до 100 — это заняло десять рабочих дней (кейс ОЗСМ, ссылка внизу). Осенью взяли копию того же сайта в состоянии 2017–2024 года, до любых оптимизаций, поставили перед ней прокси на Rust и за два дня получили 99. Ни одной строки в коде сайта. Ниже — что это за прокси, из каких фильтров состоит и на какие грабли наступили.

Цифры

Главная страница демо-стенда, медианы шести проб PageSpeed Insights, второй и последующие прогоны:

ПоказательКак былоЧерез акселератор
PageSpeed, телефон5299
PageSpeed, компьютер54100
LCP, телефон15,4 с1,9 с
FCP7,0 с0,9 с
TBT244 мс0 мс
CLS0,0600,002
Lighthouse Accessibility78100
llms.txtнет53 страницы
BreadcrumbList в разметкенет52 из 53 страниц
Накладные расходы рерайтера—≈ 1,6 мс на страницу 300 КБ
Строк кода сайта изменено—0

Ряд роста мобильного балла по этапам: 52 → 66 → 76 → 84 → 84 → 98 → 99. Скачок с 84 на 98 дал один шаг — убрали preload шрифта из <head>: с ним PSI выдаёт «две моды», потому что Lantern ставит шрифт в критический путь FCP. Компьютер: 54 → 90 → 91 → 99 → 100.

Цепочка

браузер → Traefik → Angie proxy_cache → Rust-рерайтер (hyper + lol-html) → origin клиента
                                            └→ /_img/* → imgproxy (AVIF/WebP)
  • Traefik — TLS, rate-limit, проверка ботов по обратному DNS.
  • Angie proxy_cache — кеш готовых страниц. Сжимаем один раз при записи в кеш: brotli «на лету» в Traefik резал отдачу из кеша в пять раз.
  • Рерайтер — HTTP-сервер на hyper 1 и потоковая перезапись через lol-html 3. Страница не буферизуется: первые байты уходят клиенту, пока хвост ещё идёт от origin.
  • imgproxy — картинки в AVIF/WebP по запросу; на прототипе AVIF отрезал 72 % веса JPEG.

Один клиент = один YAML с секциями filters, geo, a11y, theme, fixes. Секции нет — фильтр выключен. Откат — флаг passthrough: прокси остаётся, но отдаёт origin как есть.

Фильтры скорости

  • lcp_src_attrs / lcp_preload — находим картинку LCP в data-атрибутах слайдера и добавляем <link rel="preload"> в <head>. Первый ответ с ним не кешируется: адрес картинки зависит от страницы.
  • css_async — стили ниже сгиба грузятся асинхронно; critical CSS лежит файлом клиента и собирается офлайн.
  • delay_scripts + delay_inline_contains — третьи стороны (карты, счётчики, чаты) стартуют по первому взаимодействию или в idle.
  • lcp_poster — статичный постер первого слайда до инициализации revslider плюс резерв высоты без jQuery. Это и дало CLS 0,002.
  • inline_after_paint — инициализация слайдера после first-contentful-paint.
  • script_preload — для того, что всё же нужно рано.
  • font_preload: [] по умолчанию. См. грабли выше.

GEO: слой для ИИ-ботов

Модуль geo делает сайт читаемым для ИИ-поиска без правки контента:

  • llms.txt и llms-full.txt собираются из sitemap origin (обход раз в сутки, один запрос в секунду).
  • robots-менеджер: секции для ИИ-ботов — поисковых пускаем, «обучающих» отключаем по выбору клиента; добавляем Clean-param.
  • Текстовая версия любой страницы в Markdown: по расширению .md или по заголовку Accept: text/markdown.
  • BreadcrumbList и FAQPage извлекаются из DOM и добавляются как JSON-LD.
  • IndexNow по хэшу страницы; до перевода DNS работает в режиме dry-run.
  • Отчёт по ИИ-ботам с проверкой обратного DNS, чтобы отличать настоящих от подделок.

Никакой генерации текста моделью: всё, что попадает в машиночитаемый слой, взято со страницы. Про честность отдельно: видимость в ответах нейросетей измерима только после перевода DNS, первые данные приходят через 4–8 недель, а Алиса берёт источники из топ-20 Яндекса — место в её ответах никто гарантировать не может.

Доступность и тема

Модуль a11y чинит то, что Lighthouse и axe-core находят на чужом сайте: viewport без user-scalable=no, role="main", contrast-fix.css из отчёта axe-core (собирается офлайн), label_fix для чужих виджетов. Результат — Accessibility 78 → 100.

Тема — статический экспорт Dark Reader (около трёх минут на режим). CSS темы грузится только по выбору пользователя, поэтому Lighthouse меряет прежнюю светлую страницу и скорость не падает. Капсула справа вверху — не больше 1 КБ inline без внешнего JS: ☀/☾ мгновенно, по ▾ — контраст, шрифт A/A+/A++, картинки, сброс. Выбор хранится в localStorage, иначе берётся prefers-color-scheme. Кнопка «Наверх» ставится по тому же образцу, что и на нашем сайте, родная кнопка сайта скрывается.

Грабли

  1. Тело POST. Проксируем chunked — Apache + php-fpm видят пустой $_POST. Буферизуем и отдаём с Content-Length. Нашлось кликом по фильтру каталога, не тестами.
  2. Хост в JSON. Подмена origin-хоста нужна и в AJAX-ответах, не только в HTML.
  3. Cookie-баннер. Чужой обработчик кнопки был сломан ещё на origin; чиним на краю.
  4. Preload шрифта в <head> даёт две моды PSI. Убрали — 84 → 98.
  5. Brotli на лету в Traefik режет HIT кеша в пять раз. Сжимать при записи в кеш.
  6. Первая проба PSI врёт. Медиана ≥ 6 проб со второго прогона, уникальный параметр в URL на каждую пробу.

Как это делалось

Два дня, пятнадцать заданий, тридцать два PR. Облачная сессия Claude писала код фильтров и документ передачи по каждому заданию, локальная выкатывала на стенд и снимала медианы PSI. Дефекты, которые не ловились тестами (пустой $_POST, хост в JSON), находились кликами по живому сайту. Это отдельная тема для статьи о рабочем методе.

Что дальше

Ближайшие шаги: проверка сторонних скриптов на соответствие 152-ФЗ, ежемесячный отчёт «до/после», отдача из кеша при падении хостинга, замена reCAPTCHA на SmartCaptcha на краю, RUM с реальных устройств. Всё это — фильтры того же прокси, сайт клиента по-прежнему не трогаем.

Часто задаваемые вопросы

Почему Rust и lol-html, а не модуль для nginx или Cloudflare Workers?
Нужна потоковая перезапись HTML: страница уходит клиенту по мере прихода от origin, без буферизации всего документа. lol-html делает именно это (тот же движок стоит внутри HTMLRewriter у Cloudflare), а hyper даёт HTTP-сервер без сборщика мусора и с предсказуемыми задержками. На прототипе перезапись страницы в 300 КБ стоит около 1,6 мс, 4 vCPU держат около 1 950 страниц в секунду, p99 — 92 мс. Cloudflare не подходит по требованию хранить трафик и данные в России; nginx-плагин на C или Lua дал бы ту же логику, но без гарантий памяти и с более дорогой разработкой фильтров.
Как не сломать формы, корзину и AJAX, когда между сайтом и браузером стоит прокси?
Три места, где мы обожглись. Первое: тело POST нужно буферизовать и отдавать origin с Content-Length — при chunked-передаче связка Apache + php-fpm отдаёт пустой $_POST, и формы «отправляются в никуда». Второе: хост origin надо подменять не только в HTML, но и в JSON-ответах AJAX, иначе фильтр каталога уводит на внутренний адрес. Третье: чужой cookie-баннер с битым обработчиком чинится на краю. Регресс перед подключением гоняем по сценариям: формы, фильтр каталога, корзина, cookie.
Почему PageSpeed прыгает от прогона к прогону и как мерить честно?
Первая проба после выката или обхода sitemap стабильно врёт: мы видели 74, а через три минуты — 100 по тому же адресу. Разброс идёт почти целиком через LCP, то есть через сеть между серверами PSI и нашим. Поэтому все цифры в статье — медианы шести проб, снятых со второго прогона, и каждая проба идёт с уникальным параметром в URL, иначе PSI отдаёт кешированный отчёт и «стабильность» оказывается одним и тем же результатом.
Это клоакинг? Боты видят одно, люди другое?
Нет. Видимая страница одинакова для всех. Ботам ИИ-поиска добавляются только машиночитаемые слои: JSON-LD, meta, текстовые версии страниц в Markdown и llms.txt. Ничего не сочиняется: генераторы извлекают данные из DOM страницы и sitemap, LLM в цепочке отдачи нет вовсе. Поисковых ботов пропускаем всегда, «обучающих» — по выбору владельца сайта через robots.