From 671fef758e02b5aab16e9372b7e5df8518f0ccb9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 10 Sep 2026 16:52:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(mera):=20=D0=9C=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20GA4=20=D0=BD=D0=B0=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=BC=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=83=D1=80=D0=B5=20+=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=B0=D0=B9=D1=82=D0=B0=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ЗАЧЕМ. Статьи МЕРЫ публикуются с UTM-метками, но посмотреть, приходил ли по ним кто-нибудь, было физически нечем: веб-аналитики на публичном контуре не было вовсе. Заодно вскрылось, что «толкнуть в выдаче» тоже нельзя — всё дерево mera-public отдавало `robots: noindex, nofollow`. СЧЁТЧИКИ. Яндекс.Метрика и GA4 подключаются ТОЛЬКО в `mera-public/layout.tsx` и никогда в корневом `app/layout.tsx` — иначе счётчик уехал бы в закрытый контур (/v2, /admin, /scrapers, /history), где анонимных посетителей нет, а приватные маршруты сотрудников есть. Идентификаторы приходят build-time (`NEXT_PUBLIC_YM_ID` / `NEXT_PUBLIC_GA_ID`) — канон Dockerfile'а этого проекта: Next инлайнит NEXT_PUBLIC_* на сборке, runtime env их не подхватит. Пустое значение = тег не рендерится вовсе, никаких `ym(undefined)`. Оба build-arg'а прописаны в ОБОИХ блоках CI, включая retry-сборку без кеша. Вебвизор выключен намеренно. Он пишет ввод в поля, а на `/estimate` человек вводит адрес своей квартиры; раздел 9 политики этого не раскрывает. Включать следует одним заходом с правкой политики и маскировкой полей — в коде рядом записано, что именно понадобится. ЦЕЛИ ВОРОНКИ. Десять целей: клик по CTA, начало ввода адреса, адрес выбран, результат с разбивкой по вердикту (ok/thin/none), ошибка расчёта, ошибка валидации, отказ подсказок, показ платного тизера. Кнопок «Проверить квартиру» восемь штук в разных компонентах, все — обычные `` через PublicLink, поэтому вместо восьми копий onClick один делегированный слушатель на document: девятая кнопка подключится сама. Цель «оплата успешна» НЕ заведена — вызова checkout во фронте нет вовсе, PAYMENTS_ENABLED выключен, страницы возврата не существует; вешать её пока не на что. ИНДЕКСАЦИЯ. Снят noindex со всех публичных страниц, добавлены `app/robots.ts` и `app/mera-public/sitemap.ts`, metadataBase, canonical на КОРОТКИЕ адреса, openGraph и JSON-LD Article на главной статье. robots.txt и sitemap.xml разведены по двум разным handle в Caddy не от хорошей жизни: у Next robots.txt — конвенция корня app/, а sitemap живёт в сегменте маршрута, и формы путей не совпадают. 152-ФЗ. Раздел 9 «Файлы cookie и веб-аналитика» в политике (обработчики названы поимённо — этого требует ч. 3 ст. 6) + уведомляющий, не блокирующий баннер. Гейт «названий площадок в публичной копии быть не должно» получил узкое исключение ровно на аналитические словосочетания в политике; голое «Яндекс» как площадка остаётся запрещённым и там. ПОПУТНЫЙ БАГ (замер на живом проде 10.09.2026). `meraocenka.ru/articles/` с UTM-метками отдавал 301 на адрес БЕЗ query — матчер @meraShortSlash собирал цель из regex-захвата пути и терял параметры. Код ответа при этом оставался 301, поэтому смоук проблему не видел. Мессенджеры и автолинкификаторы дописывают слэш сами, то есть атрибуция терялась именно на трафике по опубликованной ссылке. Починено тем же приёмом, что у соседних матчеров; в смоук добавлена проверка буквального Location. ГЕЙТЫ. `isPublicPath` и периметр-тест узнали про новые машинные адреса; noindex-гейт развёрнут (падает, если флаг вернулся) и расширен на строковую форму `robots: "noindex"`; заведена проверка, что корневых `handle` в site-блоке не появляется без объявления — раньше эту дверь гейт не видел. Проверено: tsc и eslint чисто, `npm run build` проходит, robots.txt и sitemap.xml отдаются по нужным адресам, при пустых ID в HTML нет ни одного обращения к mc.yandex.ru и googletagmanager, `caddy validate` валиден, изоляция mera-public от B2B не нарушена. Два теста LoginPage падают и на нетронутом дереве — не наши. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ --- .forgejo/workflows/deploy-tradein.yml | 16 ++ caddy/sites/apps.caddy | 43 ++++- scripts/smoke-mera-perimeter.sh | 58 +++++++ tradein-mvp/docker-compose.yml | 10 ++ tradein-mvp/frontend/Dockerfile | 25 +++ .../__tests__/landing-numbers-gate.test.ts | 86 +++++++--- .../public-copy-no-platform-names.test.ts | 39 ++++- .../__tests__/public-perimeter.test.ts | 58 ++++++- .../_components/AnalyticsScripts.tsx | 155 ++++++++++++++++++ .../_components/estimate/EstimateFlow.tsx | 46 +++++- .../_components/estimate/ReportResult.tsx | 21 +++ .../_components/v3/CookieBanner.tsx | 134 +++++++++++++++ .../_components/v3/FreeCheckCard.tsx | 13 ++ .../src/app/mera-public/_lib/analytics.ts | 79 +++++++++ .../mera-public/articles/articles-content.ts | 2 +- .../articles/kak-ocenit-kvartiru/page.tsx | 42 ++++- .../src/app/mera-public/articles/page.tsx | 14 +- .../src/app/mera-public/business/page.tsx | 19 ++- .../frontend/src/app/mera-public/content.ts | 7 +- .../src/app/mera-public/docs/page.tsx | 19 ++- .../src/app/mera-public/estimate/page.tsx | 17 +- .../src/app/mera-public/landing-v3.module.css | 115 +++++++++++++ .../frontend/src/app/mera-public/layout.tsx | 62 ++++++- .../frontend/src/app/mera-public/page.tsx | 11 +- .../src/app/mera-public/privacy/page.tsx | 42 ++++- .../frontend/src/app/mera-public/sitemap.ts | 86 ++++++++++ tradein-mvp/frontend/src/app/robots.ts | 42 +++++ .../src/components/auth/RouteGuard.tsx | 13 ++ 28 files changed, 1215 insertions(+), 59 deletions(-) create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/AnalyticsScripts.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_components/v3/CookieBanner.tsx create mode 100644 tradein-mvp/frontend/src/app/mera-public/_lib/analytics.ts create mode 100644 tradein-mvp/frontend/src/app/mera-public/sitemap.ts create mode 100644 tradein-mvp/frontend/src/app/robots.ts diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 90b98b1a..4cab5fa7 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -511,12 +511,24 @@ jobs: # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, # см. frontend/Dockerfile комментарий у соответствующих ARG). + # NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION — + # ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и + # мета-теги верификации поисковых консолей. Пустая строка = скрипт + # счётчика НЕ рендерится вообще (контракт фронта, см. тот же + # Dockerfile-комментарий). Когда номера появятся — вписать + # литералом сюда И в retry-блок ниже (оба обязательны, иначе + # ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки + # образа (build-time bake, не runtime-правка на проде). build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} + NEXT_PUBLIC_YM_ID= + NEXT_PUBLIC_GA_ID= + NEXT_PUBLIC_YANDEX_VERIFICATION= + NEXT_PUBLIC_GOOGLE_VERIFICATION= cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | @@ -540,6 +552,10 @@ jobs: NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} + NEXT_PUBLIC_YM_ID= + NEXT_PUBLIC_GA_ID= + NEXT_PUBLIC_YANDEX_VERIFICATION= + NEXT_PUBLIC_GOOGLE_VERIFICATION= cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest diff --git a/caddy/sites/apps.caddy b/caddy/sites/apps.caddy index 5aacb83c..b8110feb 100644 --- a/caddy/sites/apps.caddy +++ b/caddy/sites/apps.caddy @@ -394,7 +394,14 @@ meraocenka.ru { # `trailingSlash: false` ответил бы на такой путь 308-редиректом на вариант # без слэша — то есть на ДЛИННЫЙ адрес, который handle ниже отправит 301 на # «/», и запрос закольцуется. - @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business + # /sitemap.xml дописан той же строкой, а не отдельным handle (как + # robots.txt ниже): Next отдаёт его по /trade-in/mera-public/sitemap.xml + # (src/app/mera-public/sitemap.ts) — ровно в форме, которую уже собирает + # rewrite `/trade-in/mera-public{path}` ниже. robots.txt так не смог бы: + # Next отдаёт его КОРНЕВЫМ /trade-in/robots.txt (конвенция Next — + # robots.txt существует только в корне app/, не под mera-public/), эта + # форма под `{path}` не попадает — отсюда отдельный handle у него. + @meraPages path /estimate /oferta /refund /privacy /articles /articles/kak-ocenit-kvartiru /docs /business /sitemap.xml handle @meraPages { rewrite * /trade-in/mera-public{path} reverse_proxy tradein-frontend:3000 { @@ -408,9 +415,28 @@ meraocenka.ru { # голый 404 (так было и до этого PR, с момента #2615). Заодно это # замыкает цепочку для длинных адресов со слэшем: они приходят на короткий # со слэшем и здесь нормализуются. + # БАГ, найденный замером на живом проде 10.09.2026: + # https://meraocenka.ru/articles/?utm_source=vc -> Location: + # https://meraocenka.ru/articles — query терялся, потому что + # `redir * /{re.shortslash.1}` собирает цель заново из regex-захвата + # пути и не переносит исходную query-строку. Вред тихий: статьи + # публикуются с UTM-метками, а мессенджеры и автолинкификаторы дописывают + # слэш к скопированной ссылке — то есть именно трафик по опубликованному + # посту терял атрибуцию, и 301 при этом честно отрабатывал (просто не + # туда), ошибки никто не видел. + # + # Тот же приём, что у @meraLongPages/@meraV3Gone ниже и выше: `uri + # strip_suffix /` снимает ровно завершающий слэш (сам путь уже сужен + # матчером до канонического короткого вида), `redir * {uri} permanent` + # переносит query как есть. Обёртка `route` обязательна по той же + # причине, что у соседей — внутри `handle` порядок директив определяет + # Caddy, и без неё `redir` выполнился бы раньше `uri`. @meraShortSlash path_regexp shortslash ^/(estimate|oferta|refund|privacy|articles|articles/kak-ocenit-kvartiru|docs|business)/$ handle @meraShortSlash { - redir * /{re.shortslash.1} permanent + route { + uri strip_suffix / + redir * {uri} permanent + } } # `/v3` — АДРЕС ПРЕВЬЮ, КОТОРОГО БОЛЬШЕ НЕТ. С 29.08.2026 выбранный вариант @@ -559,6 +585,19 @@ meraocenka.ru { } } + # robots.txt — ОТДЕЛЬНЫЙ handle, а не строка в @meraPages выше: у Next + # robots.txt — конвенция корня app/ (src/app/robots.ts), а не + # mera-public/, поэтому rewrite `/trade-in/mera-public{path}` его не + # обслужит — нужен свой rewrite на /trade-in/robots.txt. По образцу + # favicon.ico прямо над этим блоком, включая тот же периметр-скраб + # заголовка. + handle /robots.txt { + rewrite * /trade-in/robots.txt + reverse_proxy tradein-frontend:3000 { + header_up -X-Authenticated-User + } + } + # Публичный API МЕРЫ — ЕДИНСТВЕННЫЙ путь этого домена, доходящий до # бэкенда. Под /api/public/ по определению не лежит ничего закрытого: # гарантию даёт структура пакета app/api/public/, а не аккуратность этого diff --git a/scripts/smoke-mera-perimeter.sh b/scripts/smoke-mera-perimeter.sh index 952bc695..700d0072 100644 --- a/scripts/smoke-mera-perimeter.sh +++ b/scripts/smoke-mera-perimeter.sh @@ -71,6 +71,36 @@ check_post() { fi } +# check_redirect_location: сверяет буквальный заголовок Location редиректа, +# а не только код ответа — нужен там, где регресс не меняет код (301 +# остаётся 301), а меняет ТОЛЬКО цель (баг 10.09.2026 у @meraShortSlash в +# apps.caddy: query терялась молча, код ответа был зелёным всю дорогу). +# curl без -L (редирект не проходим), -D - дампит заголовки в stdout. +# +# ФОРМА LOCATION НЕ ФИКСИРУЕТСЯ. Caddy отдаёт цель редиректа так, как её +# собрала директива: для `redir * {uri}` это относительный путь +# (`/articles?utm_source=vc`), а для веток с явным хостом — абсолютный URL +# (`https://meraocenka.ru/articles?utm_source=vc`). Обе формы валидны по +# RFC 7231, браузер разрешает относительную сам, и переписывание одной ветки +# конфига на другую — не регресс, ради которого стоит ронять смоук. Поэтому +# сверяется ХВОСТ: путь с query, с какого бы префикса Location ни начинался. +# Ровно это и есть предмет проверки — что query дожила до цели. +check_redirect_location() { + local desc="$1" url="$2" expected_suffix="$3" + local location + location=$(curl -s -o /dev/null -D - --max-time 15 "$url" 2>/dev/null \ + | grep -i '^location:' | tr -d '\r' | sed 's/^[Ll]ocation: *//') + case "$location" in + "$expected_suffix"|*"$expected_suffix") + echo "PASS: $desc ($url -> Location: $location)" + ;; + *) + echo "FAIL: $desc ($url -> got Location '${location:-}', expected ending with '$expected_suffix')" + fail=1 + ;; + esac +} + echo "== МЕРА B2C perimeter smoke (ЭТАП 1) ==" # 1. Публичный домен отдаёт 200 анонимно. @@ -102,6 +132,14 @@ check "meraocenka.ru/estimate — public 200" "$BASE_MERA/estimate" 200 # человека на форму входа; отвалится handle — вернётся 404 вместо неё. check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200 +# 1d3. robots.txt / sitemap.xml — первое, что запрашивает поисковый краулер +# при индексации. robots.txt отдаётся ОТДЕЛЬНЫМ handle (Next-конвенция: +# файл в корне app/, а не под mera-public/), sitemap.xml — той же строкой +# что и остальные @meraPages (см. комментарии в apps.caddy). Отвалится +# любой из handle — сайт не проиндексируется вовсе. +check "meraocenka.ru/robots.txt — public 200" "$BASE_MERA/robots.txt" 200 +check "meraocenka.ru/sitemap.xml — public 200" "$BASE_MERA/sitemap.xml" 200 + # 1e. Длинные адреса поддерева отдают 301 на короткие: у страницы один # канонический адрес, а старые ссылки и закладки продолжают работать. # ГОЛЫЙ /trade-in/mera-public — регресс на баг 15.08.2026: прежний матчер @@ -110,6 +148,16 @@ check "meraocenka.ru/business — public 200" "$BASE_MERA/business" 200 check "meraocenka.ru длинный корень — 301 на /" "$BASE_MERA/trade-in/mera-public" 301 check "meraocenka.ru длинная оферта — 301 на /oferta" "$BASE_MERA/trade-in/mera-public/oferta" 301 +# 1f. Регресс-тест на баг 10.09.2026 (замер на живом проде): редирект со +# слэшем терял query-строку — статьи публикуются с UTM-метками, а +# мессенджеры и автолинкификаторы дописывают слэш к скопированной ссылке, +# то есть именно этот трафик терял атрибуцию молча (код ответа +# оставался 301, поэтому предыдущая версия смоука проблему не ловила). +# Проверяем сам факт 301 (уже покрыт проверками check выше по коду) И +# буквальный Location — только вторая половина ловит регресс. +check_redirect_location "meraocenka.ru/articles/?utm_source=vc — query сохраняется на редиректе" \ + "$BASE_MERA/articles/?utm_source=vc" "/articles?utm_source=vc" + # 2. B2B-путь на публичном домене — 404 (allowlist-by-default), не 200/401. check "meraocenka.ru/v2 — B2B path must 404" "$BASE_MERA/v2" 404 @@ -226,6 +274,16 @@ check "trade-in /api/v1/admin/proxies — 404 anonymous (существующа check "trade-in /api/v1/admin/users — 404 anonymous (несуществующая — тот же ответ)" \ "$BASE_MAIN/trade-in/api/v1/admin/users" 404 +# 3b. sitemap.xml публичного лэндинга МЕРЫ не должен просачиваться через +# закрытый B2B-контур gendsgn.ru — корень gendsgn.ru принадлежит Site +# Finder, у него нет своего route на /sitemap.xml под МЕРУ, allowlist-by- +# default этого site-блока (как и у meraocenka.ru) отдаёт 404 на всё +# незаявленное. NB: не проверено вживую на проде (нет approval на prod- +# доступ в рамках этой сессии) — если у Site Finder когда-нибудь появится +# СВОЙ /sitemap.xml, ожидание 404 придётся пересмотреть на 200-с-другим- +# содержимым. +check "gendsgn.ru/sitemap.xml — must NOT serve MERA sitemap (закрытый контур)" "$BASE_MAIN/sitemap.xml" 404 + # 4. gendsgn.ru/api/v1/admin/* отдаёт 401 анониму (auth gate стоит ДО роутинга # в FastAPI — конкретный путь неважен, любой /api/v1/admin/* перехватывается # на уровне Caddy до бэкенда). diff --git a/tradein-mvp/docker-compose.yml b/tradein-mvp/docker-compose.yml index 341f27fe..43534b0c 100644 --- a/tradein-mvp/docker-compose.yml +++ b/tradein-mvp/docker-compose.yml @@ -59,6 +59,16 @@ services: build: context: ./frontend dockerfile: Dockerfile + # Паритет с prod build-args (.forgejo/workflows/deploy-tradein.yml, + # job build-frontend): те же 4 счётчика/SEO-переменные, тот же пустой + # дефолт (= "счётчик не подключается", см. frontend/Dockerfile). + # Локально включить: экспортировать переменную в shell ДО + # `docker compose build` — Compose подставит её сюда вместо "". + args: + NEXT_PUBLIC_YM_ID: "${NEXT_PUBLIC_YM_ID:-}" + NEXT_PUBLIC_GA_ID: "${NEXT_PUBLIC_GA_ID:-}" + NEXT_PUBLIC_YANDEX_VERIFICATION: "${NEXT_PUBLIC_YANDEX_VERIFICATION:-}" + NEXT_PUBLIC_GOOGLE_VERIFICATION: "${NEXT_PUBLIC_GOOGLE_VERIFICATION:-}" container_name: tradein-frontend restart: unless-stopped environment: diff --git a/tradein-mvp/frontend/Dockerfile b/tradein-mvp/frontend/Dockerfile index 6965c12a..9695f441 100644 --- a/tradein-mvp/frontend/Dockerfile +++ b/tradein-mvp/frontend/Dockerfile @@ -30,6 +30,31 @@ ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ARG NEXT_PUBLIC_ENABLE_PREVIEW="" ENV NEXT_PUBLIC_ENABLE_PREVIEW=$NEXT_PUBLIC_ENABLE_PREVIEW +# Аналитика и SEO-верификация (Яндекс.Метрика, GA4, Яндекс.Вебмастер, +# Google Search Console) — тот же контракт, что у переменных выше: Next.js +# инлайнит NEXT_PUBLIC_* в статические бандлы на `npm run build`, runtime +# env их НЕ подхватит, поэтому это build-arg, а не рантайм-конфиг. +# +# ПУСТОЙ ДЕФОЛТ — ЧАСТЬ КОНТРАКТА ФРОНТА, не временная заглушка: пустое +# значение означает «счётчик/мета-тег не рендерится вообще» (весь +# соответствующий script/meta отсутствует в разметке) — так решает +# frontend-код, потребляющий эти env (Dockerfile сам их не читает). +# +# СЧЁТЧИКОВ ПОКА НЕТ — владелец заведёт их позже. Когда появятся номера: +# 1) вписать литералом в build-args джоба build-frontend +# (.forgejo/workflows/deploy-tradein.yml) — В ОБОИХ блоках, основном +# И retry-без-кеша, иначе ретрай молча уедет без счётчика; +# 2) пересобрать образ — смена значения ТРЕБУЕТ build (build-time bake), +# одной правки runtime-конфига на проде недостаточно. +ARG NEXT_PUBLIC_YM_ID="" +ENV NEXT_PUBLIC_YM_ID=$NEXT_PUBLIC_YM_ID +ARG NEXT_PUBLIC_GA_ID="" +ENV NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID +ARG NEXT_PUBLIC_YANDEX_VERIFICATION="" +ENV NEXT_PUBLIC_YANDEX_VERIFICATION=$NEXT_PUBLIC_YANDEX_VERIFICATION +ARG NEXT_PUBLIC_GOOGLE_VERIFICATION="" +ENV NEXT_PUBLIC_GOOGLE_VERIFICATION=$NEXT_PUBLIC_GOOGLE_VERIFICATION + # Версия продукта («Мера») + короткий git SHA + дата сборки — ДОЛЖНЫ быть # build-time ARG (не runtime env): Next.js инлайнит NEXT_PUBLIC_* в статические # бандлы на `npm run build`, а этот build context (./tradein-mvp/frontend) не diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts index 328fbf4d..0a5362ab 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/landing-numbers-gate.test.ts @@ -278,28 +278,76 @@ describe("гейт витринных чисел (#2904)", () => { }); /** - * НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО. Проверка на подстроку остаётся - * зелёной при `index: true` — то есть ровно тогда, когда страница уходит в - * индекс. Поэтому сверяется значение. + * ГЕЙТ ПЕРЕВЁРНУТ (10.09.2026). До этой правки всё дерево было закрыто от + * поиска (`robots: {index:false, follow:false}` в layout.tsx и на пяти + * подстраницах), и тест следил, чтобы флаг НЕ потерялся раньше времени. + * Индексация открыта решением владельца — теперь опасность обратная: кто-то + * по привычке (copy-paste соседней страницы, откат мерджа) вернёт noindex + * молча, и сайт выпадет из поиска без единого сигнала в CI. Поэтому + * проверка развёрнута: падать, если запрещающий индексацию блок ВЕРНУЛСЯ. * - * ПРОВЕРЯЕТСЯ ПАРА, А НЕ ОДИН ФАЙЛ. Пока витрина жила на `v3/page.tsx`, у - * неё был собственный `export const metadata` и флаг стоял в двух местах — - * обоих и требовали. С переездом в корень второй экземпляр снят намеренно - * (см. шапку `page.tsx`): метаданные держит только layout. Требовать - * `noindex` в `page.tsx` теперь значило бы требовать вернуть дубль, а просто - * убрать проверку — оставить дыру: страничные `metadata` в Next - * ПЕРЕКРЫВАЮТ layout, и `index: true` на уровне корня погасил бы флаг, не - * тронув файла, за которым тут следят. Отсюда две половины: в layout стоит - * noindex, а корень своих метаданных не заводит вовсе. + * НАЛИЧИЯ СЛОВА `robots` НЕДОСТАТОЧНО — регэксп по-прежнему сверяет именно + * значение `index:false, follow:false`, а не факт упоминания ключа: у + * страниц есть легитимный `openGraph`/`alternates` рядом, трогать их эта + * проверка не должна. + * + * СПИСОК ФАЙЛОВ — те же места, где раньше стоял флаг: layout.tsx (общий для + * всего поддерева) и пять публичных подстраниц, у каждой из которых + * `metadata.robots` мог быть выставлен персонально и перекрыть layout. + * Корневой `page.tsx` сюда не входит: у него, как и раньше, своих metadata + * нет вовсе (следующая проверка это отдельно охраняет), значит и вернуться + * флагу там неоткуда. */ - const NOINDEX = /robots:\s*\{\s*index:\s*false\s*,\s*follow:\s*false\s*\}/u; + /** + * ФОРМ ЗАПРЕТА НЕСКОЛЬКО, И ЛОВИТЬ НАДО ВСЕ. + * + * Первая редакция этой проверки сверяла ровно одну запись — + * `robots: { index: false, follow: false }`, — потому что именно она и + * стояла в снятых флагах. Но Next принимает `metadata.robots` и строкой + * (`robots: "noindex"`), и объектом с одним полем (`robots: { index: + * false }`), и через `googleBot`. Любая из этих форм закрывает страницу от + * индексации ровно так же, а гейт, знающий только третью, пропустил бы их + * молча — то есть охранял бы формулировку, а не свойство. + * + * Поэтому две проверки. Первая ловит `index: false` в любом объекте + * `robots` (порядок и состав полей не важны), вторая — строковую форму со + * словом `noindex`. `openGraph`/`alternates` рядом ни под одну не подпадают: + * обе требуют именно ключа `robots`. + */ + const NOINDEX_OBJECT = /robots:\s*\{[^}]*\bindex:\s*false/u; + const NOINDEX_STRING = /robots:\s*(['"`])[^'"`]*noindex/u; - it("в layout.tsx стоит noindex, а не просто слово robots", () => { - expect( - read("layout.tsx"), - "layout.tsx: витрина открыта поисковикам — маршрут наружу ещё не открыт", - ).toMatch(NOINDEX); - }); + /** + * Юридические страницы (`oferta`, `refund`, `privacy`) в список НЕ входят + * намеренно: флага индексации у них не было и до этой правки — они + * унаследовали `noindex` от layout, а собственного `metadata.robots` не + * объявляли. Возврат флага им грозит только через layout, а layout здесь + * проверяется. Заводить для них строки значило бы утверждать, что у них + * есть свой `metadata.robots`, которого нет. + */ + const PUBLIC_INDEXED_FILES = [ + "layout.tsx", + "estimate/page.tsx", + "articles/page.tsx", + "articles/kak-ocenit-kvartiru/page.tsx", + "docs/page.tsx", + "business/page.tsx", + ]; + + it.each(PUBLIC_INDEXED_FILES)( + "%s не запрещает индексацию — noindex не вернулся", + (rel) => { + const source = read(rel); + expect( + source, + `${rel}: в metadata.robots снова стоит index:false — страница опять закрыта от поиска`, + ).not.toMatch(NOINDEX_OBJECT); + expect( + source, + `${rel}: в metadata.robots снова стоит строковый noindex — страница опять закрыта от поиска`, + ).not.toMatch(NOINDEX_STRING); + }, + ); it("корень не заводит своих metadata — иначе noindex из layout перекрыт незаметно", () => { expect( diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts index 882d82f4..e33b7d08 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts @@ -129,6 +129,43 @@ function stripComments(text: string): string { .join("\n"); } +/** + * ИМЕНОВАНИЕ ОБРАБОТЧИКА В ПОЛИТИКЕ КОНФИДЕНЦИАЛЬНОСТИ — ЗАКОННОЕ ИСКЛЮЧЕНИЕ. + * + * Гейт закрывает один конкретный риск: посетитель не должен видеть, ПО КАКИМ + * ПЛОЩАДКАМ собраны данные его оценки. «Яндекс» в разделе про cookie — это + * не площадка-источник, а обработчик персональных данных по поручению + * Оператора, и ч. 3 ст. 6 152-ФЗ прямо требует его назвать: политика, в + * которой написано «некий сервис веб-аналитики», раскрытием не является. + * То есть здесь запрет гейта и требование закона направлены в разные + * стороны, и выигрывает закон. + * + * ИСКЛЮЧЕНИЕ НАМЕРЕННО УЗКОЕ — не «не проверять privacy/page.tsx». Из строки + * маскируются только сами аналитические словосочетания; ГОЛОЕ «Яндекс» в + * значении площадки остаётся запрещённым и в политике тоже. Поэтому попытка + * написать в юр-документе «данные Яндекс.Недвижимости» гейт по-прежнему + * уронит — а именно ради этого он и заведён. + */ +const LEGAL_PROCESSOR_MENTIONS: Array<{ file: string; allow: RegExp }> = [ + { + file: join("mera-public", "privacy", "page.tsx"), + // Словоформы падежей («Яндекс.Метрики», «Яндекс.Метрике») — `\p{L}*`. + allow: /Яндекс\.Метрик\p{L}*|ООО\s+«Яндекс»/gu, + }, +]; + +/** Маскирует законные упоминания обработчика ПДн до проверки гейта. */ +function maskLegalMentions(file: string, text: string): string { + let masked = text; + for (const { file: suffix, allow } of LEGAL_PROCESSOR_MENTIONS) { + if (!file.endsWith(suffix)) continue; + // Замена одинаковой длины: номера строк в отчёте о находке не должны + // съехать, а «×» не входит в `\p{L}` и новых слов не создаёт. + masked = masked.replace(allow, (m) => "×".repeat(m.length)); + } + return masked; +} + /** * true, если `needle` встречается в `line` как отдельное слово — не часть * более длинного TS-идентификатора (`CianValuationSummary`, `cianId`). @@ -158,7 +195,7 @@ describe("публичная копия не содержит реальных const hits: string[] = []; for (const file of files) { const raw = readFileSync(file, "utf8"); - const text = stripComments(raw); + const text = maskLegalMentions(file, stripComments(raw)); text.split("\n").forEach((line, i) => { if (hasWholeWord(line, needle)) { hits.push(`${file}:${i + 1}`); diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts index e356272d..65e2ee83 100644 --- a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-perimeter.test.ts @@ -109,6 +109,23 @@ describe("короткие адреса публичного домена", () = */ const PREVIEW_ONLY: string[] = []; + /** + * Машинные артефакты для поисковых роботов: их раздаёт публичный домен, но + * страницами они не являются и в `PUBLIC_ROUTES` им не место. + * + * `PUBLIC_ROUTES` — не просто перечень открытых адресов, это ТИП цели для + * `PublicLink` (`to` принимает только его ключи). Занеси сюда `sitemap.xml` + * — и в разметке станет выразимой ссылка «перейти на карту сайта», которой + * не должно существовать: карта сайта адресована роботу, а не посетителю. + * Поэтому отдельный список, а не строка в реестре маршрутов. + * + * Здесь только `/sitemap.xml`: он единственный живёт в матчере @meraPages, + * который разбирает проверка ниже. `/robots.txt` открыт своим `handle` + * (у Next это конвенция корня `app/`, и форма пути другая), в этот разбор + * не попадает и потому в списке не нужен. + */ + const MACHINE_ONLY = ["/sitemap.xml"]; + const meraPagesLine = meraBlock.match(/@meraPages path ([^\n]+)/); it("каждый маршрут из PUBLIC_ROUTES раздаётся публичным доменом", () => { @@ -134,7 +151,11 @@ describe("короткие адреса публичного домена", () = */ it("на публичном домене не открыто ничего сверх известных страниц", () => { const served = meraPagesLine?.[1].split(/\s+/).filter(Boolean) ?? []; - const known = new Set([...Object.values(PUBLIC_ROUTES), ...PREVIEW_ONLY]); + const known = new Set([ + ...Object.values(PUBLIC_ROUTES), + ...PREVIEW_ONLY, + ...MACHINE_ONLY, + ]); const unexpected = served.filter((p) => !known.has(p)); expect( unexpected, @@ -142,6 +163,41 @@ describe("короткие адреса публичного домена", () = ).toEqual([]); }); + /** + * ВТОРАЯ ДВЕРЬ В ТОТ ЖЕ ПЕРИМЕТР — отдельные `handle`, мимо @meraPages. + * + * Проверка выше разбирает ОДНУ строку матчера, поэтому видит только те + * адреса, что открыты через него. Но корневой путь можно открыть и вторым + * способом — своим `handle /<путь>`, и таких в блоке уже несколько: + * `/favicon.ico` (браузер просит его сам), `/robots.txt` (у Next это + * конвенция корня `app/`, форма пути не совпадает с rewrite'ом @meraPages), + * `/metrics` (наоборот, явный отказ). Пока их было два и оба завёл один + * человек в один заход, дыра была теоретической; с добавлением robots.txt + * способ стал рабочим приёмом, а значит им воспользуются снова — и + * следующий такой `handle` откроет наружу что-нибудь, о чём приложение не + * знает, полностью мимо гейта выше. + * + * Поэтому список именной. Новый корневой `handle` = новая строка здесь, + * ровно как новый путь = строка в PUBLIC_ROUTES. Матчер берёт только + * односегментные пути с точкой (файлы) и `/metrics`: вложенные + * `/trade-in/...`-хопы — это внутренняя проводка basePath, а не публичная + * поверхность, у них своя проверка ниже (B2B-пути обязаны отдавать 404). + */ + const ROOT_HANDLES_ALLOWED = ["/metrics", "/favicon.ico", "/robots.txt"]; + + it("корневые handle публичного домена — только объявленные", () => { + const declared = [...meraBlock.matchAll(/^\s*handle\s+(\/[^\s*{]+)\s*\{/gm)] + .map((m) => m[1]) + .filter((p) => p === "/metrics" || /^\/[^/]+\.[^/]+$/.test(p)); + const unexpected = declared.filter((p) => !ROOT_HANDLES_ALLOWED.includes(p)); + expect( + unexpected, + `корневой handle открыт в Caddy, но не объявлен в тесте: ${unexpected}. ` + + `Если путь действительно должен быть публичным — добавь его в ` + + `ROOT_HANDLES_ALLOWED вместе с объяснением, зачем ему отдельный handle.`, + ).toEqual([]); + }); + /** * Длинные адреса обязаны редиректить на короткие — иначе разосланные ссылки * и закладки превращаются в 404. Список тоже поимённый (регексп с захватом diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/AnalyticsScripts.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/AnalyticsScripts.tsx new file mode 100644 index 00000000..49faa074 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/AnalyticsScripts.tsx @@ -0,0 +1,155 @@ +"use client"; + +/** + * AnalyticsScripts — подключение Яндекс.Метрики и GA4 к публичному контуру + * «МЕРЫ». Монтируется ОДИН раз в `app/mera-public/layout.tsx` (другим + * агентом — этот файл сам себя никуда не встраивает). + * + * `strategy="afterInteractive"` (а не `beforeInteractive`): счётчики не + * нужны для отрисовки первого экрана и не должны конкурировать за полосу + * пропускания с шрифтами/CSS витрины — они догружаются, когда страница уже + * интерактивна. Оба блока рендерятся независимо: пустой `NEXT_PUBLIC_YM_ID` + * или `NEXT_PUBLIC_GA_ID` (стейджинг, превью-сборки, пока не заведён боевой + * счётчик) просто убирает соответствующий тег — `ym(undefined, ...)` или + * `gtag.js?id=` с пустым id не отправляются вовсе. + * + * ДЕЛЕГИРОВАННЫЙ КЛИК ПО CTA «ПРОВЕРИТЬ КВАРТИРУ». Ссылка на `/estimate` + * продублирована минимум в восьми местах (HeaderV3, StickyCtaV3, TwoPathsV3, + * ObjectionsV3, GuessGameV3, FooterV3, articles/page.tsx, + * articles/kak-ocenit-kvartiru/page.tsx) — и это везде обычный `` через + * `PublicLink`, без onClick. Вешать обработчик в восьми файлах хуже, чем + * здесь один: восемь копий одного и того же кода почти гарантированно + * разъедутся при следующей правке (кто-то поправит формулировку цели в одном + * месте и забудет остальные семь), а при появлении девятой кнопки её тоже + * пришлось бы не забыть подключить руками. Один слушатель `click` на + * `document` в bubble-фазе ловит все восемь мест и любые будущие — ссылка + * определяется по факту `href="/estimate"`, а не по компоненту-источнику. + * `capture` не нужен: обычный bubble долетает от любого вложенного элемента + * ссылки (иконки, span'а с текстом) до document, порядок всплытия здесь не + * важен — обработчик ничего не отменяет и не блокирует переход. + */ + +import Script from "next/script"; +import { useEffect } from "react"; + +import { track } from "../_lib/analytics"; + +const YM_ID = process.env.NEXT_PUBLIC_YM_ID; +const GA_ID = process.env.NEXT_PUBLIC_GA_ID; + +export function AnalyticsScripts() { + useEffect(() => { + // Дедуп двойного клика. Переход на `/estimate` — обычная навигация по + // ``, и до выгрузки страницы проходит заметное время: нетерпеливый + // посетитель успевает щёлкнуть дважды, и в отчёте это выглядит как два + // разных достижения цели. Окно в 1 с отсекает дребезг, но не мешает + // засчитать осмысленный повторный клик после возврата назад — при + // возврате компонент монтируется заново и замыкание обнуляется. + let lastCtaAt = 0; + + function handleClick(event: MouseEvent) { + const target = event.target as HTMLElement | null; + const link = target?.closest('a[href="/estimate"]'); + if (!link) return; + + const now = Date.now(); + if (now - lastCtaAt < 1000) return; + lastCtaAt = now; + // Текст ссылки — не адрес и не персональные данные, безопасно слать + // как параметр цели. Обрезаем на случай длинной формулировки CTA. + const place = (link.textContent ?? "").trim().slice(0, 60); + track("cta_estimate", { place }); + } + + document.addEventListener("click", handleClick); + return () => document.removeEventListener("click", handleClick); + }, []); + + return ( + <> + {YM_ID && ( + <> + {/* ВЕБВИЗОР ВЫКЛЮЧЕН ОСОЗНАННО, а не забыт. + + Он пишет запись действий посетителя, включая ввод в поля формы + (маскируется по умолчанию только `input[type=password]`), а на + `/estimate` человек вводит адрес своей квартиры. Раздел 9 + политики конфиденциальности объявляет сбор IP, идентификатора + устройства, параметров сессии и UTM-меток — записи действий там + нет. Пока раскрытие не покрывает запись, включать её нельзя: + 152-ФЗ требует, чтобы политика описывала фактически собираемое, + а не наоборот. + + Задача, ради которой ставились счётчики, — увидеть источники + переходов и прохождение воронки; для неё вебвизор не нужен + вовсе. Включать его следует одним заходом с двумя правками: + (1) отдельная фраза про запись действий в п. 9.1 + `privacy/page.tsx`, (2) маскировка полей ввода адреса и + телефона через `ym-hide-content` / `data-ym-disable-keys`. */} + + {/* Без JS счётчик всё равно должен считать визит — стандартный + пиксель Метрики, обязателен по её же документации. + + `next/image` здесь неприменим принципиально, и правило eslint + выключено осознанно, а не ради тишины. Во-первых, внутри + `