Як ми зробили каталог на 21 000 AI-інструментів — і чому найважчим виявився не код

💡 Усі статті, обговорення, новини про AI — в одному місці. Приєднуйтесь до 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 > 0.5–0.8 с), Google сам зупиняє сканування, і сторінки зависають у «Discovered — currently not indexed».

Локалізація 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 — це власні XML-роути + ручний індекс. Винесли в окрему задачу, бо на живому SEO-файлі костилі неприпустимі.

Каталог, який читають не лише люди, а й 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.

власний GA4-дашборд у адмінці

Побічні граблі: 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. Питання по архітектурі — залюбки в коментарях.

👍ПодобаєтьсяСподобалось0
До обраногоВ обраному0
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Замість того щоб від себе написати 3 речення про фунціонал сервісу, ви скопіювали з чату цю білібєрду...

Я робив подібне, зібрав інсайти та зрозумів яких інструментів насправді не вистачає.

І яких інструментів бракувало ?

пофіксіть, будь ласка

Дякую, що помітили! Конфлікт із банером під пошуком пофіксив.

Підписатись на коментарі