惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Securelist
Engineering at Meta
Engineering at Meta
MongoDB | Blog
MongoDB | Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
A
About on SuperTechFans
S
Security @ Cisco Blogs
L
LangChain Blog
Cloudbric
Cloudbric
B
Blog
Google Online Security Blog
Google Online Security Blog
I
InfoQ
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
GbyAI
GbyAI
WordPress大学
WordPress大学
月光博客
月光博客
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
小众软件
小众软件
Cyberwarzone
Cyberwarzone
Martin Fowler
Martin Fowler
AWS News Blog
AWS News Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
PCI Perspectives
PCI Perspectives
T
The Exploit Database - CXSecurity.com
Hugging Face - Blog
Hugging Face - Blog
Microsoft Azure Blog
Microsoft Azure Blog
C
CERT Recently Published Vulnerability Notes
博客园 - 司徒正美
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
F
Full Disclosure
P
Palo Alto Networks Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
V2EX - 技术
V2EX - 技术
G
GRAHAM CLULEY
T
Threat Research - Cisco Blogs
P
Privacy International News Feed
S
Schneier on Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Attack and Defense Labs
Attack and Defense Labs
V
Visual Studio Blog
N
News and Events Feed by Topic
C
Cybersecurity and Infrastructure Security Agency CISA
S
Security Affairs
Spread Privacy
Spread Privacy
Vercel News
Vercel News
量子位
Apple Machine Learning Research
Apple Machine Learning Research
Security Latest
Security Latest
T
The Blog of Author Tim Ferriss
IT之家
IT之家
Stack Overflow Blog
Stack Overflow Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Подмена hero на edge по UTM: Cloudflare Pages Functions + HTMLRewriter для React SSG за 200 строк
Антон Ро Батень · 2026-06-01 · via Все публикации подряд на Хабре

Средний

8 мин

6.7K

Проблема. У вас один SSG-лендинг, на который льётся платный трафик из 12 разных рекламных кампаний. Каждая группа объявлений сделана под свою боль ЦА: «AI-сотрудники», «AI-агенты», «стратегическая сессия», «управленческая отчётность». Все ведут на один дефолтный hero «ИИ для бизнеса». Конверсия в заявку проседает на 30–50% по сравнению с разнотемными лендингами под каждую группу. Делать 12 отдельных лендингов — дорого по разработке и убивает SEO. Подменять hero JavaScript-ом на клиенте — FOUC, плохой Core Web Vitals, и Яндекс/Google видят дефолт.

В этой статье — рабочая схема, которую мы поставили в продакшен за один день: edge-функция Cloudflare Pages переписывает HTML на лету через HTMLRewriter, SSG остаётся первым источником истины, client-side React выполняет ту же логику при гидратации. 200 строк кода, ноль зависимостей сверх стандартных, латенси без изменений (HTMLRewriter работает потоком), Lighthouse не страдает.

Альтернативы, которые мы рассмотрели и отбросили

Делать N статичных лендингов — растёт с числом UTM-вариантов линейно, ломает каноникализацию, дублирует SEO-сигналы. Для 12 кампаний — 12 копий контента, которые надо синхронизировать каждый раз когда меняется блок ниже hero.

Client-side подмена через React.useEffect — FOUC: пользователь видит дефолтный hero, потом он мгновенно меняется. На медленном соединении видна вспышка, на быстром — заметна, потому что hero — это первое что глаз фокусирует. Дополнительно — Яндекс и Google видят при первом рендере дефолт, что для SEO не критично, но для рекламных платформ (Quality Score) — критично.

Server-side рендер с переменными в URL — требует Next.js / Remix с SSR, runtime-стоимость, более сложный деплой. Если у вас уже SSG (vite-react-ssg, Astro, Eleventy) — это шаг назад.

Edge Workers с HTMLRewriter — переписывание HTML на потоке между origin и клиентом. Latency-overhead единицы миллисекунд. SSG как был, так и остался — функция работает поверх. Это и есть то, что мы выбрали.

Архитектура

