Під час публікації посилання на веб-застосунок у месенджерах або соціальних мережах (Telegram, WhatsApp, Discord, Twitter/X, LinkedIn, Slack, VK) користувачі очікують побачити інформативний сніпет: заголовок статті, короткий опис та банер високої роздільної здатності. За формування цих даних відповідає протокол OpenGraph (OG).
Проте в класичній архітектурі Single Page Application (SPA) виникає непереборний бар'єр: соціальні боти принципово не виконують JavaScript. На відміну від Googlebot, боти месенджерів функціонують як примітивні HTTP-клієнти на базі cURL/libcurl. Їхнє завдання — виконати швидкий GET-запит із жорстким таймаутом (зазвичай 1–2 секунди), спарсити початковий фрагмент HTML (перші 50–100 КБ), витягти теги <meta property="og:..."> та розірвати з'єднання.
Якщо застосунок є чистим CSR SPA, бот отримує відповідь:
<div id="root"></div>
<script src="/bundle.js"></script>У результаті формується так званий «сірий сніпет»: порожня картка з дефолтним заголовком index.html або взагалі відмова від показу прев'ю.
Для вирішення цієї проблеми у високонавантажених системах застосовується архітектурний патерн Dynamic Rendering / Bot Proxying (динамічний рендеринг та проксіювання ботів).
+-------------------------------------------------------------------------+
| АРХІТЕКТУРА ДИНАМІЧНОГО ПРОКСІЮВАННЯ БОТІВ |
+-------------------------------------------------------------------------+
[ Вхідний HTTP GET Запит ]
|
v
+-------------------------------+
| Прикордонний шлюз (Edge/Nginx)|
| Аналіз заголовка User-Agent |
+-------------------------------+
|
+------------------+------------------+
| |
[ Звичайний Браузер ] [ Соціальний Бот ]
(Chrome, Safari, Firefox) (TelegramBot, TwitterBot, etc.)
| |
v v
+-------------------+ +-------------------+
| Статичний CDN | | Edge Worker / |
| Origin Storage | | Meta Prerender |
+-------------------+ +-------------------+
| |
Віддача index.html Запит даних в API ->
+ React JS Bundle Генерація легкого HTML
| з усіма <meta og:...>
v |
Клієнтський рендеринг v
у браузері користувача Віддача 200 OK + теги
(Vary: User-Agent)У схемі динамічного рендерингу прикордонний рівень (Edge Worker на Cloudflare/Fastly або Nginx на балансувальнику) виступає в ролі маршрутизатора трафіку:
Детекція краулерів за регулярним виразом User-Agent:
Система перевіряє заголовок User-Agent. Список сигнатур включає:
telegrambot (Telegram)twitterbot (X / Twitter)facebookexternalhit (Facebook / Messenger)whatsapp (WhatsApp)discordbot (Discord)slackbot (Slack)vkshare (VK)linkedinbot (LinkedIn)Легковажний Meta-SSR замість повного рендерингу:
Запускати повноцінний headless Chromium (Puppeteer/Playwright) на кожен клік за посиланням у Telegram архітектурно неефективно: затримка складе 1.5–3 секунди, а сервер вичерпає ліміти CPU. Для соціальних ботів не потрібен фінальний HTML усього сайту. Достатньо повернути ультралегкий HTML-документ розміром 2 КБ, що містить лише теги <title>, <meta property="og:..."> та <meta name="twitter:...">.
Заголовок відповіді Vary: User-Agent та захист від Cache Poisoning:
Це критичний елемент інфраструктури. Без заголовка Vary: User-Agent проміжний CDN-кеш може зберегти згенеровану для бота легковажну HTML-відповідь і віддати її звичайному відвідувачу (користувач побачить білий екран без React-застосунку). Або, навпаки, закешувати порожній каркас SPA і віддати його боту.
Реалізуємо рішення на двох рівнях інфраструктури: конфігурація Nginx для власного хостингу та serverless-воркер Cloudflare Workers для розподіленого кордону мережі (Edge).
Використовуємо директиву map для зіставлення регулярних виразів User-Agent та перенаправлення ботів на бекенд-сервіс метаданих:
# Визначення соціальних краулерів та пошукових ботів без JS
map $http_user_agent $is_social_bot {
default 0;
~*(telegrambot|twitterbot|facebookexternalhit|whatsapp|discordbot|slackbot|vkshare|linkedinbot|applebot) 1;
}
server {
listen 443 ssl http2;
server_name agenticlab.dev;
# SSL сертифікати та базові налаштування...
root /var/www/agenticlab/spa/dist;
index index.html;
location / {
# КРИТИЧНО ВАЖЛИВО: сповіщаємо CDN і браузери про варіативність відповіді за User-Agent
add_header Vary "User-Agent" always;
# Якщо запит прийшов від бота — проксіюємо на Node.js сервіс метаданих
if ($is_social_bot = 1) {
proxy_pass http://127.0.0.1:3001;
break;
}
# Звичайним користувачам віддаємо статичний SPA з підтримкою HTML5 History API
try_files $uri $uri/ /index.html;
}
# Статичні асети віддаються безпосередньо без проксіювання
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
}Cloudflare Worker перехоплює запит на глобальному кордоні (Edge) з нульовим навантаженням на основний origin-сервер:
interface Env {
ASSETS: { fetch: typeof fetch };
API_BASE_URL: string;
}
// Регулярний вираз для виявлення краулерів
const BOT_UA_REGEX =
/telegrambot|twitterbot|facebookexternalhit|whatsapp|discordbot|slackbot|vkshare|linkedinbot|applebot/i;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userAgent = request.headers.get('User-Agent') || '';
const url = new URL(request.url);
// Ігноруємо запити до статичних асетів
const isStaticAsset = /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$/i.test(url.pathname);
// Якщо це не бот або запит до асету — віддаємо оригінальний SPA-бандл
if (isStaticAsset || !BOT_UA_REGEX.test(userAgent)) {
const response = await env.ASSETS.fetch(request);
const newHeaders = new Headers(response.headers);
newHeaders.append('Vary', 'User-Agent');
return new Response(response.body, {
status: response.status,
headers: newHeaders,
});
}
// Обробка запиту бота: витягуємо метадані через API
return await handleBotRequest(url, env);
},
};
async function handleBotRequest(url: URL, env: Env): Promise<Response> {
const pathParts = url.pathname.split('/').filter(Boolean);
let title = 'Fullstack Agentic Lab — Освітня платформа';
let description = 'Поглиблені курси з веб-розробки та системної архітектури.';
let ogImage = 'https://agenticlab.dev/assets/default-og.png';
try {
// Приклад: парсинг динамічного роуту /courses/:slug
if (pathParts[0] === 'courses' && pathParts[1]) {
const slug = pathParts[1];
const apiRes = await fetch(`${env.API_BASE_URL}/api/v1/courses/${slug}`);
if (apiRes.ok) {
const data = await apiRes.json() as { title: string; summary: string; coverUrl?: string };
title = `${data.title} | Fullstack Agentic Lab`;
description = data.summary;
if (data.coverUrl) {
ogImage = data.coverUrl;
}
}
}
} catch (err: unknown) {
console.error('[Bot Proxy Error]:', err instanceof Error ? err.message : 'Unknown');
}
// Генерація ультралегкого HTML-шелу виключно для бота
const metaHtml = `<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeHtml(description)}">
<!-- OpenGraph -->
<meta property="og:type" content="website">
<meta property="og:url" content="${url.href}">
<meta property="og:title" content="${escapeHtml(title)}">
<meta property="og:description" content="${escapeHtml(description)}">
<meta property="og:image" content="${ogImage}">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${escapeHtml(title)}">
<meta name="twitter:description" content="${escapeHtml(description)}">
<meta name="twitter:image" content="${ogImage}">
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(description)}</p>
</body>
</html>`;
return new Response(metaHtml, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Vary': 'User-Agent',
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
},
});
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}Якщо зворотний проксі або CDN кешує відповіді без урахування заголовка User-Agent, перший запит від Telegram-бота згенерує легковажний HTML зі сніпетом і осяде в кеші CDN. Наступний живий користувач, який відкрив сайт через Google Chrome, отримає з кешу текстовий HTML для бота без скриптів React, побачивши зламану сторінку.
Рішення: На всіх рівнях маршрутизації при віддачі диференційованого контенту ОБОВ'ЯЗКОВО повертайте заголовок Vary: User-Agent.
Спроба піднімати безголовий браузер Chromium на кожен вхідний запит від соціальних ботів призводить до вичерпання оперативної пам'яті та падіння сервера під час хвилі репостів у великих публічних Telegram-каналах.
Рішення: Для соціальних ботів генеруйте лише статичний HTML-скелет із метатегами (Meta Shell) на базі швидких REST-запитів до API бази даних із часом відповіді до 50 мс.
Специфікація OpenGraph суворо вимагає передачі абсолютних URL із протоколом: https://example.com/images/card.png. Вказівка відносного шляху <meta property="og:image" content="/images/card.png" /> призведе до того, що парсери Telegram і Twitter не зможуть вирішити адресу картинки і покажуть сніпет без ілюстрації.
Рішення: Завжди формуйте абсолютні посилання з використанням базового домену застосунку.
Сервер віддає коректний тег og:image, але CDN налаштований на блокування запитів із зовнішніми Referer (захист від хотлінкінгу). TelegramBot або Twitterbot запитують зображення зі своїх серверів без браузерного Referer і отримують HTTP 403 Forbidden.
Рішення: Перевірте правила WAF та CDN: ендпоінти OpenGraph-картинок мають віддавати статус 200 OK для будь-яких вхідних запитів.
Cloudflare Worker або Nginx аналізує вхідний заголовок User-Agent перед зверненням до основного сервера.
Зіставлення зі списком ботів без JS. Запит визнається запитом соціального краулера.
Легковажний воркер запитує тільки сутність документа з бази (5-10 мс) замість завантаження мегабайтів JS-бандла.
Формування мінімального HTML із <title>, <meta property='og:...'> та заголовком Vary: User-Agent.