ozsm.ru: Mobile PSI 44→100, security headers и post-mysqldump incident recovery

Веб Штурм
  • SEO
  • MODX
  • Core Web Vitals
  • Beget
  • 152-ФЗ
  • incident-recovery
  • case-study

Сайт промышленного производителя ozsm.ru загружался на телефоне 11 секунд. За 10 рабочих дней мы довели его до 2 секунд и до максимальной оценки Google. Параллельно закрыли юридический риск штрафа 1–6 млн ₽ по 152-ФЗ и убрали keyword stuffing. Потом одна неосторожная команда в базе данных стёрла половину результата — восстановили за день из дисциплины бэкапов. Три урока, которые мы теперь применяем на всех клиентских проектах.

Что было до: сайт терял клиентов на телефоне

ОЗСМ — российский производитель вибропогружателей, сваебоев и металлоконструкций. Продукт тяжёлый, длинный цикл сделки 2–6 месяцев, основной канал заявок — поиск по моделям техники. Конкуренты — европейские бренды и переупаковщики с большими бюджетами.

На 23 апреля 2026 года сайт был построен на MODX 2.7 (платформа 2018 года) и шёл на shared-хостинге Beget. Объективная картина:

Что это означало для бизнеса: посетитель с телефона уходил к конкуренту, не дождавшись прайс-листа. По нашим оценкам, до 60% мобильного трафика терялось до показа продукта.

Что мы сделали: разогнали сайт за 10 дней, не переписывая его

Платформу не трогали. Никаких миграций, никаких новых URL, никакого переноса контента. Работали только с тем, что грузит браузер: убирали лишнее, откладывали неважное, делали ленивым то, что не нужно на первом экране.

Восемь итераций по 1 дню каждая, после каждой — замер. Если стало хуже — откат до того, как пользователь это увидит.

Главная картинка-слайдер грузилась 7 секунд. Это был старый плагин 2018 года. Заменили на статический блок, первое изображение вшили прямо в HTML. Стало мгновенно.

Картинки товаров весили 6 МБ. Конвертировали в современный формат WebP, добавили правило «грузить только когда пользователь до них доскроллил». Стало 1,8 МБ. Каталог из 200+ карточек открывается без задержки.

Карта России с регионами поставок весила 800 КБ. Грузилась всегда, даже если пользователь ушёл со страницы до неё. Сделали так, чтобы карта подгружалась только когда пользователь её увидел на экране. Эффект: -800 КБ для всех, кто не доскроллил.

Яндекс.Метрика грузилась первой. Перенесли её в фоновую очередь — теперь она запускается только когда браузер свободен или когда пользователь начал взаимодействовать со страницей. Это не лишает нас аналитики, но снимает блокировку первого экрана.

Google reCAPTCHA → Yandex SmartCaptcha. Российский аналог, сертифицирован ФСТЭК, данные не уходят за границу. Юридический риск закрыт, плюс бонус: SmartCaptcha весит в 18 раз меньше reCAPTCHA. Дополнительно — отложенная загрузка (грузится только когда пользователь начал заполнять форму) и невидимая ловушка для ботов.

Доступность по WCAG. Закрыли пять нарушений: убрали блокировку зума (это нарушение для людей с плохим зрением), увеличили контраст cookie-баннера, расширили зону клика кнопок до 44×44 пикселя.

Технически: что мы сделали (для разработчиков)

Спринт 1 — восемь итераций (B5–B14) с 24 апреля по 2 мая 2026.

B5 — hero swiper. RevSlider 2018 года отдавал крупный JPG как LCP-элемент. Замена: статичный hero-блок с inline-base64 первого слайда (~9 КБ). LCP-кандидат теперь в HTML, без отдельного network request.

B6–B10 — image pipeline. MODX-плагин на лету оборачивает все <img> в <picture><source type="image/webp"> плюс loading="lazy" для всех картинок ниже первого экрана. Каталог: 6 МБ JPG → 1,8 МБ WebP. На продуктовых страницах CLS падает с 0,549 до 0,000 через фикс размеров контейнера Fotorama.

B11 — jQuery cleanup + Метрика lazy. Найдены две копии jQuery (одна в <head>, вторая через document.write из legacy RevSlider). Убрали document.write, удалили html5shiv (polyfill для IE6–8), dead gtag, Я.Чат. Метрика обёрнута в requestIdleCallback({timeout:3000}) + триггеры на первое взаимодействие + 7-секундный fallback (Lighthouse не двигает мышью, без таймера Метрика не успевает инициализироваться в замере). Эффект: jQuery bootup 2,4 с → 0,6 с, TBT −300 мс.