┌─────────────────┐   GET /?utm_offer=ai-agents   ┌──────────────────────┐
│   Browser       │ ────────────────────────────▶ │  Cloudflare Edge     │
└─────────────────┘                               │                      │
        ▲                                          │  ┌────────────────┐  │
        │                                          │  │ _middleware.ts │  │
        │                                          │  │  читает UTM,   │  │
        │                                          │  │  next() в SSG, │  │
        │                                          │  │  HTMLRewriter  │  │
        │                                          │  │  переписывает  │  │
        │                                          │  └────────┬───────┘  │
        │                                          └───────────┼──────────┘
        │                                                      │
        │                                                      ▼
        │                                          ┌──────────────────────┐
        │                                          │  Static SSG asset    │
        │                                          │  /index.html         │
        │   подменённый HTML                       │  с data-offer-slot=* │
        └──────────────────────────────────────────└──────────────────────┘

Ключевая идея — data-attribute якоря. В React-компоненте Hero ставим атрибуты на DOM-узлы которые могут подменяться:

// src/sections/Hero/Hero.tsx
import { useSearchParams } from 'react-router-dom'
import { resolveOffer } from '@/data/offers'

export function Hero() {
  const [searchParams] = useSearchParams()
  const offer = resolveOffer(searchParams.get('utm_offer'))

  return (
    <section>
      <div data-offer-slot="eyebrow-wrap">
        <span aria-hidden className="dot" />
        <span data-offer-slot="eyebrow">{offer.eyebrow}</span>
      </div>

      <h1>
        <span data-offer-slot="h1">{offer.h1}</span>
        <br />
        <span data-offer-slot="h1-sub">{offer.h1Sub}</span>
      </h1>

      <p data-offer-slot="lede">{offer.lede}</p>

      <a href="#cta" className="btn-primary">
        <span data-offer-slot="cta">{offer.ctaText}</span>
      </a>
    </section>
  )
}

data-offer-slot — единственная вещь, которая знают оба слоя: и React, и edge-функция. Это контракт между ними.

Чуть подробнее про слоты: я специально поставил data-offer-slot="eyebrow" на внутренний span, а не на родительский div. Если поставить на родителя, HTMLRewriter затрёт decorative-элементы (точка <span aria-hidden> слева от eyebrow). Правило: слот должен оборачивать только текстовый узел, без сиблингов.

Edge-функция

Cloudflare Pages поддерживает функции в каталоге functions/ рядом с кодом. Файл _middleware.ts отрабатывает для всех путей в проекте (если не возвращён context.next() явно).

// functions/_middleware.ts
import { OFFERS, isOfferKey } from '../src/data/offers'

export const onRequest: PagesFunction = async (context) => {
  const url = new URL(context.request.url)

  // Подмена работает только на корне. Для /blog, /guides и т.п.
  // у каждой страницы свой смысл, hero подменять не нужно.
  if (url.pathname !== '/' && url.pathname !== '/index.html') {
    return context.next()
  }

  if (context.request.method !== 'GET') {
    return context.next()
  }

  const utmOffer = url.searchParams.get('utm_offer')
  if (!isOfferKey(utmOffer)) {
    // Дефолтный hero уже в SSG — отдаём как есть.
    return context.next()
  }

  const offer = OFFERS[utmOffer]
  const response = await context.next()

  // Гарантируем что это HTML, прежде чем парсить через HTMLRewriter.
  const contentType = response.headers.get('Content-Type') ?? ''
  if (!contentType.includes('text/html')) {
    return response
  }

  const rewriter = new HTMLRewriter()
    .on('[data-offer-slot="eyebrow"]', textReplacer(offer.eyebrow))
    .on('[data-offer-slot="h1"]', textReplacer(offer.h1))
    .on('[data-offer-slot="h1-sub"]', textReplacer(offer.h1Sub))
    .on('[data-offer-slot="lede"]', textReplacer(offer.lede))
    .on('[data-offer-slot="cta"]', textReplacer(offer.ctaText))

  const rewritten = rewriter.transform(response)

  // Cache-Control: private — варианты hero не должны кешироваться
  // на CDN-уровне как общий ресурс.
  const newHeaders = new Headers(rewritten.headers)
  newHeaders.set('Cache-Control', 'private, no-store')
  newHeaders.set('Vary', 'Accept-Encoding')
  newHeaders.set('X-Offer-Variant', utmOffer)

  return new Response(rewritten.body, {
    status: rewritten.status,
    statusText: rewritten.statusText,
    headers: newHeaders,
  })
}

function textReplacer(text: string) {
  return {
    element(el: Element) {
      // html: false — escape'ит спецсимволы, безопасно для XSS.
      el.setInnerContent(text, { html: false })
    },
  }
}

