Боевой сайт службы дезинфекции, дезинсекции и дератизации: https://dezinhelp.ru
Полная замена лендинга на Tilda: главная, 27 страниц услуг, контакты, политика, собственная админка, мультигородские версии на wildcard-поддоменах, определение города посетителя, приём заявок в Telegram и amoCRM. Ни CMS, ни фреймворков, ни сборщиков, ни базы данных — чистый PHP 8 + ванильный JS, контент в JSON-файлах, хостинг — обычный shared (Beget).
- Стек и принципы
- Структура репозитория
- Как собирается страница
- Роутинг
- Контент и админка
- Мультигород: wildcard-поддомены
- Определение города посетителя
- Заявки: формы, квиз, Telegram, amoCRM
- SEO
- Производительность
- Доступность
- Безопасность
- Установка и запуск
- Сборка и деплой
- Инструменты разработчика
- Известные ограничения и открытые вопросы
| Бэкенд | PHP 8 (на бою 8.5), без Composer и внешних библиотек |
| Фронтенд | HTML5, CSS3, ванильный JavaScript — без фреймворков и рантайма |
| Хранилище | JSON-файлы в /data, без СУБД |
| Шрифт | Roboto, self-hosted, вариативный woff2 по трём подмножествам |
| Сборка | sh tools/build.sh — минификация CSS (свой скрипт) и JS (terser через npx) |
| Хостинг | Beget shared, nginx + Apache (.htaccess), деплой по FTP/rsync |
| Интеграции | Telegram Bot API, amoCRM REST v4, DaData (геолокация), Яндекс.Метрика |
Принципы, которые держались весь проект:
- Один источник правды на сущность. Города — только
data/cities.json, услуги — толькоdata/services.json+data/services/<slug>.json, секреты — толькоconfig.php. - Нет дублей разметки. Все повторяющиеся секции — инклюды в
source/include/, шапка<head>одна на все страницы, четыре карусели на сайте — одна функцияinitCarousel()в JS. - Данные отдельно от вёрстки. Шаблон не знает, откуда пришёл контент: между ним и файлами
стоит
source/php/data.php. Смена хранилища правит один файл. - Деградация вместо падения. Нет
config.php— берётсяconfig.sample.php. Нет минифая — отдаётся исходник. CRM недоступна — заявка уходит в Telegram. Не сработало ничего — заявка пишется в лог на диск. - Табы вместо пробелов, короткие комментарии по-русски к каждому нетривиальному блоку.
├── index.php главная
├── service.php шаблон страницы услуги (одна на все 27)
├── kontakty.php контакты
├── politika.php политика обработки персональных данных
├── 404.php самостоятельная страница ошибки (без шапки и данных)
├── sitemap.php sitemap.xml (генерится, rewrite из .htaccess)
├── robots.php robots.txt (генерится: Sitemap от текущего хоста)
├── .htaccess роутинг, защита служебных папок, сжатие, кэш
│
├── data/ ВЕСЬ контент сайта; закрыт от веба (403)
│ ├── services.json индекс услуг: 4 группы, 27 услуг, флаги visible/footer
│ ├── services/*.json 27 файлов контента страниц услуг
│ ├── cities.json реестр 80 городов: slug, падежи, enabled, index
│ ├── site.json телефоны, почта, адрес, реквизиты, чек-лист героя
│ ├── amo.json карта полей и справочников amoCRM
│ ├── quiz.json квиз-заявка: 4 шага + финальный экран
│ └── prices / faq / reviews / works / team / methods / process / stats /
│ license / coverage / contacts / home-services / service-page.json
│
├── source/
│ ├── php/ бизнес-логика
│ │ ├── bootstrap.php общий вход: конфиг → город → буфер вывода → данные шапки
│ │ ├── city.php вся логика поддоменов и подстановки {CITY...}
│ │ ├── data.php чтение /data + хелперы выборки
│ │ ├── asset.php min-версия + ?v=filemtime
│ │ ├── order.php приёмник форм: валидация → amoCRM → Telegram
│ │ ├── amo.php транспорт amoCRM: контакт → сделка → примечание
│ │ ├── amo-hook.php вебхук amoCRM → уведомление в Telegram
│ │ ├── telegram.php отправка сообщения ботом
│ │ ├── geo.php DaData: город по IP и по координатам
│ │ ├── detect.php эндпоинт определения города (JSON, с лимитом)
│ │ └── config.php секреты, НЕ в git (есть config.sample.php)
│ ├── include/ 22 инклюда: header/footer, 17 блоков страниц, head, schema, modal
│ ├── css/main.css 4 236 строк + main.min.css
│ ├── js/main.js 788 строк + main.min.js
│ ├── fonts/ roboto-{cyrillic,latin,latin-ext}.woff2
│ └── img/ 101 webp: услуги, работы, сертификаты, команда, галерея
│
├── admin/ собственная панель управления
│ ├── index.php одна точка входа, ?page=…
│ ├── php/{auth,lib,actions}.php
│ └── views/ 8 экранов
│
└── tools/ скрипты разработчика, на хостинг не выкладываются
├── build.sh минификация css/js перед выкладкой
├── minify-css.php свой минификатор без зависимостей
├── amo-dump.php разведка аккаунта amoCRM (read-only)
├── amo-map.php сборка/проверка data/amo.json
└── amo-test.php сухой прогон раскладки заявки по полям, без сети
Все публичные страницы начинаются одинаково — с require source/php/bootstrap.php. Он:
- Читает
config.php(или шаблон, если рабочего нет). - Разбирает хост и определяет город до любого вывода: неизвестный поддомен → 404,
moskva.<домен>→ 301 на основной домен. - Ставит
<meta robots>и заголовокX-Robots-Tagпо флагуindexгорода. - Готовит карту плейсхолдеров и включает
ob_start("city_render"). - Накладывает публичные контакты из
data/site.jsonповерх конфига (админка правит их, секреты при этом остаются только в конфиге и в HTML не попадают). - Считает
$siteUrlот текущего хоста — canonical и og:url сами держатся на поддомене.
Ключевой приём — подстановка городских названий через буфер вывода. Плейсхолдеры
{CITY}, {CITY_IN}, {CITY_GENITIVE}, {CITY_TO}, {CITY_PREP} заменяются одним strtr
по готовому HTML целиком. Поэтому их можно писать где угодно — в шаблоне, в JSON из админки,
в alt, в title, в скрытых полях форм, в JSON-LD — и физически нельзя забыть подключить
подстановку в новом месте.
Всё делает .htaccess + service.php:
| Адрес | Что происходит |
|---|---|
/ |
index.php |
/<slug>/ |
service.php?slug=…, слаг ищется в индексе услуг |
/kontakty/, /politika/ |
свои шаблоны |
/sitemap.xml, /robots.txt |
rewrite на sitemap.php / robots.php |
/index.php |
301 на / (проверка по THE_REQUEST, внутренний DirectoryIndex не задет) |
/Obrabotka-Ot-Klopov |
301 на /obrabotka-ot-klopov/ — нижний регистр + слеш |
/data/, /source/php/, /source/include/, /tools/ |
403 |
| несуществующий слаг | честный 404, а не пустой шаблон с кодом 200 |
Канонический редирект сохраняет query-строку: UTM-метки рекламы не теряются, служебный
slug из строки вырезается. Приёмники, которые дёргают снаружи (order.php, detect.php,
amo-hook.php), вынесены исключениями до правила, закрывающего папку.
Контента много (27 страниц услуг, 9 прайс-таблиц, 80 городов), и весь он лежит в JSON,
а правится через собственную панель на /admin/.
Что умеет панель:
- Услуги — список с сортировкой и переключателем видимости; редактор страницы услуги на вкладках: SEO (title/description/keywords), герой (H1, чек-лист, сноска, картинка, цена), тексты (вводный + SEO-текст), прайс (несколько таблиц, каждая со своим переключателем), FAQ, набор блоков страницы (галерея, методы, порядок обработки, сертификаты, ветераны, зона выезда, квиз, слайдер услуг).
- Главная — герой, прайс, слайдер услуг, примеры работ «до/после».
- Города — таблица реестра: название, поддомен, четыре падежа, «Работает», «В поиске».
Колонки «Основной» намеренно нет: сервер берёт флаг
mainиз действующего файла и игнорирует присланный, строку основного города нельзя ни удалить, ни выключить. - Настройки — телефоны, почта, адрес, мессенджеры, реквизиты; смена логина и пароля.
- Корзина — удалённые услуги восстанавливаются вместе с файлом контента.
Как устроена запись. Всё идёт через json_write(): валидация структуры → бэкап старой
версии в data/.backup (хранится 10 копий на файл) → контрольная распаковка того, что
собрались писать → запись во временный файл → атомарный rename. Оборванный запрос не
оставляет битый JSON, а сохранение с ошибкой откатывается на человеческом тексте ошибки,
а не на пятисотке.
Безопасность панели: одна учётка, password_hash/password_verify, hash_equals на
логине, sleep(1) на неудачную попытку, session_regenerate_id после входа, CSRF-токен на
всех пишущих экшенах, кука сессии httponly + samesite=Lax + secure по фактическому
протоколу и path=/admin/. HTML из текстовых полей чистится белым списком тегов с полным
срезанием атрибутов — копипаст из Word не сломает вёрстку и не пронесёт скрипт. Картинки
принимаются с проверкой типа и пережимаются в WebP на сервере через GD.
Один физический сайт обслуживает 80 городских версий: Москва — основной домен, 79 городов
Московской области — поддомены (khimki.dezinhelp.ru, balashikha.dezinhelp.ru, …).
Копий сайта нет, лишних файлов нет.
- Реестр —
data/cities.json: слаг (он же поддомен), название и четыре падежных формы, флагиmain,enabled,index. - Вся логика поддоменов — в
source/php/city.php. Больше нигде в проекте про поддомены не знают. - Персонализируются title/description/keywords, H1–H3, основной и SEO-текст, FAQ, alt и подписи, поля форм — через те же плейсхолдеры.
- В каждую услугу добавлены городской абзац SEO-текста и пункт FAQ «Выезжаете ли вы {CITY_TO}?» (винительный падеж, чтобы не выходило «выезжаете ли вы в Балашихе»).
- Намеренно не персонализированы: адрес офиса, локации примеров работ (реальные адреса с метро), заголовки «Наши сертификаты», «Отзывы наших клиентов», «Зона выезда».
- Слаги в стиле
-ский → -sky(zhukovsky,dolgoprudny); несклоняемые на -ово/-ино оставлены как есть. - Переключатель города — список населённых пунктов на странице; ссылки ведут на поддомены, текущий подсвечен.
- Админка на поддоменах отдаёт 404: 79 лишних точек входа в логин не нужны.
sitemap.xmlотдаётся только основным доменом. Все города, кроме Москвы, сейчас подnoindex, follow— рычаг «В поиске» есть в панели по каждому городу.
Требование заказчика: посетитель должен сразу попадать на версию своего города.
main.jsпри загрузке спрашиваетnavigator.geolocation(таймаут 8 с).- Координаты — или отказ/таймаут — уходят в
source/php/detect.php, тот идёт в DaData: обратная геокодировка по координатам либо определение по IP. - Название нормализуется (
г. КоролевиКоролёв— один город) и ищется в реестре. Нет в списке или сервис не ответил — остаёмся на Москве. - Другой город —
location.replaceна его поддомен тем же путём. Дальше поп-ап «Нужна обработка {CITY_IN}?»: «Да» — остаётся, «Нет» — полноэкранный список городов с живым поиском.
Куки ставятся на базовый домен, чтобы поддомены видели их друг у друга: dh_city (что
определили; пока она есть, ни браузер, ни DaData не дёргаются — 30 дней, 180 после ручного
выбора) и dh_city_ok (город подтверждён, поп-ап больше не показывается — 180 дней;
крестик ставит сессионную). Так определение выполняется один раз на посетителя.
Квота DaData — 10 000 запросов в сутки, поэтому detect.php держит собственный лимит:
20 запросов с IP в час, счётчик в файле под flock, IP берётся из REMOTE_ADDR
(заголовки подделываются и для лимита не годятся).
На сайте шесть точек входа заявки: промо-полоса, форма героя, «назначить специалиста», модальное окно «Заказать онлайн», две формы на контактах и квиз-калькулятор.
Общий фронт. Одна инициализация на все формы: маска +7 (###) ###-##-##, маска имени,
собственная валидация вместо нативных подсказок, honeypot-поле, AJAX-отправка, статус
в role="status".
Квиз — 4 шага (физ/юрлицо → проблема → вид объекта → сроки) + финальный экран
с телефоном и способом связи. На странице услуги нужный вариант шага «проблема» отмечен
заранее — человек уже прочитал H1, повторно тыкать очевидное не заставляем. С клиента
приходят только индексы ответов, тексты сервер берёт из data/quiz.json: прислать
произвольную строку в Telegram нельзя.
Сервер (order.php). Только POST; honeypot отвечает молчаливым «успехом»; город берётся
с хоста, а не из формы (подделать нельзя); телефон — ровно 11 цифр; поля обрезаются по
символам (Telegram не примет сообщение длиннее 4096); согласие квиза проверяется и на
сервере; лимит 5 заявок с IP за 10 минут через файл под flock.
Рекламные метки. main.js запоминает метки первого захода (utm_*, gclid, yclid,
fbclid, _ym_uid, referrer, landing) в куку dh_track на 90 дней. Кука на базовом домене —
переживает гео-редирект на поддомен. Побеждает первый заход: иначе внутренний переход
или редирект затёр бы источник и вся реклама выглядела бы прямыми заходами.
amoCRM (source/php/amo.php) — внешняя интеграция на долгосрочном токене, без JS-виджета:
- Режим
unsorted: заявка кладётся в «Неразобранное» нужной воронки («Физ. лица» или «Юр.лица» — по первому шагу квиза). Режимleads(сразу на этап) тоже реализован и переключается одной строкой вdata/amo.json. - Поиск дубля по телефону обязателен, а не опционален: в CRM уже пишут телефония и сервис обратного звонка, без поиска один клиент расползается на несколько контактов. Поиск в amo строковый и по всем полям сразу, поэтому ищем по последним 10 цифрам и потом сверяем номер у найденного контакта — иначе заявка прилипнет к чужой карточке.
- Ответы квиза раскладываются по реальным полям заказчика (проблема, объект, комнаты,
сроки), рекламные метки — по
is_api_only-полям, остальное уходит примечанием. - Ни один ID полей не написан руками: карта
data/amo.jsonгенерится из выгрузки аккаунта (tools/amo-dump.php), проверяетсяtools/amo-map.php --check, раскладка заявки прогоняется без сети черезtools/amo-test.php. - Номера в CRM пишутся в формате
8 (999) 123-45-67— по просьбе заказчика, чтобы у всех источников был один вид и не расходился поиск дублей. Маска на сайте осталась+7.
Telegram оставлен вторым каналом по прямой просьбе заказчика: amoCRM у них уже подводила,
а сообщение в чат приходит даже когда CRM недоступна. Заявка считается принятой, если
сработал хотя бы один канал; если не сработал ни один — она дописывается в
data/.orders-failed.log, потому что второй раз человек писать не станет.
Обратный вебхук (amo-hook.php): при переходе сделки на этап «Передано мастеру» в
Telegram-группу уходит сообщение с городом, проблемой, именем, телефоном и ссылкой на сделку.
Подписи у вебхуков amo нет, поэтому защита — секрет в строке запроса; чужой ключ и его
отсутствие дают 403, посторонние этапы игнорируются молча.
- Уникальные title/description/keywords на каждую из 27 услуг, персонализированные по городу.
canonicalиog:urlсчитаются от текущего хоста — на поддомене они указывают на поддомен.- Open Graph + Twitter card на всех страницах (общий
head.php). - JSON-LD:
LocalBusiness(телефоны, почта, разобранный на части адрес,areaServedтекущего города,priceRange) иFAQPage— ноFAQPageвыводится только там, где блок вопросов реально есть на странице. Ответы чистятся от<br>и тегов. sitemap.xmlсобирается из индекса услуг,lastmodберётся с даты файла контента, то есть обновляется сам при правке через админку.robots.txtгенерится PHP: строкаSitemap:указывает на текущий хост, а на городских поддоменах её нет вовсе. Статический файл жёстко называл боевой домен и врал на тесте.- Плоские ЧПУ вида
/obrabotka-ot-klopov/, канонический вид адреса доводится 301-м. - Городские версии закрыты от индексации мета-тегом и заголовком
X-Robots-Tag, при этомrobots.txtих не запрещает — робот должен зайти и увидетьnoindex.
11 августа 2026, ~14:49–14:50 МСК, боевой домен. Поле «Discover what your real users are experiencing» — No Data: CrUX по домену ещё не набран, все цифры лабораторные.
| Страница | Performance | Accessibility | Best Practices | SEO | Agentic Browsing | |
|---|---|---|---|---|---|---|
Услуга /izbavitsya-ot-muravev/ |
Mobile | 100 | 100 | 96 | 100 | 2/2 |
Услуга /izbavitsya-ot-muravev/ |
Desktop | 99 | 100 | 96 | 100 | 2/2 |
Главная / |
Mobile | 92 | 100 | 88 | 100 | 2/2 |
Для сравнения — замер 25 июля 2026 на тестовом домене, до подключения геолокации и счётчика: главная 99/100/100/100 (desktop) и 98/100/100/100 (mobile), страница услуги 99/100/100/100 на обеих платформах. То есть базовый сайт брал 100 по всем категориям, и оба нынешних минуса — цена двух добавленных возможностей.
- Свой Roboto вместо Google Fonts — убрало render-blocking ≈2050 мс и цепочку из пяти
запросов к
fonts.gstatic.com. Три вариативных woff2 по подмножествам, preload кириллицы и латиницы. - Минификация +
?v=filemtimeчерезasset(): CSS 91,6 → 54,6 КБ (10,2 КБ по сети), JS 33,1 → 14,3 КБ (4,7 КБ по сети). Если минифай устарел — отдаётся исходник, сайт не ломается, просто чуть тяжелее. - Картинки: всё в WebP,
srcset/sizes/fetchpriority=highна LCP-картинке героя, явныеwidth/heightиloading="lazy"на остальном — CLS около нуля. content-visibility: autoна нижних секциях (#objects,#coverage,#faq). На#citiesвешать нельзя: пока секция свёрнута, её ссылки по координатам налезают на подвал и падает аудит target-size.- Gzip и заголовки кэша в
.htaccess,Cache-Control: no-cacheдля HTML (страницы собираются из JSON и меняются через админку).
Разница — ровно две вещи, и обе внешние по отношению к вёрстке:
- Запрос геолокации на загрузке (−4 к Best Practices на обеих страницах, аудит «Requests the geolocation permission on page load»). Lighthouse хочет, чтобы разрешение спрашивали по действию пользователя. Здесь это невозможно: определение города — часть заказа, автопереход на поддомен должен произойти до того, как человек начал читать страницу. Сознательный размен четырёх баллов на функциональность.
- Счётчик Яндекс.Метрики, который стоит только на главной — остальные −8 к Best
Practices и −8 к Performance. По отчёту PSI:
tag.js87 КБ с TTL кэша 1 час, forced reflow 124 мс изmetrika/tag.js, сторонние кукиsync_cookie. Страница услуги счётчика не содержит и держит 96/100.
Прочее из отчёта главной, что осталось жёлтым осознанно:
- Use efficient cache lifetimes — nginx на Beget отдаёт статику с недельным кэшем и
mod_expiresиз.htaccessигнорирует. Поднять TTL нельзя; свежесть после правок держится на?v=filemtime. - Render-blocking
main.min.css(10,2 КБ / 330 мс) — критический CSS не выносили: файл общий на все страницы, а выигрыш на 10 КБ не стоил двух источников правды в стилях. - Unused CSS ≈17 КБ на главной — это стили страниц услуг в общем файле. Дробить не стали по той же причине.
- В цепочке критического пути виден
detect.php(~1355 мс) — это геолокация, см. пункт 1.
Accessibility 100 на всех замеренных страницах, и это не случайность:
- Контраст доведён до AA во всех местах, где зелёный или синий текст шёл по светлому фону
(для этого заведены отдельные токены
--color-accent-text,--color-blue-dark). aria-labelво всех полях форм (там, где по макету нет видимой подписи),<main>на страницах, явные размеры логотипа, тач-цели не меньше 24px у телефонов и ссылок подвала.- Аккордеон FAQ и раскрывающийся текст услуги — на
aria-expanded, модалки закрываются по Esc и по клику вне окна, у мобильного меню и полноэкранного списка городов есть scroll lock. - Ветка
prefers-reduced-motionу анимаций блока команды. - Карусели листаются стрелками и свайпом, клоны по краям помечены
aria-hiddenиtabindex="-1".
- Секреты только в
config.php— файла нет в git, естьconfig.sample.phpс комментариями. В HTML и JS не попадает ни один токен. /data/,/source/php/,/source/include/,/tools/закрыты правилом[F]в.htaccess(проверено на бою: 403). ПодстраховкаFilesMatchна*.json— на случай выключенногоmod_rewrite. Листинг папок запрещён.- Экранирование вывода
h()(htmlspecialcharsсENT_QUOTES) везде, где в разметку идут данные; JSON-LD кодируется сJSON_HEX_TAG, чтобы случайный</script>в тексте не порвал страницу. - Обход директорий отсечён
basename()на всех путях, собираемых из входных данных (слаг услуги, имя файла данных). - Лимиты частоты: 5 заявок с IP за 10 минут, 20 определений города с IP за час — оба на
файлах под
flock, оба наREMOTE_ADDR. - Вебхук amoCRM защищён секретом в строке запроса (подписи у amo-вебхуков нет).
- Админка — см. раздел выше: CSRF, hash_equals, регенерация сессии, санитайз HTML, проверка типов и размеров загружаемых картинок.
git clone https://github.com/<user>/dezinhelp.git
cd dezinhelp
cp source/php/config.sample.php source/php/config.phpВ config.php заполнить:
| Ключ | Зачем |
|---|---|
site_url |
базовый адрес для canonical и sitemap |
base_domain |
домен основной версии; пусто — берётся из текущего хоста (так работают и тест, и localhost) |
phones, email, address, license, messengers |
публичные контакты (дальше правятся через админку) |
telegram_bot_token, telegram_chat_id |
без них формы отвечают ошибкой config |
amo_subdomain, amo_long_term_token |
пустой поддомен полностью выключает отправку в CRM |
amo_hook_secret |
php -r "echo bin2hex(random_bytes(16));" |
dadata_token |
определение города; пустой ключ просто выключает геолокацию |
admin_user, admin_password_hash |
php -r "echo password_hash('пароль', PASSWORD_DEFAULT);" |
Локальный запуск:
php -S localhost:8000Встроенный сервер не знает .htaccess, поэтому страницы услуг открываются как
/service.php?slug=obrabotka-ot-klopov, либо через свой router-мок. И учтите: локальный
php -S занижает Performance (нет gzip и кэш-заголовков) — сверять цифры только с PSI.
⚠️ В локальномconfig.phpобычно лежит боевой токен бота: тестовая заявка с валидным телефоном реально улетит в чат заказчика, а при заполненномamo_subdomain— ещё и создаст карточку в живой CRM. Тестового контура у amoCRM нет.
sh tools/build.sh # main.min.css + main.min.jsГонять перед каждой выкладкой: asset() подставляет минифай только если он свежее
исходника, иначе тихо отдаёт исходный файл — сайт останется рабочим, но выигрыш потеряется.
Оба min-файла лежат в git и заливаются вместе с исходниками.
Деплой — копирование файлов на shared-хостинг (FTP/rsync). Отдельно, вне git:
source/php/config.php. Не выкладывается: tools/, .git, скриншоты. Папки data/.backup,
data/.trash и файлы лимитов создаются на сервере сами.
Для боевого домена нужны wildcard-DNS *.dezinhelp.ru и wildcard-сертификат — оба на месте.
| Скрипт | Что делает |
|---|---|
tools/build.sh |
минификация CSS (свой скрипт) и JS (terser через npx) |
tools/minify-css.php |
минификатор CSS без единой зависимости |
tools/amo-dump.php |
разовая read-only разведка аккаунта amoCRM: воронки, этапы, поля, справочники |
tools/amo-map.php |
сборка data/amo.json из выгрузки; --check сверяет карту с аккаунтом |
tools/amo-test.php |
сухой прогон раскладки заявки по полям CRM, без обращения к сети |
Честный список — то, что осознанно осталось как есть, и то, что стоит починить.
Стоит починить (в коде этого нет — правка сделана прямо на сервере):
-
Счётчик Метрики вставлен в начало
index.phpна хостинге, вне репозитория и внеsource/include/matrika.html(в git этот файл — пустая заготовка под счётчики). Последствия видны на бою:- счётчик выводится до
<!DOCTYPE html>и только на главной; страницы услуг, контакты и 404 аналитикой не покрыты; - вывод начинается раньше, чем PHP успевает отправить заголовки, поэтому ломаются оба
ранних решения бутстрапа:
moskva.dezinhelp.ruвместо 301 на основной домен отдаёт пустую страницу (в теле только код счётчика), а несуществующий поддомен видаnosuchcity.dezinhelp.ruпоказывает страницу «не найдено» со статусом 200 вместо 404 (мягкий 404 — Google такие индексирует); - он же стоит главной −8 к Best Practices и −8 к Performance (см. раздел выше).
Лечится переносом кода счётчика в
source/include/matrika.html— он уже подключён во всех шаблонах внутри<head>, включая 404. Заодно аналитика появится на всех страницах. - счётчик выводится до
Осознанные компромиссы:
- Запрос геолокации на загрузке страницы — требование заказчика, −4 к Best Practices на каждой странице. Альтернатива (спрашивать по клику) убивает смысл автоперехода.
- Автопереход на поддомен работает на любом хосте, а не только на основном домене. Это буквально по ТЗ, но означает, что посетитель городской посадочной из поиска или рекламы может быть уведён на свой поддомен. Первый кандидат на правку, если не понравится.
- Подпись под логотипом всегда «СЭС г. {CITY}», хотя часть пунктов реестра — посёлки
(Восточный, Кокошкино, Некрасовка). Отдельного поля под тип населённого пункта в
cities.jsonнет. - Единый CSS-файл на весь сайт: ≈17 КБ неиспользуемых стилей на главной против двух источников правды в стилях. Выбран первый вариант.
- Кэш статики на Beget — 7 дней, ставится nginx-ом,
.htaccessон игнорирует. Аудит «efficient cache lifetimes» останется жёлтым.
Данные, которые заказчик не прислал:
- Скан удостоверения СЭС — в
data/license.jsonполеimageпустое, на его месте рисуется пунктирная заглушка. Положить файл и прописать путь — разметка сама переключится на<img>(не забытьwidth/heightпротив CLS). @-ник в Telegram для страницы контактов — показывается общая ссылка «Написать в Telegram».- Ссылка на MAX в
data/site.json— заглушка#, заказчик проставит через админку.
Мелочи и хвосты:
- Пункт меню «Гарантия» ведёт на
#— раздела под него в проекте нет. - Города не попадают в
sitemap.xml(карта только для основного домена) — понадобится, когда включат индексацию городских версий. - Адреса и карта общие для всех городов: полей под свой адрес в
cities.jsonнет, заголовок секции карты жёстко «Наш офис в Москве». settings_save_siteв админке переписываетdata/site.jsonфиксированным списком ключей: любое новое поле, добавленное туда руками, молча пропадёт при первом сохранении настроек. Поэтому контент страницы контактов лежит в отдельномdata/contacts.json.- Пять блоков на страницах услуг (примеры работ, удостоверение, команда, отзывы, города) подключены без флага и в админке не выключаются — они общие для сайта, а не свои у услуги.
- Имена и даты у двух отзывов-скриншотов проставлены по картинкам, заказчиком не подтверждены.
- Автотестов и CI нет: проверка — ручные прогоны, headless-скрины по брейкпоинтам и
tools/amo-test.phpдля раскладки заявки.
Снято заказчиком — не предлагать заново: отдельная страница /contacts/, BreadcrumbList,
своя og:image для услуг, пер-городские переопределения текстов и цен.
- 80 городских версий сайта без единой копии файлов — подстановка падежей через буфер вывода оказалась и проще, и надёжнее шаблонных функций: забыть подключить её физически негде.
- Заявка не теряется никогда: amoCRM → Telegram → лог на диске, успех при срабатывании любого звена.
- Интеграция с чужой живой CRM без тестового контура — с поиском дублей по последним 10 цифрам номера, картой полей, собранной скриптом из выгрузки, и сухим прогоном раскладки без сети.
- 100/100/96/100 на странице услуги при полностью самописном стеке и обычном shared-хостинге, без CDN, кэш-слоя и сборщиков.