B11.3 — critical CSS inline. Async-load CSS через <link rel=preload> + onload=this.rel='stylesheet' на legacy-сайте даёт CLS 0,443. Сделали иначе: 1,3 КБ gzip critical CSS прямо в <head> через MODX-chunk (header, hero, основная типографика). Основной bundle уже не render-blocking.

B12 — Accessibility 84 → 100. Пять правок: убран user-scalable=no из viewport, контент обёрнут в <main>, aria-hidden="true" на декоративный swiper, color-contrast cookie-banner 3,2:1 → 4,8:1, target-size через border: 12px solid transparent (визуально 20×20, hit-area 44×44 — WCAG 2.5.5).

B13 — Yandex SmartCaptcha. Sitekey ysc1_kpckIp39Xepyb7rR7iC6llP8xXXzf8gZURfrIAT4d978ee67. Lazy-load на первое взаимодействие (focusin/input/click/touchstart) + 8s safety fallback. Plus honeypot field (display:none + tabindex=-1 + aria-hidden) + server-side time-trap (Date.now() - form_ts > 3000). JS снизился с ~363 КБ до ~20 КБ.

B11.3 Geosales — lazy-load Я.Карт. Заменили eager-embed на placeholder <div id="GeosalesMap" data-coords="..."> + 30 строк vanilla. IntersectionObserver с rootMargin: '400px' загружает api-maps.yandex.ru только когда пользователь подскроллил на 400 пикселей до карты. Sequential loader (api-maps → geosales/yandex.js → geosales/default.js) обязателен — параллельный fetch ломает window.ymaps контракт.

B14 — final polish. Toast-уведомления через собственный form-toast.js (jGrowl-shim API, vanilla JS, ~3 КБ). MODX-конфиг: minifyJs=1, imageCompression=85 для JPG fallback. Cleanup 4 ненужных chunk’а от редизайнов 2019–2021.

Что получили на сайте после первого спринта

МетрикаБылоСтало
Оценка Google Mobile44100
Оценка Google Desktop68100
Доступность (WCAG)84100
Время загрузки на мобильном11,3 с1,9 с
Задержка интерактивности1108 мс0 мс
Размер кода520 КБ~50 КБ

Бизнес-итог: посетитель на телефоне теперь видит первый экран быстрее, чем успевает закрыть вкладку. Юридический риск 1–6 млн ₽ закрыт. Конкурентам в SEO стало сложнее обгонять нас на технических факторах.

Что было плохо с поиском и AI-выдачей

После того как сайт начал быстро открываться, мы посмотрели его глазами поисковика. Картина:

Что мы сделали с поиском за один день

Пять групп правок (G1–G5), без новых разработок, без миграций.

Страница политики ПДн. Создали страницу, перенесли текст с дочерней страницы, проверили — открывается, в карте сайта, в подвале работает.

Правила для поисковых ботов. Переписали файл robots.txt: 13 разделов, AI-боты Anthropic/OpenAI/Perplexity явно разрешены (для цитирования в ChatGPT и Perplexity), спам-парсеры заблокированы. Карту сайта (sitemap.xml) обновили: 55 страниц с правильными приоритетами.

Серверные настройки. Через файл .htaccess включили 6 заголовков безопасности: HSTS (защита от MITM), X-Frame-Options (защита от clickjacking), Referrer-Policy (защита приватности пользователей), убрали утечку информации о версии PHP. Старые перенаправления 302 → 301. www-версию домена принудительно отправляем на основной.

Описание главной страницы. Из 729 символов мусора — 197 символов прямого ответа: «ОЗСМ — российский производитель вибропогружателей с 1950 года…». Поисковик теперь понимает, что показывать в выдаче.

Микроразметка для AI. Заменили неправильную «цена = 0» на конструкцию «цена по запросу». Google Rich Results Validator показал зелёный. Добавили блок FAQ с 6 вопросами по моделям вибропогружателей — это самый цитируемый тип разметки в ChatGPT и Perplexity.

IndexNow. Подключили механизм мгновенного оповещения Яндекса и Bing при изменении страниц — теперь правки появляются в индексе за минуты, а не за дни.

Технически: что и куда положили

G1 — .htaccess:

  • 302 → 301 на основном rewrite.
  • www → non-www 301.
  • Strict-Transport-Security: max-age=63072000; includeSubDomains (2 года, без preload до прохождения hstspreload.org).
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • X-Frame-Options: DENY.
  • Header unset X-Powered-By (был leak PHP/7.3.x).
  • Cache-Control: public, max-age=... для статики — отложен. Beget Apache 2.4 без expr= extension, conditional <If> тоже недоступен.

