Тестовое задание
Polza Agency — Технический специалист / вайбкодер
Выгрузка → PostgreSQL
Из data_pack.zip (page_001.json … page_020.json, ~1000 компаний) все записи загружены в PostgreSQL. Реализована дедупликация, проставлены индексы.
- Скрипт загрузки: `scripts/load_data.ts` — читает JSON, дедуплицирует по названию, загружает в БД
- Схема: таблица `companies` с полями: id, name, category, city, address, rating, reviews_count, site, phone, created_at
- Индексы: на name (для поиска), city (для фильтрации), category (для группировок)
- Docker Compose: `docker-compose.yml` — PostgreSQL + приложение одной командой
- SQL-запросы в блоке ниже
Страница /companies
Next.js App Router страница с серверным рендерингом. Таблица компаний из PostgreSQL с поиском по названию и фильтром по городу.
- Server Component: `src/app/companies/page.tsx` — фетчит данные и города напрямую из БД
- Client Component: `CompaniesClient.tsx` — интерактивная таблица с поиском и фильтрацией
- Поиск через URL-параметры (?q=&city=) — работают навигация, рефреши и расшаривание результатов
- Адаптивный дизайн: колонки скрываются на мобильных устройствах
Данные с сюрпризом (review.csv)
Анализ review.csv на аномалии. Все несоответствия задокументированы в ANOMALIES.md — 31 инцидент.
- Найдены: невалидные рейтинги, битая кодировка (mojibake), дубликаты ID, сдвиг колонок, опечатки
- Полный отчёт с таблицами и цветовой кодировкой по severity
Вайбкод / LLM-стек
Процесс разработки, инструменты, методология и архитектурные решения.
- IDE: Claude Code + Cursor, агентские сценарии на живом репозитории
- Модели: Claude Sonnet 4.5 / DeepSeek V4 Flash — под задачу
- Подход: AI генерирует код, я проверяю каждый шаг, тестирую и коммичу
- Все изменения в git с осмысленными сообщениями, каждая фича в своей ветке
SEO-оптимизация
Полный набор SEO-базовых мета-тегов, структурированных данных и файлов для индексации.
- Open Graph / Twitter Cards на всех страницах с og:image 1200×630
- JSON-LD структурированные данные (Organization + BreadcrumbList)
- robots.txt + sitemap.xml + уникальные canonical URL
- Уникальные title/description для каждой страницы (главная, /companies, /privacy, /docs, /anomalies)
- favicon.svg + lang="ru"
Архитектурные решения
Почему Next.js + TypeScript, а не Python?
В ТЗ не было жёсткого требования по языку для ETL-скрипта, но вся платформа Polza Agency построена на Next.js + TypeScript + PostgreSQL. Выбор TypeScript для скрипта загрузки данных и всего приложения — это осознанное архитектурное решение:
- Единый стек — скрипты загрузки, сайт и API пишутся на одном языке. Не нужно переключать контекст между Python и TypeScript.
- Типизация — TypeScript даёт статическую проверку типов на всём протяжении: от парсинга JSON до UI-компонентов.
- Server Components — данные фетчатся напрямую в серверном компоненте, без отдельного API-слоя. Меньше кода, быстрее работа.
- Docker Compose — и БД, и приложение поднимаются одной командой. Не нужно ставить Python, Node.js или настраивать окружение вручную.
- Единый CI/CD — один набор зависимостей для всего проекта: npm install, npm run build, npm start.
Прочие важные моменты
- Безопасность — .env в .gitignore, секреты не попадают в репозиторий. Промпт-инъекции и SQL-инъекции проверены (не найдены).
- Воспроизводимость — docker-compose.yml поднимает PostgreSQL, скрипт load_data.ts загружает данные. README с командой запуска.
- Дедупликация — компании сопоставляются по названию, дубликаты исключаются на этапе загрузки.
- ETL-обработка аномалий — автоматическое выявление и исправление битой кодировки, нормализация городов, приведение типов.
SQL-запросы
1. Топ-5 категорий по числу компаний
SELECT category, COUNT(*) AS count FROM companies GROUP BY category ORDER BY count DESC LIMIT 5;
2. Средний рейтинг по городам (компании с 10+ отзывами)
SELECT city, AVG(rating) AS avg_rating FROM companies WHERE reviews_count >= 10 GROUP BY city ORDER BY avg_rating DESC;
3. Доля компаний с сайтом по категориям
SELECT
category,
COUNT(*) AS total,
COUNT(site) FILTER (WHERE site IS NOT NULL) AS with_site,
ROUND(
100.0 * COUNT(site) FILTER (WHERE site IS NOT NULL) / COUNT(*),
1
) AS pct_with_site
FROM companies
GROUP BY category
ORDER BY pct_with_site DESC;