Что важно в этом коде:

return context.next() пять раз в начале — это early return для всех случаев, когда подмена не нужна. Главное правило edge-функций: не работать там, где не надо. Любая лишняя обработка добавляется к TTFB.

HTMLRewriter — потоковый парсер. Он не загружает весь HTML в память, а проходит токен за токеном. Для большого SSG-HTML (у нас ~70 КБ) это означает что first-byte отдаётся почти сразу после первого совпадения селектора, а не после полного парсинга. Замер на нашем сайте: +3–5 мс к TTFB на edge, незаметно.

setInnerContent(text, { html: false }) — безопасный режим. HTMLRewriter автоматически escape’ит <, >, &, " если передан { html: false }. Это критично — данные приходят из URL-параметра, доверять им нельзя. Если кто-то откроет ?utm_offer=<script>...</script>, мой isOfferKey отфильтрует это раньше, но defence-in-depth никогда не лишний.

Cache-Control: private, no-store — для подменённых вариантов. Без этого CF-edge закеширует первый вариант с utm_offer=ai-agents и начнёт отдавать его всем посетителям того же edge-узла. SSG-дефолт остаётся public, cacheable.

Импорт from '../src/data/offers' — Cloudflare Pages при сборке функций умеет бандлить TypeScript-зависимости из соседних каталогов. Это позволяет иметь один источник истины для офферов: и React, и edge читают из одного файла. Альтернатива — дублировать данные в functions/_lib/, что мы пробовали и отбросили из-за рассинхрона.

Источник истины: мапа офферов

// src/data/offers.ts
export type OfferKey =
  | 'ai-employees'
  | 'ai-agents'
  | 'strat-session'
  | 'analytics'
  | 'automation'
  | 'ai-crm'

export type Offer = {
  eyebrow: string
  h1: string
  h1Sub: string
  lede: string
  ctaText: string
}

export const DEFAULT_OFFER: Offer = {
  eyebrow: 'Для собственников · выручка от 50 млн ₽',
  h1: 'ИИ-сотрудники для роста маржи и масштабирования.',
  h1Sub: 'AI-архитектура с KPI на P&L. Инжиниринг, не автоматизация.',
  lede: '…',
  ctaText: 'AI-диагностика (30 мин, бесплатно)',
}

export const OFFERS: Record<OfferKey, Offer> = {
  'ai-agents': {
    eyebrow: 'Для собственников · выручка от 50 млн ₽',
    h1: 'Разработаем AI-агентов под задачи вашего бизнеса.',
    h1Sub: 'От бота-обработчика до автономного аналитика. С KPI на P&L.',
    lede: '…',
    ctaText: 'AI-диагностика (30 мин, бесплатно)',
  },
  // … остальные 5 офферов
}

export function isOfferKey(value: unknown): value is OfferKey {
  return typeof value === 'string' && value in OFFERS
}

export function resolveOffer(utmOffer: string | null | undefined): Offer {
  if (utmOffer && isOfferKey(utmOffer)) {
    return OFFERS[utmOffer]
  }
  return DEFAULT_OFFER
}

Один файл, типизированный мап, два разрешителя (тип-guard isOfferKey для edge и сам resolver resolveOffer для React). Когда нужно добавить новый оффер — правится только этот файл, перебилд автоматический.

Двойная защита: client-side как fallback

Edge-функция может не сработать в трёх случаях:

  1. Локальная разработка через pnpm dev — Vite не запускает CF Pages Functions.

  2. Cloudflare Workers перегружен (редко, но бывает).

  3. Тестовая ветка задеплоилась без functions/.

Чтобы вариант оффера всё равно загрузился, React-компонент тоже читает utm_offer через useSearchParams и подставляет правильный hero на клиенте. Это idempotent — если edge уже переписал HTML, React видит ту же строку в DOM, виртуальный DOM не отличается, патч не применяется. Если не переписал — React делает работу при гидратации.

Это особенно важно для preview-deploy’ев и для случаев когда пользователь шарит URL https://site.com/?utm_offer=ai-agents друзьям без proxy — friendly URL открывается с правильным контентом везде.

Что я узнал в проде