G2 — SEO infrastructure:

  • robots.txt — static file в public_html/. 13 секций User-agent. AI-боты ClaudeBot, GPTBot, PerplexityBot, OAI-SearchBot, Google-Extended — Allow. Training scrapers (CCBot, anthropic-ai, cohere-ai, ImagesiftBot) — Disallow. MODX resource id=10 с тем же alias — unpublish.
  • sitemap.xml — static. 55 URL, пять уровней priority. /privacy-policy/ включён.
  • IndexNow — KEY 8d35edabb883d220f85cea185c59b22c, файл public_html/<KEY>.txt. Пинг возвращает 202.

G3 — MODX cleanup chunk id=88 (meta):

  • yandex-verification meta-tag: 7 дублей → 1.
  • <title> — Fenom conditional {$_modx->resource.longtitle ?: $_modx->resource.pagetitle} + fallback на siteName.
  • <meta name="description"> — Fenom conditional с fallback. Stuffed 729 chars → 197 chars BLUF через regex non-greedy .*? + s flag + known terminator.
  • <base href> — удалён.
  • keywords — удалён.

Аналогичные патчи на chunks 91/92/93 и file core123/elements/chunks/head.tpl. Активны только для template 10 (главная + privacy). Templates 4/5/6 рендерят <head> через неизвестный source — TBD (compiled Fenom cache, Beget edge caching или SEO_Schemas plugin override через OnLoadWebDocument).

G4 — JSON-LD: Plugin SEO_Schemas id=34. Product.offers.price="0" + InStockpriceSpecification + InStock (валидно для B2B «Цена по запросу»). На главной добавлен Organization, FAQPage с 6 Q/A.

G5 — /privacy-policy: MODX resource id=311 (alias privacy-policy, template 10), content скопирован из активного id=252 (15 841 char). published=1.

Гочи MODX:

  • MODX text/plain ресурсы стрипают newlines → robots.txt и sitemap.xml как static files в webroot. Apache RewriteCond %{REQUEST_FILENAME} !-f отдаёт static первым.
  • modx_system_settings columns = \key`/value` (backticks обязательны).
  • Real core path = core123/ (обфускация Beget), НЕ core/.
  • Beget Apache 2.4 без expr= extension и без <If> directive — Cache-Control conditional override через .htaccess не работает.

Бэкап перед каждой группой — в /home/a/adminnru/ozsm.ru/backup-pre-<group>-<timestamp>/.

Что получили в SEO

11 из 11 проверок пройдено. На стороне Яндекса и Google остаётся только время — индекс пересобирается за 1–4 недели после структурных правок такого масштаба.

Главное: сайт теперь готов цитироваться в AI-поиске. ChatGPT и Perplexity при запросе про вибропогружатели находят микроразметку FAQ и блок прямых ответов — это даёт ссылку на сайт в ответе AI вместо ссылки на конкурента.

Как мы потеряли половину результата за одну команду — и восстановили за день

После закрытия SEO-спринта пользователь сообщил: блок «География поставок» сломан, фотогалерея исчезла, на странице контактов чекбокс согласия пред-отмечен (это снова нарушение 152-ФЗ).

Что произошло. Накануне я делал бэкап одной настройки сайта стандартной командой базы данных. Команда выглядит как «сохрани одну строку». На самом деле она сохраняет: «удали таблицу — создай таблицу заново — вставь одну строку». На следующее утро я применил этот бэкап «обратно». Все 121 настроек сайта стёрлись, осталась одна.

Среди стёртого — все артефакты двухнедельной работы: код первой формы, лента фотогалереи, отложенная загрузка Яндекс.Метрики, верстка футера.

Что нас спасло. За время первого спринта я делал полный дамп базы перед каждой итерацией. У меня лежал бэкап от 24 апреля — 126 МБ, моментальный снимок до начала всех изменений. Из него извлекли только одну нужную таблицу (без затрагивания остальных 200+ таблиц). Поверх неё «накатили» правки последних дней из последовательной цепочки бэкапов — по одной правке от каждой итерации.

К вечеру работоспособность была восстановлена. Параллельно поймали и закрыли три каскадных проблемы: карта поставок перестала показываться (другая причина, кеш MODX стрипал регистрации JS), производительность снова упала (отключили кеш для исправления карты, потом нашли решение без отключения), чекбокс согласия был помечен заранее (нарушение 152-ФЗ, штраф 60–100 тыс. ₽).

Что сделали навсегда. Три правила записали в memory-файл, который доступен на любом нашем будущем проекте. Они описаны ниже в разделе «Уроки».

