Як ми зробили каталог на 21 000 AI-інструментів — і чому найважчим виявився не код
TL;DR
Ми зібрали український каталог AI-інструментів на 21 696 карток, кожна — з українським описом, локальним контекстом і повною Schema-розміткою. Стек: Next.js 14 (App Router) + FastAPI (async) + PostgreSQL/pgvector + Redis/Celery, Docker на одному сервері Hetzner за Cloudflare. Локалізацію 21k карток зробили через безкоштовний Gemini з ротацією моделей (≈ $0). І попри технічно здорову архітектуру, головним болем виявилась не вона, а авторитет домену. Про це чесно в кінці.
Задача
Ринок AI-інструментів росте швидше, ніж будь-хто встигає його каталогізувати. Українською структурованого каталогу з локальним контекстом (чи підтримує сервіс UA-мову, як з оплатою з України, чи є заміна російському софту) не було. Ми вирішили зробити programmatic SEO у чистому вигляді: одна якісна шаблонна сторінка × тисячі одиниць даних = тисячі індексованих сторінок довгого хвоста.
Звучить просто. Диявол — у масштабі: 21 тисяча сторінок — це вже не «згенеруй HTML», а купа окремих інженерних питань: краул-бюджет, дублікатність, TTFB під ботами, вартість локалізації, свіжість даних.
Стек і чому саме він
- Backend: FastAPI + SQLAlchemy 2.0 (async) + asyncpg, PostgreSQL 16 з pgvector (семантичний пошук по інструментах). Alembic на міграції.
- Черги: Redis 7 + Celery + Celery Beat — уся автоматизація (імпорти, генерація контенту, скриншоти, новини) винесена у фонові задачі.
- Frontend: Next.js 14, App Router, ISR на сторінках інструментів.
- Інфра: Docker Compose на одному сервері Hetzner (8 vCPU / 15 ГБ RAM), за Cloudflare (CDN, кеш, WAF, гео-блок).
- AI: pluggable-провайдер, за замовчуванням — Gemini free-tier з ротацією моделей.
Ключове рішення: один сервер, а не мікросервіси. На нашому масштабі трафіку одна коробка за Cloudflare тримає все з запасом у сотні разів. Мікросервіси тут були б передчасною оптимізацією і зайвим болем на деплої.
Programmatic SEO: як не поховати 21k сторінок у глибині
Найбільша помилка агрегаторів — сторінки на глибині 15+ кліків від головної. Бот туди просто не доходить (краул-бюджет). Наш шлях кожної картки — ≤3 кліки від головної:
Головна → SSR-хаб категорії (/catalog/<slug>) → сторінка інструмента (/tool/<slug>)