HTMLRewriter не работает с мульти-нодными слотами. Если у вас в DOM <div data-offer-slot="x"><span>часть1</span> <em>часть2</em></div>setInnerContent затрёт и span, и em. Решение: ставить слот на лист дерева (текстовый узел), декоративные сиблинги выносить за пределы слота.

Cache-Control: private важен. Без него CF-edge закеширует первый вариант. Я этот шаг забыл в первой версии — два дня все юзеры с edge-узла Frankfurt видели ?utm_offer=strat-session независимо от UTM. Тестируйте подмену с разных IP и разных географий.

vite-react-ssg использует data-router (react-router-dom v6.4+). Это интересный side-effect: data-router глобально перехватывает клики на <a href="/..."> внутри <RouterProvider>. Если у вас в public/ лежит статичная страница вне SPA-routes — клик на ссылку к ней приводит к 404 от React, а не к навигации браузера. Лечится onClick={(e) => { e.preventDefault(); window.location.assign(href + '/') }} для статичных путей.

Bundle cache на JS-файлы. Cloudflare ставит Cache-Control: public, max-age=31536000, immutable на /assets/*.js. Это правильно (hash в имени файла гарантирует cache-bust при изменении), но если вы тестируете через DevTools с включённым кэшем — старый JS-бандл может не подхватить новую версию сразу после деплоя. Hard refresh обязателен.

Edge-цена. Cloudflare Pages даёт 100k бесплатных function-invocations/день. Подмена hero — это 1 invocation на каждый запрос корня сайта. На нашем трафике (несколько тысяч в день) — далеко от лимита. Если у вас миллионы PV — может быть смысл смотреть на Cloudflare Workers Paid tier ($5/мес за 10M invocations).

Когда схема не подходит

Если контент сильно структурный. HTMLRewriter работает на уровне текста внутри элементов. Если вам нужно подменить целые блоки (другая структура секций, другие компоненты) — это не HTMLRewriter, это R/SSR.

Если SEO критично для каждой UTM-вариации. Подменённый контент HMRR-фильтруется через Cache-Control: private — Яндекс/Google его не видят. Они видят только дефолтный SSG. Если вам нужно ранжироваться по UTM-вариациям (что нелогично, но бывает) — нужно делать настоящие отдельные URL’ы.

Если у вас не Cloudflare. Vercel Edge Functions поддерживают похожий API (new HTMLRewriter()), но API чуть отличается. Netlify Edge Functions работают через Deno и HTMLRewriter доступен через polyfill. AWS CloudFront Functions — нет нативного HTMLRewriter, надо писать своё. Самый чистый стек — именно Cloudflare Pages.

Что дальше

Сейчас у нас 6 вариантов hero под 12 групп объявлений (некоторые группы делят оффер). Чтобы убедиться что схема даёт прирост конверсии — нужно: 1) измерить базовую конверсию на дефолте, 2) измерить на каждой UTM-вариации, 3) сравнить с A/B-контролем где половина трафика по той же UTM получает дефолтный hero. Этот эксперимент в планах, отпишу результаты отдельным постом через 4–6 недель когда соберём статистическую значимость.

Также — голосовые версии офферов через текст-в-голос на edge для тех, кто ходит на сайт с iPhone в наушниках. Это уже next-next-step.

Если кто-то делает похожую edge-подмену — интересно почитать в комментариях про другие подходы. Особенно — про работу с динамическими блоками (карточки, картинки), потому что у HTMLRewriter тут много нюансов.

Код в одном репозитории, без секретов, можно адаптировать под свой проект: я не выкладываю весь репо целиком (там много кастомного), но кусок про подмену — это ровно те 200 строк что приведены выше. Лицензия MIT, можете брать и форкать.

Если кто-то делает похожую edge-подмену — интересно почитать в комментариях про другие подходы. Особенно про работу с динамическими блоками (карточки, картинки) — у HTMLRewriter тут много нюансов.

Код целиком в публичном репозитории — минимальный самодостаточный пример: middleware с HTMLRewriter, мапа офферов, React-компонент с data-offer-slot, статичный HTML без React, README с граблями из прода. Лицензия MIT, берите и форкайте.

Production-проверено на dnai.engineering — открой ?utm_offer=ai-agents, ?utm_offer=strat-session, ?utm_offer=ai-employees и увидишь подмену вживую (или curl покажет три разных h1 по одному и тому же URL — это именно то, что нужно для платного трафика).