Технически: цепочка восстановления и три incident'а

Incident #1 — TRUNCATE. Команда mysqldump --where='id=88' modx_site_htmlsnippets > backup-chunk88.sql без --no-create-info всегда пишет DROP TABLE IF EXISTS + CREATE TABLE + filtered INSERTs. mysql < restore.sql неизбежно TRUNCATE-ит таблицу. Потеряны 121 chunks (B11–B14 sprint artifacts: chunk160-swiper, chunk142-kontact-form, chunk146-zayavka, chunk87 footer).

Recovery chain:

backup-pre-seo-20260424-114401/db-dump.sql   ⭐ 126 МБ FULL DB
backup-pre-b11-20260502_191110/
backup-pre-jquery-cleanup-20260502_194523/   ⭐ chunk87 footer
backup-pre-b12-a11y-20260502_201757/         ⭐ chunk160-swiper.html.bak
backup-pre-b13-smartcaptcha-20260502_211831/
backup-pre-b14-a11y-20260502_215036/         ⭐ chunk142, template4, resource297
  1. Из 126 МБ dump’а через sed -n '/CREATE TABLE.*site_htmlsnippets/,/CREATE TABLE/p' — restored только таблицу modx_site_htmlsnippets через cross-update pattern (rename target → temp_X, recreate clean, copy snippet/content columns → original, drop temp). Non-destructive.

  2. Поверх — Sprint 1 artifacts: chunk 88 «meta» из backup-pre-g3-verification, chunk home_hero_swiper (id=156) из B12, chunk 142 «kontact-form» из B14, template 4 + resource 297 + base.tpl + ozsm-cls.css из B14.

Incident #2 — Geosales карта не работает. На first hit OK, на subsequent — нет. Причина: MODX cacheable=true (default) → cached HTML output стрипает regClientScript() и regClientHTMLBlock() registrations. Fix: UPDATE modx_site_content SET cacheable=0 WHERE id=1 + &lazy=0 в [[GeosalesMap?]] call + direct <link> Geosales CSS injection в chunk 88.

Incident #3 — PSI regression из-за cacheable=0. Каждый request rebuild’ит full page через snippets → LCP скаканул с 1,9 с до 3,7 с.

Попытка 1: inline <script>var GeosalesConfig = {...};</script> directly в resource content. Fenom silent fail — страница 0 байт. Fenom видит фигурные скобки {...} JS object literal и пытается их парсить как template directive.

Попытка 2: patch chunk 88 через bash heredoc + PHP heredoc + complex regex для inject. Escape character mess. preg_replace вернул null. UPDATE с null = chunk 88 обнулён. Второй раз тот же тип ошибки за session.

Final solution — external JS file pattern. /assets/components/seo/geosales-lazy.js (deployed через scpssh php /tmp/install.php):

window.GeosalesConfig = {
  center: [53.24, 50.22], zoom: 10,
  action: 'list', cluster_icons: true,
  controls: ['zoomControl'], behaviors: ['drag','dblClickZoom'],
  clusterize: true, clickZoom: 12, clusterclickout: true,
};

if ('IntersectionObserver' in window) {
  const obs = new IntersectionObserver((entries) => {
    if (!entries[0].isIntersecting) return;
    obs.disconnect();
    loadMaps(); // sequential: api-maps → geosales/yandex.js → geosales/default.js
  }, { rootMargin: '400px' });
  obs.observe(document.getElementById('GeosalesMap'));
} else {
  loadMaps();
}

В chunk 88 — один <script src="/assets/components/seo/geosales-lazy.js" defer></script>. cacheable=1 restored. 5 hits stable, PSI вернулся к baseline.

Incident #4 — 152-ФЗ checkbox + description. Chunks 142 + 146 имели <input type="checkbox" checked> на consent (нарушение, штраф 60–100 тыс. ₽). Fix: checkedrequired (HTML5 form validation). Description chunk 88: 729 chars stuffed → 197 chars BLUF через scp PHP script (bash heredoc + PHP heredoc уже dead-on-arrival).

Layout polish. Три контейнера получили style="max-width:1480px;margin:0 auto;". Geosales col-md-2 + col-md-8 → flex layout (flex:0 0 220px nav + flex:1 1 auto;min-width:0 map) — min-width:0 обязательно для flex-child с длинным content.

Хронология спринта

Спринт ozsm.ru 2026-04 → 2026-05

Уроки для тех, кто планирует то же

Каждый из этих уроков мы получили ценой production-инцидента — не из best-practice guide. Один и тот же урок повторился несколько раз в одной сессии (особенно третий). Именно поэтому они стали правилами на уровне memory-файла, доступного из любого нашего следующего проекта.