Тонкість, на якій ми обпеклися: інтерактивний каталог із фільтрами був "use client" — грід інструментів вантажився в браузері, і в сирому HTML для бота було 0 посилань на картки. Google бачив порожню сторінку. Рішення — окремі SSR-хаби категорій, які рендерять грід на сервері (50 карток/сторінка + краулабельна пагінація), тоді як фасетна навігація ?category=X&price=free лишилась клієнтською й згорнута через rel="canonical", щоб не плодити мільйони сміттєвих комбінацій в індексі.
Плюс щільна перелінковка: «схожі інструменти», «альтернативи», хлібні крихти (BreadcrumbList), хаби-добірки (/best/<slug>, /compare/<slug>, /stacks/<slug>).
ISR (revalidate = 3600) + Redis-кеш на бекенді + Cloudflare спереду тримають TTFB низьким навіть коли бот сканує агресивно. Це критично: якщо під масовим краулінгом сервер починає гальмувати (TTFB >
Локалізація 21 000 карток за ≈ $0
Перекласти й локалізувати 21k карток комерційним API — це відчутні гроші. Ми зробили це безкоштовно через ротацію безкоштовних моделей Gemini.
Ідея проста: у кожної free-tier моделі свій ліміт запитів. Драйвер ходить по списку моделей (gemini-2.5-flash, -2.0-flash, -2.5-flash-lite, ...) і на HTTP 429 перемикається на наступну. Кілька моделей × власні квоти = помножений безкоштовний вихід.
Уся «магія» — буквально в цьому (спрощено з нашого коду):
# батч кидає виняток, щойно API відповів 429 (квота вичерпана)
if resp.status_code == 429:
raise GeminiQuotaExceeded(model)
# ...а ротація просто йде по списку моделей до першої, у якої ще є квота
async def generate_rotate(tools, models, start=0):
for idx in range(start, len(models)):
try:
return await generate_batch(tools, model=models[idx]), idx
except GeminiQuotaExceeded: # 429 на цій моделі
continue # наступна модель = свіжа квота
# Виняток кидається, якщо цикл завершився і жодна модель не спрацювала
raise GeminiQuotaExceeded("усі моделі вичерпані")
Дисципліна, без якої це не працює на масштабі:
- Батчинг — щоб велика сторінка не обрізала відповідь моделі.
- Fallback — впав батч → лишаємо оригінал (AI ніколи не має ламати пайплайн).
- Duplication-safe self-chaining — Celery-задача обробляє чанк, ставить наступний, зупиняється на rate-limit, і не чіпає те, що вже локалізоване.
- Чернетки, а не пряме перезаписування — генерація створює draft, який ревʼюється перед публікацією. Контент-пайплайн не має права мовчки псувати живі дані.
Результат: 100% каталогу має український tagline, description і блок локального контексту. Не «переклад гуглом», а on-brand копія з урахуванням українського контексту.
Sitemap, що поважає власний індекс
Дрібниця, яку 90% агрегаторів роблять неправильно: пхають у sitemap усі URL, зокрема ті, що самі ж закрили в noindex. Google краулить їх, бачить noindex → ловить сигнал «ви самі не знаєте, що індексувати», і марнує краул-бюджет.
У нас endpoint, що живить sitemap, фізично виключає сторінки, які не проходять index-гейт — sitemap завжди збігається з тим, що реально дозволено індексувати. lastmod — з реального updated_at, sitemap динамічний (рендериться на запит, а не печеться на білді), щоб зміни в базі зʼявлялись у ньому одразу.
Чесний провал звідси ж: коли sitemap доріс до ~42k URL (ліміт Google — 50k на файл), ми спробували розбити його на sitemap-index через generateSitemaps() у Next.js. Шарди (/sitemap/<id>.xml) згенерувались, а от сам індекс /sitemap.xml під нашим force-dynamic Next не віддав — 404. Зловили на перевірці прода за 2 хвилини після деплою й відкотили. Урок: generateSitemaps + force-dynamic не дружать; правильний split — це власні
Каталог, який читають не лише люди, а й AI-агенти
Окремий напрям, у який мало хто в UA ще заходив: зробити каталог машинно-читабельним для AI-агентів (ChatGPT, Perplexity, Claude тощо). Це не «SEO майбутнього», це вже працює. Ми підняли повний agent-ready шар:
.well-known/*— API-каталог (RFC 9727), MCP Server Card, agent-skills.- Справжній MCP-сервер (
/api/v1/mcp, JSON-RPC 2.0): агент може викликатиsearch_ai_tools,get_tool,list_categoriesнапряму. - WebMCP через
navigator.modelContext— інструмент прямо зі сторінки. - Content negotiation:
Accept: text/markdown→ чистий markdown для LLM, тоді як браузер і Googlebot отримують звичайний HTML (нульовий вплив на Google). auth.md+ DNS-AID (SVCB-запис) + DNSSEC.
Зовнішній сканер agent-readiness показав нам зростання з 29 до 100/100.
Спостережуваність без чужих дашбордів
Щоб не жити наосліп, вбудували в адмінку власний GA4-дашборд через Data API (service account, read-only): ключові метрики з порівнянням період-до-періоду, живий лічильник «користувачі сьогодні», графік динаміки. Плюс DB-native SEO-аудит (дублі title/meta, сироти, биті внутрішні посилання) — свій маленький Screaming Frog, який має прямий доступ до бази, а не гадає з HTML.

Побічні граблі: Cloudflare блокує дефолтний User-Agent: python-httpx/* як бота — свої ж перевірки посилань спочатку віддавали суцільні 403, поки не поставили браузерний UA.
А тепер чесно: чому все це — ще не трафік
Технічно сайт здоровий. Архітектура, Schema, ISR, sitemap, agent-ready — усе на місці. І це видно по цифрах Google Search Console: за перші тижні молодий домен заіндексував 13,5 тис. сторінок, а покази з пошуку виросли з нуля до ~280 на день. Тобто воно працює.
Але 23 тис. сторінок ще не в індексі. І найцікавіше — коли розкладаєш, чому саме:
- 21 706 — «Виявлено, наразі не проіндексовано» (Discovered — currently not indexed). Google знає про ці URL (зі sitemap), але ще не витратив краул-бюджет, щоб їх завантажити.
- Лише 173 — «Проскановано, наразі не проіндексовано» (Crawled — not indexed). Тобто коли Google реально доходить до сторінки, він майже завжди її індексує.
- Решта (~1,1 тис.) — очікуване й навмисне:
noindex, закрите вrobots.txt, канонічні дублі.


Ось діагноз одним абзацом: проблема не в якості сторінок і не в коді. Якби контент був тонкий, «Crawled — not indexed» рахувався б десятками тисяч — а він 173. Проблема в тому, що Google не доходить до 21 тисячі готових сторінок, бо нормує краул-бюджет за авторитетом домену (у нас DR 0). Ми навіть бачимо це в логах: Applebot викачує в рази більше, ніж Googlebot.
Висновок, який коштував нам часу усвідомити: на масштабному контент-проєкті ідеальна інженерія — це квиток на вхід, а не квиток на трафік. 21 тисяча сторінок стоїть готова в черзі; а що відчиняє чергу — не наступний деплой, а перша сотня якісних посилань.
Чек-лист, якщо ви теж будуєте контентний продукт
- Мало ресурсів → не робіть мікросервіси. Один сервер за Cloudflare тримає контентний проєкт із запасом у сотні разів. Мікросервіси — це передчасна складність, за яку заплатите деплоєм і часом, а не швидкістю.
- Авторитет домену будуйте з першого дня, а не після коду. Це найбільша помилка програміста-продуктовця: спершу «допишу ідеально», потім «займуся посиланнями». Google розблоковує краул за авторитетом — тож перша сотня посилань має початися паралельно з розробкою, а не після неї.
- Sitemap має збігатися з index-гейтом. Не пхайте в нього те, що самі закрили в noindex — це прямий сигнал Google, що ви не розумієте власний сайт.
- AI у пайплайні не має права ламати дані. Батч упав → лишаємо оригінал. Генеруємо чернетки, а не перезаписуємо живе. Ротація/квоти/fallback — обовʼязкові, не «потім».
- Перевіряйте кожен деплой на проді одразу. Наш sitemap-split прожив на продакшені 2 хвилини саме тому, що ми перевірили
/sitemap.xmlвідразу після викату. Костиль на живому SEO-файлі коштує дорожче, ніж відкат.
Стек одним списком
Next.js 14 (App Router, ISR) · FastAPI async · SQLAlchemy 2.0 / asyncpg · PostgreSQL 16 + pgvector · Redis 7 + Celery · Docker Compose · Hetzner · Cloudflare · Gemini free-tier (rotation) · MCP / .well-known · GA4 Data API.
Проєкт, про який мова — AICatalog. Питання по архітектурі — залюбки в коментарях.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЗамість того щоб від себе написати 3 речення про фунціонал сервісу, ви скопіювали з чату цю білібєрду...
Я робив подібне, зібрав інсайти та зрозумів яких інструментів насправді не вистачає.
І яких інструментів бракувало ?
пофіксіть, будь ласка
Дякую, що помітили! Конфлікт із банером під пошуком пофіксив.