Как мы строили pashavin.ru: от формы до автогенерации проектов

Когда делаешь персональный сайт для эксперта, хочется чтобы он не просто красиво выглядел, но и работал как полноценный инструмент: собирал заявки, демонстрировал проекты и обновлялся без лишней ручной работы. Именно это мы и строили на pashavin.ru — итерация за итерацией, от базовой формы обратной связи до автоматической генерации маркетинговых описаний через Claude API.
Эта статья о том, как эволюционировал проект: какие проблемы возникали по пути, какие технические решения мы выбирали и почему.
Форма обратной связи с Telegram-интеграцией
Самый очевидный элемент любого сайта-визитки — форма "Связаться". Казалось бы, что тут сложного? Но дьявол в деталях.
Первоначально форма содержала всего три поля: имя, телефон и тему обращения. Этого было категорически мало. Люди хотят объяснить контекст, дать удобный для них способ связи, написать детали запроса. Поэтому мы расширили форму до шести полей:
// ContactForm — расширенная версия
<Input name="name" placeholder="Ваше имя" required />
<Input name="phone" placeholder="+7 (999) 000-00-00" required />
<Input name="email" type="email" placeholder="email@example.com" />
<Input name="telegram" placeholder="@username" />
<Select name="topic">...</Select>
<Textarea name="message" placeholder="Расскажите подробнее о вашем запросе..." rows={4} />Email и Telegram — необязательные, человек сам выбирает как с ним удобнее связаться. Textarea для развёрнутого сообщения — необязательна, но даёт возможность сразу дать контекст.
Стек: Next.js App Router + shadcn/ui для компонентов. Отдельно пришлось создать компонент Textarea — его не было в проекте. Добавили через shadcn CLI, и он сразу вписался в дизайн-систему.
API route /api/contact обновили так, что новые поля отображаются в Telegram-сообщении только если они заполнены — никаких пустых строк в уведомлениях:
// api/contact/route.ts
const lines = [
`👤 ${name}`,
`📞 ${phone}`,
email ? `📧 ${email}` : null,
telegram ? `💬 ${telegram}` : null,
`📌 Тема: ${topic}`,
message ? `\n💬 ${message}` : null,
].filter(Boolean).join('\n');Деплой через Dokploy — переменные окружения TELEGRAM_BOT_TOKEN и TELEGRAM_CHAT_ID хранятся там же, не в репозитории. Пуш в GitHub → webhook → автодеплой. Всё просто и надёжно.
Одновременно с формой обновили блок "Обо мне". Добавили раздел "Экспертные темы" с шестью направлениями, которые уже были в personal.ts — просто не отображались на странице. Фото получило aspect-ratio 9/16 вместо 3/4 — вертикальный портрет смотрится органичнее в этом блоке.
Внутренние страницы проектов
Следующая большая задача — секция "Разработки с помощью ИИ". Изначально каждый проект вёл на внешнюю ссылку. Это плохо по нескольким причинам: пользователь уходит с сайта, нет контроля над контентом, нет SEO-ценности от страниц проектов.
Решение — внутренние страницы-кейсы по паттерну /projects/[slug]. Маршрут уже существовал, но использовал простую карточку: скриншот + описание + highlights. Нужен был другой уровень — лендинг-кейс в брутальной типографике с marquee-баннером, детальным описанием, стеком технологий и результатами.
Архитектура данных выглядит так:
// types/project.ts
interface Project {
slug: string;
title: string;
shortDescription: string;
fullDescription: string;
stack: string[];
highlights: string[];
results?: string[];
externalUrl?: string;
githubUrl?: string;
}Страница /projects/[slug] рендерится статически через generateStaticParams — никаких лишних запросов к базе данных, всё на этапе билда. Это важно для производительности и SEO.
Автоматическое подтягивание проектов с GitHub
Когда проектов становится много, поддерживать захардкоженный список вручную — боль. Создал новый проект arbscanner, запушил на GitHub, жду его на сайте... а его нет. Потому что кто-то забыл добавить в projects.ts.
Эту проблему мы решили кардинально: написали скрипт scripts/generate-projects.ts, который:
- Запрашивает список репозиториев через
gh repo list - Для 13 известных проектов берёт вручную написанные маркетинговые описания
- Для новых репозиториев автоматически создаёт запись
- Пропускает утилитарные репы (
project-docs,tasksи подобные)
const SKIP_REPOS = [
'project-docs', 'tasks', 'supabase-selfhosted',
'ai-aggregator', 'v0-prajs-yandeks'
];
const repos = await getGithubRepos();
const projects = repos
.filter(r => !SKIP_REPOS.includes(r.name))
.map(r => KNOWN_PROJECTS[r.name] ?? generateFromRepo(r));Скрипт запускается при деплое (postinstall или отдельный npm script), генерирует projects-generated.ts и коммитит в репозиторий. Новый реп на GitHub → следующий деплой → проект появляется на сайте автоматически.
Результат первого запуска: 20 проектов, arbscanner наверху с правильной датой создания репозитория.
AI-генерация маркетинговых описаний
Автоподтягивание проектов решило проблему с синхронизацией, но новые репозитории получали сухие технические названия вместо маркетинговых описаний. Это не работает для портфолио.
Решение — few-shot генерация через Claude API. Идея простая: у нас есть 13 отлично написанных проектов. Скармливаем их как примеры, добавляем код нового проекта, просим сгенерировать в том же стиле.
// scripts/generate-projects.ts
async function generateMarketingDescription(repo: Repo): Promise<ProjectData> {
const repoFiles = await readLocalFiles(repo.name);
const prompt = `
Вот 13 примеров маркетинговых описаний проектов:
${KNOWN_PROJECTS_AS_EXAMPLES}
Теперь создай описание для нового проекта.
Название репозитория: ${repo.name}
Описание на GitHub: ${repo.description}
Файлы проекта:
${repoFiles}
Создай title, shortDescription, stack, highlights в том же стиле.
`;
const response = await anthropic.messages.create({
model: 'claude-opus-4-5',
max_tokens: 1024,
messages: [{ role: 'user', content: prompt }]
});
return parseResponse(response);
}Скрипт читает локальные файлы репозитория — README, package.json, основные исходники — и передаёт их как контекст. Результат кэшируется: если описание уже сгенерировано, повторного запроса к API нет.
Из 7 новых проектов 5 получили качественные маркетинговые описания. Два (v0-prajs-yandeks, v0-keyword-analysis-plan) — мелкие v0-эксперименты без кода и описания, их добавили в SKIP_REPOS.
Pagination тоже переделали: вместо показа всех проектов сразу — первые 6, кнопка "Загрузить ещё" добавляет ещё по 6. Это стандартный паттерн для портфолио, снижает первоначальную нагрузку на рендер и даёт пользователю контроль.
// components/projects-table.tsx
const [visibleCount, setVisibleCount] = useState(6);
const visibleProjects = projects.slice(0, visibleCount);
<ProjectsGrid projects={visibleProjects} />
{visibleCount < projects.length && (
<Button onClick={() => setVisibleCount(c => c + 6)}>
Загрузить ещё
</Button>
)}Парсер конференций: дорого и грязно
Отдельный проект в экосистеме — parserevent.pashavin.ru, автоматический коллектор спикеров и спонсоров конференций. Идея хорошая: обходишь сайты конференций, вытаскиваешь контакты, получаешь базу для аутрича. На практике — две серьёзные проблемы.
Проблема 1: мусор в данных. Парсер собирал не только имена спикеров, но и тексты кнопок, заголовки секций, навигационные элементы. "Купить билет", "Открыть регистрацию", "Скачать программу" — всё это попадало в базу как "спикеры".
Корень проблемы — широкие CSS-селекторы. Когда нет .speaker-card, скрипт искал по section, div рекурсивно, то есть фактически по всему DOM. Регулярка NAME_RE (2-4 слова с заглавных букв) пропускала весь этот мусор.
Проблема 2: стоимость AI. Пайплайн работал по принципу AI-first: сначала отправляем всю страницу в Claude, потом эвристики как fallback. При объёме 30K токенов на страницу и 5 страницах на конференцию — это дорого.
Решение для мусора — расширить фильтры:
# conferences.py
SPEAKER_NOISE_EXACT = {
# было 10 слов, стало 33
'купить', 'скачать', 'войти', 'зарегистрироваться',
'забронировать', 'подписаться', 'открыть', 'закрыть',
'программа', 'расписание', 'билеты', 'партнёры',
# ... и ещё 21 термин
}
SPEAKER_NOISE_PARTS = [
# было 7 паттернов, стало 17
'купить', 'скачать', 'войти', 'забронировать',
'register', 'buy ticket', 'download', 'subscribe',
# ...
]
MIN_SPEAKER_NAME_LENGTH = 5 # новое ограничениеДля спонсоров — ещё жёстче: img[alt] берётся только из секций с заголовком "спонсор" или "партнер", а не из всего DOM. Это убрало логотипы из шапки, футера и декоративных блоков.
Решение для стоимости — инвертировать пайплайн:
# worker.py — новая логика
def process_conference(url: str) -> ConferenceData:
# 1. Сначала эвристики
data = heuristic_extraction(url)
# 2. AI только если мало данных
if len(data.speakers) < 3:
data = ai_refine(data, url, max_tokens=10_000, max_pages=2)
return dataAI-first → эвристика-first с AI как опциональным refinement. Лимит контекста сократили с 30K до 10K токенов, страниц с 5 до 2. По нашим оценкам это снижает стоимость AI-вызовов в 5-10 раз при сохранении качества для большинства сайтов конференций.
Все 32 теста прошли после рефакторинга — старые тесты на AI-first pipeline переписали под новую логику с eвристикой как основным методом.
Выводы и уроки
Главный урок всей этой работы — итеративность важнее идеального первого решения. Форма начиналась с трёх полей, страницы проектов — с внешних ссылок, генерация описаний — с ручного обновления файлов. Каждый шаг был рабочим, каждый следующий шаг делал систему лучше.
Автоматизация там, где есть повторение. Ручное обновление projects.ts при каждом новом репозитории — это технический долг, который рос незаметно. Когда боль стала ощутимой (новый проект не появился на сайте), мы решили её системно: скрипт + GitHub API + AI-генерация. Теперь новый репозиторий появляется на сайте при следующем деплое автоматически.
AI — это инструмент, а не архитектура. Парсер конференций работал по принципу "всё через AI" и платил за это высокую цену — и деньгами, и качеством данных. Правильный подход: детерминированная логика как основа, AI как улучшение там, где детерминированная логика недостаточна. Эвристики дают предсказуемость и скорость, AI добавляет гибкость для edge cases.
Few-shot генерация работает отлично для стиля. Когда у вас есть 13 качественных примеров в нужном тоне и структуре, Claude воспроизводит этот стиль очень точно. Не нужно долго объяснять что такое "маркетинговое описание" — просто показать примеры. Это мощный паттерн для любых задач генерации контента с конкретными требованиями к форме.
Деплой должен быть скучным. GitHub push → webhook → Dokploy → готово. Никаких ручных действий, никаких SSH-сессий для обновления кода. Когда деплой автоматизирован, порог для внесения изменений снижается — ты делаешь небольшие итерации чаще, а не накапливаешь большой батч изменений из страха перед деплоем.
Стек проекта: Next.js 14 с App Router, TypeScript, shadcn/ui для компонентов, Telegram Bot API для уведомлений, Anthropic Claude API для генерации описаний, деплой через Dokploy. Всё это работает в связке предсказуемо и надёжно.

AI-инженер, предприниматель, маркетолог. Основатель feberra.com и x10seo.ru. 13 лет в перфоманс-маркетинге, 3 года в системной интеграции AI в бизнес.