Урок 1 — стандартный бэкап одной строки в базе данных уничтожает таблицу. Команда выглядит безопасно, последствия — катастрофические. Если вам нужно сохранить одну запись «на всякий случай» перед правкой — делайте это через PHP-скрипт или специальный флаг команды, который вырезает разрушающие части. Не через стандартный экспорт. И всегда держите полный снимок базы данных перед началом любого спринта правок — он спас нам неделю работы.

Урок 2 — не запускайте сложные команды через несколько уровней оболочек. В нашем случае это была команда вида «SSH → bash → PHP → регулярное выражение → база данных». На каждом уровне свои правила экранирования. Если одно перепутали — команда молча отрабатывает с null-значением и затирает рабочую запись. Решение: писать обычный файл скрипта на компьютере, копировать на сервер целиком, запускать одной командой.

Урок 3 — для динамических виджетов на старом сайте используйте отдельный JS-файл, а не код прямо в шаблоне. Шаблонизатор MODX (Fenom) видит фигурные скобки {...} в JavaScript и пытается их интерпретировать как свои инструкции. Страница «молча» становится пустой — без ошибки, без записи в лог. Один внешний JS-файл, подключённый через тег <script src="..." defer>, решает проблему навсегда. Этот же подход теперь используется на всех клиентских проектах, где есть Я.Карты, Я.Метрика или другие сторонние виджеты.

Технически: anti-patterns и patterns в коде

Урок 1 — mysqldump --where для restore single rows.

Anti-pattern:

mysqldump --where='id=X' db table > backup.sql
# позже:
mysql db < backup.sql   # TRUNCATE table, оставляет только row X

Pattern (если нужен single-row backup):

mysqldump --no-create-info --where='id=X' db table > backup-row.sql
# Это просто один INSERT — можно прогнать вместе с UPDATE.

Pattern (изолированный rollback одной строки):

$row = $modx->getObject('modChunk', 88);
file_put_contents("backup-chunk88-" . date("Ymd_His") . ".php",
    "<?php return " . var_export($row->toArray(), true) . ";");
// Rollback позже: include + setMany + save

Урок 2 — bash heredoc + PHP heredoc + regex.

Anti-pattern (срабатывало 4 раза за одну session):

ssh user@host "php -r \"
\\\$content = '...';
\\\$patched = preg_replace('/regex/s', '\\\$replacement', \\\$content);
file_put_contents('chunk.txt', \\\$patched);
\""

preg_replace вернёт null при любой syntax error в pattern, и file_put_contents запишет null = пустой файл.

Pattern:

cat > script.php <<'PHP'
<?php
require '/path/modx/index.php';
$chunk = $modx->getObject('modChunk', ['id' => 88]);
$content = $chunk->getContent();
$chunk->setContent($patched);
$chunk->save();
PHP
scp script.php user@host:/tmp/seo-patch.php
ssh user@host "php /tmp/seo-patch.php"

Урок 3 — external JS file для cacheable-safe dynamic features.

Anti-pattern:

<script>
window.GeosalesConfig = {center: [53.24, 50.22], zoom: 10};
</script>

Fenom видит {center: ...}, парсит как {var} или {if} directive. Silent fail, 0 байт.

Pattern: вся логика в /assets/components/<feature>/<feature>-lazy.js, конфиг через window.<FeatureName>Config = {...} или data-* атрибуты на placeholder-элементе. Подключение в head — <script src defer>. Cache-safe (cacheable=1 остаётся), defer-load, нет inline {}, нет риска silent 0-byte page.

Что осталось на следующий заход

Технический фронт закрыт. В backlog — задачи следующего уровня (контентовый сейл, авторитет):

Каждый пункт — отдельный мини-спринт или вообще не разработка. Главное уже сделано.

Главный вывод

Сайт 2018 года на старом движке можно довести до максимальных оценок производительности и SEO. Это не вопрос платформы — это вопрос дисциплины: убирать неиспользуемое, откладывать необязательное, делать ленивым то, что не нужно на первом экране.

Главные риски — не платформа, а наследие старых решений (две копии jQuery, мёртвые скрипты, тяжёлые embed-виджеты) и инструменты администрирования базы данных, которые проектировались до того как production-сайты могли пострадать от одной неосторожной команды.

Связано

См. также кейс ОЗСМ с итоговыми метриками и статью про lazy-load Яндекс.Карт, отдельный разбор замены Google reCAPTCHA на SmartCaptcha и гайд Mobile PSI 44→100 на legacy MODX с исходными цифрами по B11.3.