Сайт ветклиники likedog: 780 услуг и 28 специалистов
Связь с заказчиком likedog.ru обрывалась несколько раз - пришлось восстанавливать сессию по обрывкам сообщений и логам. Но даже в таких условиях за пару дней я вытянул сайт ветклиники из состояния «контент есть, но он мёртвый» в полноценный SEO-каркас: 28 страниц специалистов и 780 живых позиций прайса. По порядку: что мы сделали, какие развилки были на пути и почему часть «очевидных» оптимизаций я отговорил делать вообще.
Контекст: ветклиника, 365 статей и провал в трафике
likedog.ru — это сайт ветеринарной клиники в Москве (Сивцев Вражек, 18 — это ЦАО, Хамовники, очень дорогой и плотно конкурентный район). На сайте уже был блог: 365 статей про породы собак и кошек, написанных по шаблону a-la «Сибирский хаски — это порода собак, которая была выведена в Сибири». Контент был, индексация была, но трафик после Q1 просел: летела SSL/DNS-миграция на Hetzner, и Яндекс с Google какое-то время просто не могли нормально достучаться до сайта.
Когда мы вернулись к работе, у владельца был заготовлен план: «давай переписывать статьи, делать их человечнее». Я честно сказал, что это даст marginal эффект, не transformational. Главное починилось само — миграция отдала боту HTTP 200, и трафик начнёт восстанавливаться по мере перекраулинга. А вот настоящие точки роста для ветклиники в ЦАО — это коммерческие и локальные страницы: «вызов ветеринара на дом СВАО», «круглосуточный ветеринар», «стерилизация кошки цена». Конверсия в звонок там в разы выше, чем в блоге про хаски.
В итоге решили делать оба фронта параллельно: editorial pass на 365 статей идёт фоном (с почти нулевой ценой через Claude CLI на subscription), а основные силы — на новый раздел /uslugi/.
Editorial pass: 365 статей через Claude CLI с concurrency=8
Первая задача — нужно было прогнать все 365 статей через Claude и получить более живые тексты. Скрипт scripts/edit-articles.ts уже был, но работал последовательно — три статьи в пилоте заняли заметное время. На 365 это было бы вечно.
Добавил пул воркеров. Сначала smoke-test на 2 статьях с concurrency=2: wall-time 218 секунд, CPU-time 384 секунды — параллелизм даёт ~1.76x на двух воркерах. Никаких race conditions, идемпотентность по seo_edited_at отрабатывает. После этого запустил на все 365 с concurrency=8 в фоне.
Хранение оригинала — в колонке full_description_legacy, чтобы можно было откатить любой текст до исходника. Дельта вошла в миграцию 0003_article_seo_edit.sql. К моменту начала работы над /uslugi/ фоновый процесс уже отрапортовал 195/365 готово, из них 191 OK и 4 timeout-fail на самых длинных статьях — их потом перезапустили с флагом --rerun --slug .... Стоимость прогона — буквально $0, потому что Claude CLI ходит по подписке Max, а не по API-токенам.
Разведка конкурентов: два параллельных агента вместо одного
Заказчик прислал ссылку на mobil-veterinar.ru как пример «топового сайта в Яндексе по семантике вызова на дом». Я сделал то, что обычно стоит делать в таких случаях, — диспатчнул двух subagent'ов параллельно вместо того, чтобы прожигать собственный контекст на сырой HTML.
- Агент №1: подробный разбор mobil-veterinar.ru — структура URL, sitemap, перелинковка, FAQ-блоки.
- Агент №2: парсинг органической выдачи Яндекса по ключам «вызов ветеринара на дом», «ветклиника круглосуточно Москва» — топ-3 конкурентов с разбором их структуры.
Получил отчёт по четырём сильным игрокам — svoydoctor.ru (~2600 URL в sitemap, 20 филиалов = 20 локальных лендингов, 661 статья), doctor-vet.ru (174 URL, плотная воронка «дом / 24ч / усыпление»), vetclinika.moscow и сам mobil-veterinar.ru. Главные паттерны: явные посадочные под «вызов на дом», отдельная страница под эвтаназию, локальные лендинги по районам, и — самое важное — реальные цены прямо на странице услуги. Это сильный сигнал и для пользователя (сразу понятно, попадаешь ли в бюджет), и для Яндекса (карточки услуг с микроразметкой Schema.org Service индексируются в товарных колдунщиках).
Реальный прайс: 780 строк, 28 категорий по специалистам
Дальше выяснилось, что у клиники есть свой XLS-прайс на 780 позиций в 28 категориях. И вот тут возникла критичная архитектурная развилка. Существующая БД (service) была организована по 15 «зонтикам» (Хирургия, Дерматология, Кардиология...), а реальный прайс — по 28 специалистам (Терапевт 115 услуг, Хирург 112, Офтальмолог 69, Стоматолог 48, УЗИ 28...). Это две разные сетки, и они не маппятся 1:1.
Выбрали стратегию: не ломать существующий /services/, а сделать новый раздел /uslugi/[specialist-slug]/ параллельно. Старые страницы остаются (живые ссылки, индексация, перелинковка), новые — закрывают семантику по специалистам и подуслугам с реальными ценами.
-- 0004_pricing_items.sql
CREATE TABLE pricing_item (
id BIGSERIAL PRIMARY KEY,
specialist_id BIGINT REFERENCES specialist(id),
name TEXT NOT NULL,
price_rub INT NOT NULL,
unit TEXT,
notes TEXT,
source_row INT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX pricing_item_specialist_idx ON pricing_item(specialist_id);Импорт XLS — через библиотеку xlsx (она же SheetJS), один раз пробежался по листам, разнёс по specialist + pricing_item, и закоммитил в репо.
Реконсилиация цен: не трогаем рутину, поднимаем хирургию
Прайс был от июля 2024, и сравнение с медианой рынка ЦАО 2026 года показало интересную картину:
| Услуга | likedog 2024 | Рынок ЦАО 2026 | Разрыв |
|---|---|---|---|
| Стерилизация кошки | ~3 500 ₽ | 7 500 ₽ | ×2 |
| Кастрация кота | ~2 000 ₽ | 4 000 ₽ | ×2 |
| Стерилизация суки 10–25 кг | ~5 000–6 000 ₽ | 13 000 ₽ | ×2 |
| Первичный приём терапевта | ~900 ₽ | 1 800 ₽ | ×2 |
Искушение «обновим всё» здесь сильное, но я отговорил. Если поднять цены на рутину (приёмы, вакцинация) — клиника потеряет позиционирование «доступная клиника в центре», а это часть её SEO-бренда. Поэтому выбрали whitelist-подход: обновляются только хирургия и эвтаназия (12 правил), всё остальное остаётся как есть. Реализовали это отдельным скриптом reconcile-pricing.ts, который применяет правила идемпотентно — можно перезапустить любое количество раз.
Архитектура страниц /uslugi/[slug]/ и SEO-обвязка
Шаблон страницы специалиста собран из шести компонентов:
- Заголовок + краткое intro (генерируется через Claude CLI, кэшируется в
specialist.intro). - Таблица услуг с ценами (тянется из
pricing_itemjoinspecialist). - FAQ-блок (3–5 вопросов, тоже генерируется один раз через CLI).
- JSON-LD микроразметка
MedicalBusiness+Service+FAQPage— компонентJsonLd.tsxуже был, расширили под новые типы. - Перелинковка с
/services/(зонтичные категории) и блогом. - Sticky-CTA «Записаться» + телефон.
И да — для срочной семантики («ветеринар на дом», «круглосуточно», «эвтаназия») мы сделали отдельный B3-блок: три urgent-funnel landing'а с расширенным FAQ, Schema.org EmergencyService и формой быстрого вызова. Их я выделил в отдельный спек именно потому, что воронка там другая — пользователь приходит «прямо сейчас», и страница должна закрывать его за один экран.
Ещё один блок (B4) — карточки врачей: 8 страниц /vrachi/<slug>/ с фото, специализациями и расписанием. Это классический E-E-A-T сигнал для медицинской ниши: Google и Яндекс обоим очень нравится, когда у статьи / услуги есть конкретный автор-эксперт.
Баг, который сожрал час: import-time vs runtime env
Когда я писал верификационный скрипт verify-tables.ts, он стабильно падал с тем, что SUPABASE_URL пустой, хотя .env.local лежит на месте. Полез смотреть. Вот что нашёл:
// scripts/verify-tables.ts — НЕ работает
import { supabaseAdmin } from "@/lib/supabase"; // <- здесь читается process.env
import { config } from "dotenv";
config({ path: ".env.local" }); // <- слишком поздноДело в том, что lib/supabase.ts делает createClient(process.env.SUPABASE_URL, ...) на верхнем уровне модуля. Когда Node резолвит import, он выполняет тело модуля до того, как мой собственный код успевает позвать dotenv.config(). Поэтому в момент создания клиента переменных ещё нет.
Фикс простой — либо подгружать dotenv через --require dotenv/config, либо делать свой локальный createClient прямо в скрипте (как сделано в edit-articles.ts):
// рабочий вариант
import { config } from "dotenv";
config({ path: ".env.local" });
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ db: { schema: "public" } }
);Выглядит банально, но я вижу эту ошибку в каждом втором проекте на Next.js с самописными CLI-скриптами. Основной workflow Next-приложения подхватывает env через next dev / next build, поэтому проблема не вылезает в обычной разработке — а вот любой tsx scripts/... ломается.
Результат и выводы
Что получилось по итогам работы:
- 365 статей прогнаны через editorial pass (~95% успешно, остальные перезапущены отдельно), стоимость — $0 благодаря Claude CLI на подписке.
- 28 страниц специалистов
/uslugi/[slug]/с реальными ценами, FAQ, JSON-LD и перелинковкой — закрывают всю семантику по подуслугам. - 780 позиций прайса в нормализованной таблице
pricing_itemс возможностью идемпотентной реконсилиации. - 3 urgent-funnel landing'а под срочную семантику с
EmergencyServiceразметкой. - 8 карточек врачей под E-E-A-T.
- Sitemap, навигация, cross-link обновлены — Яндекс и Google видят новый раздел сразу после деплоя.
Главный урок этой истории — для меня лично — про то, что важно говорить заказчику «не делай этого» так же часто, как «давай сделаем». Если бы я молча взялся переписывать 365 статей, мы бы потратили дни на marginal-улучшение и оставили на столе главный шанс — коммерческие посадочные. Когда заказчик пришёл с готовым решением «давай редачить тексты», правильный ход — поставить под сомнение само решение, а не только его реализацию. SEO-эффект от хорошей страницы услуги с прайсом и FAQ для ветклиники в ЦАО на порядок больше, чем от литературной полировки статьи про сибирского хаски.
Второй урок — про параллельность работы. Editorial pass на 365 статей шёл фоном 2.5 часа, и всё это время я не сидел и не ждал — я работал над дизайном /uslugi/, разведкой конкурентов через subagent'ов, миграциями. Background jobs — это не просто способ ускориться, это способ не блокировать собственное мышление. Каждый раз, когда я ловлю себя на «надо подождать пока скрипт отработает», стоит задаться вопросом «а что я могу делать прямо сейчас». Обычно — много чего.
Третий урок — про две разные онтологии в одной системе. У likedog была сетка «зонтиков» (15 категорий) и сетка «специалистов» (28 категорий из реального прайса). Соблазн «давайте сольём в одну» был сильный, но цена ошибки — поломанные ссылки, потеря индексации, недели работы поверх. Решение «оставить старое, сделать новое параллельно» иногда выглядит как технический долг, но на деле это самый честный путь, когда у двух систем разные источники истины и разные пользователи. Старый /services/ обслуживает зонтичную семантику, новый /uslugi/ — детальную; и они спокойно сосуществуют в одной Postgres базе с двумя таблицами и одним общим JsonLd.tsx.
И последнее — про дисциплину разделения спека и плана. Я честно использовал skill brainstorming → writing-plans → subagent-driven-development, и это сэкономило кучу времени на перерисовках. Спек на 233 строки, план на 1616 строк, 15 задач + pre-flight, каждая задача с явным acceptance critera. Когда после Task 1 я уткнулся в неожиданность — что в прайсе нет позиции «стерилизация кошки», потому что хирургия там организована по «категориям сложности 1–4» — я не запаниковал и не побежал переделывать всё, а спокойно остановился, скорректировал спек и продолжил. Это и есть та самая «дисциплина», ради которой существуют все эти superpowers-skills. Без них на таком объёме (28 страниц специалистов, 780 позиций прайса, 3 funnel'а, 8 врачей) я бы утонул в third-order правках за первый же день.
В итоге проект оказался не про «давайте перепишем статьи», а про «давайте построим новый коммерческий каркас сайта». И это ровно та работа, ради которой клиенты нанимают разработчика — увидеть, что задача, которую они формулируют, не та, которую им реально нужно решить.

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