МосКомДез — сайт службы дезинфекции
Действующий сайт службы дезинфекции, дезинсекции и дератизации «МосКомДез»: главная, 27 страниц услуг, контакты и политика, собственная админ-панель. Полная замена лендинга на Tilda — с переносом контента, чисткой технических косяков донора и сохранением SEO. Ключевая особенность: один физический сайт обслуживает 80 городских версий на wildcard-поддоменах — Москва на основном домене и 79 городов Московской области, без единой копии файлов. Заявки уходят одновременно в amoCRM заказчика и в Telegram. Всё написано на чистом PHP без CMS и базы данных, работает на обычном shared-хостинге и не требует ни одной сборки на сервере.
Что было нужноСайт стоял на Tilda, упирался в её ограничения и не позволял завести отдельные страницы под города области.
Что получилосьОдин сайт обслуживает 80 городских версий, заявки уходят в amoCRM и Telegram, позиции при переезде не потеряны.
Мультигородская архитектура
Требование заказчика — городские посадочные под всю Московскую область. Копировать сайт 80 раз нельзя: контент правится через админку и мгновенно разъехался бы.
- Реестр городов — один JSON: слаг (он же поддомен), название и четыре падежных формы, флаги «основной», «работает», «в поиске». Он же источник списка городов на странице и переключателя версий.
- Городские названия подставляются плейсхолдерами {CITY}, {CITY_IN}, {CITY_TO} и другими — заменой по готовому HTML через колбэк буфера вывода. Поэтому они работают везде разом: в шаблонах, в текстах из админки, в alt и title, в скрытых полях форм и в микроразметке. Забыть подключить подстановку в новом блоке физически негде.
- Персонализированы title, description, keywords, заголовки H1–H3, основной и SEO-текст, FAQ, подписи к картинкам и формы. Намеренно не персонализированы адрес офиса и реальные локации примеров работ.
- Разбор хоста решается до любого вывода: неизвестный поддомен отдаёт честный 404, поддомен основного города — 301 на главный домен, чтобы не плодить дубли.
- Canonical и og:url считаются от текущего хоста и сами держатся на поддомене. robots.txt генерится PHP: строка Sitemap указывает на текущий хост, а на городских версиях её нет вовсе.
- Вся логика поддоменов заперта в одном файле — остальной код про них не знает.
- Админка на поддоменах отдаёт 404: 79 лишних точек входа в логин не нужны.
Контент и собственная админ-панель
Контента много — 27 страниц услуг, девять прайс-таблиц, 80 городов, — и весь он лежит в JSON, а правится через самописную панель.
- Редактор страницы услуги на вкладках: SEO, герой (заголовок, чек-лист, картинка, цена), тексты, прайс из нескольких таблиц, FAQ и набор блоков страницы — галерею, методы, сертификаты, квиз и прочее можно выключить по каждой услуге.
- Отдельные экраны: герой главной, прайс главной, слайдер услуг, примеры работ «до/после», города, настройки, корзина с восстановлением удалённых услуг.
- Запись всегда одинаковая: валидация → бэкап старой версии (10 копий на файл) → контрольная распаковка того, что собрались писать → временный файл → атомарный rename. Оборванный запрос не оставит битый JSON, а ошибка выходит человеческим текстом, а не пятисоткой.
- Тексты чистятся белым списком тегов с полным срезанием атрибутов: копипаст из Word не сломает вёрстку и не пронесёт скрипт.
- Загрузка фотографий: приём файла с проверкой типа, кроп и конвертация в WebP на сервере через GD.
- В таблице городов намеренно нет колонки «Основной»: сервер берёт этот флаг из действующего файла и игнорирует присланный, а строку основного города нельзя ни удалить, ни выключить — заказчик не должен мочь случайно переназначить главный домен.
Заявки, квиз и интеграция с amoCRM
Шесть точек входа заявки на сайте: промо-полоса, форма героя, «назначить специалиста», модальное окно, две формы на контактах и квиз-калькулятор.
- Квиз из четырёх шагов плюс финальный экран. На странице услуги нужный вариант шага «проблема» отмечен заранее — человек уже прочитал заголовок, повторно тыкать очевидное не заставляем. С клиента приходят только индексы ответов, тексты сервер берёт из своего JSON: прислать произвольную строку нельзя.
- Город заявки определяется по хосту, а не по полю формы — подделать нельзя.
- Защита приёмника: honeypot, серверная валидация телефона и согласия, обрезка полей по символам, лимит 5 заявок с одного IP за 10 минут на файле под flock.
- Интеграция с живой amoCRM заказчика без тестового контура: заявка кладётся в «Неразобранное» нужной воронки — физлица и юрлица разводятся по первому шагу квиза. Ответы квиза раскладываются по реальным полям CRM, рекламные метки — по служебным API-полям, остальное уходит примечанием.
- Поиск дубля по телефону обязателен, а не опционален: в CRM уже пишут телефония и сервис обратного звонка. Поиск в amo строковый и по всем полям сразу, поэтому ищем по последним десяти цифрам номера и потом сверяем найденный контакт — иначе заявка прилипает к чужой карточке.
- Ни один ID поля не написан руками: карта соответствий генерится скриптом из выгрузки аккаунта, проверяется отдельной командой, а раскладка заявки по полям прогоняется локально без обращения к сети.
- Telegram оставлен вторым каналом по просьбе заказчика: заявка считается принятой, если сработал хотя бы один канал, а если не сработал ни один — дописывается в закрытый лог на диске. Терять лид нельзя: второй раз человек писать не станет.
- Обратный вебхук: при переходе сделки на финальный этап в Telegram-группу уходит сообщение с городом, проблемой, контактами и ссылкой на сделку.
- Рекламные метки первого захода (utm, gclid, yclid, referrer) запоминаются в куку на базовом домене — она переживает гео-редирект на поддомен, поэтому источник не затирается и реклама не превращается в прямые заходы.
Определение города посетителя
Отдельное требование заказчика: человек должен сразу попадать на версию своего города.
- Браузер спрашивается о геолокации, при отказе или таймауте город определяется по IP — обе ветки идут через DaData.
- Название нормализуется (город из сервиса и город из реестра сравниваются без «г.», регистра и «ё»), город ищется в реестре; чужой город — остаёмся на Москве.
- Свой город — переход на его поддомен тем же путём, дальше поп-ап «Нужна обработка в …?»: «Да» — остаться, «Нет» — полноэкранный список городов с живым поиском.
- Результат и подтверждение кладутся в куки на базовом домене, чтобы поддомены видели их друг у друга: определение выполняется один раз на посетителя.
- У бесплатной квоты сервиса 10 000 запросов в сутки, поэтому эндпоинт держит собственный лимит — 20 определений с IP в час, счётчик в файле под flock.
SEO
- Уникальные метаданные на каждую из 27 услуг, персонализированные по городу.
- JSON-LD: LocalBusiness с телефонами, разобранным на части адресом и зоной обслуживания текущего города, плюс FAQPage — но только там, где блок вопросов реально выводится на странице.
- sitemap.xml собирается из индекса услуг, дата изменения берётся с файла контента и обновляется сама при правке через админку.
- Плоские ЧПУ, канонический адрес доводится 301-м с сохранением UTM-меток.
- Городские версии закрыты от индексации мета-тегом и HTTP-заголовком, при этом robots.txt их не запрещает — робот должен зайти и увидеть noindex. Включение города в поиск — рычаг в админке.
- Open Graph и Twitter card на всех страницах, осмысленная страница 404 с noindex.
Производительность и доступность
PageSpeed Insights, боевой домен, август 2026. Страница услуги: Performance 100, Accessibility 100, Best Practices 96, SEO 100 на мобильных и 99/100/96/100 на десктопе. Главная: 92/100/88/100 на мобильных.
- Скорость вытянута вёрсткой и ассетами, а не инфраструктурой: ни CDN, ни кэш-слоя, ни сборщиков на сервере — обычный shared-хостинг.
- Свой Roboto вместо Google Fonts убрал render-blocking около 2 секунд и цепочку из пяти внешних запросов: три вариативных woff2 по подмножествам, preload кириллицы и латиницы.
- Минификация своими средствами и версионирование ассетов по времени файла: CSS 91,6 → 54,6 КБ (10,2 КБ по сети), JS 33,1 → 14,3 КБ (4,7 КБ). Если минифай устарел, отдаётся исходник — сайт не ломается.
- Все изображения в WebP с явными размерами, srcset и fetchpriority на LCP-картинке, ленивая загрузка остального, content-visibility на нижних секциях. CLS около нуля.
- Accessibility 100: контраст AA во всех местах, где цветной текст шёл по светлому фону, aria-подписи в формах, тач-цели не меньше 24 px, закрытие модалок по Esc, ветка prefers-reduced-motion у анимаций.
- Минус четыре балла в Best Practices — цена запроса геолокации на загрузке страницы: Lighthouse хочет разрешение по клику, но тогда автоперехода на нужный город не получится. Осознанный размен баллов на функциональность, оговорённый с заказчиком. До подключения геолокации сайт брал 100 по всем четырём категориям.
Безопасность и надёжность
- Секреты только в конфиге вне репозитория; в HTML и JS не попадает ни один токен.
- Папки с контентом, служебным PHP, кусками шаблонов и скриптами разработчика закрыты от прямого доступа, с подстраховкой на случай выключенного mod_rewrite.
- Экранирование вывода везде, где в разметку идут данные; микроразметка кодируется так, что случайный </script> в тексте не порвёт страницу; обход директорий отсечён на всех путях, которые собираются из входных данных.
- Админка: хэш пароля, сравнение с защитой от тайминга, задержка на неудачную попытку, регенерация сессии, CSRF-токен на всех пишущих действиях, кука только для раздела админки с httponly и secure по фактическому протоколу.
- Вебхук CRM защищён секретом в адресе — подписи у amoCRM-вебхуков нет.
- Мягкая деградация везде: нет конфига — берётся шаблон, нет минифая — исходник, недоступна CRM — работает Telegram, не сработало ничего — заявка ложится в лог.
Чем интересен проект
- 80 городских версий сайта без единой копии файлов — подстановка падежей через буфер вывода оказалась и проще, и надёжнее шаблонных функций.
- Интеграция с чужой живой CRM без тестового контура: карта полей, собранная скриптом из выгрузки аккаунта, склейка дублей по последним десяти цифрам номера и сухой прогон раскладки заявки локально, без единого запроса наружу.
- Заявка не теряется никогда: CRM → Telegram → лог на диске, успех при срабатывании любого звена.
- 100/100/96/100 на странице услуги при полностью самописном стеке, без CDN, кэш-слоя и сборщиков — на дешёвом shared-хостинге.
- Полный цикл одним человеком: перенос контента с Tilda → архитектура → бэкенд → фронтенд → админка → интеграция с CRM → подготовка 100+ изображений → SEO → оптимизация → деплой на боевой домен.