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
+ выключено осознанно, а не ради тишины. Во-первых, внутри
+ `
+
9. Файлы cookie и веб-аналитика
+
+ 9.1. Для сбора статистики посещаемости и оценки качества работы
+ Сайта Оператор использует сервисы веб-аналитики: Яндекс.Метрика
+ (ООО «Яндекс») и Google Analytics 4 (Google LLC), обрабатывающие
+ данные по поручению Оператора. Указанные сервисы устанавливают в
+ браузере пользователя файлы cookie и собирают технические данные:
+ IP-адрес, идентификатор устройства/браузера и параметры сессии, в
+ том числе UTM-метки источника перехода на Сайт.
+
+
+ 9.2. Данные, указанные в п. 9.1, используются исключительно для
+ статистики посещаемости и улучшения качества работы Сайта и не
+ используются Оператором для принятия каких-либо решений в отношении
+ конкретного посетителя.
+
+
+ 9.3. Пользователь вправе отказаться от использования файлов cookie
+ средствами настроек своего браузера (блокировка cookie), а также —
+ отдельно для Яндекс.Метрики — воспользоваться официальным
+ механизмом отказа:{" "}
+
+ yandex.ru/support/metrica/general/opt-out.html
+
+ . Отказ от cookie не ограничивает доступ к функциям Сайта.
+
+
+ 9.4. Оператор данных, указанных в настоящем разделе, — тот же, что
+ указан в разделе 2 настоящей Политики. Сроки хранения таких данных
+ определяются сервисами веб-аналитики самостоятельно и не превышают
+ сроков, указанных в разделе 6 настоящей Политики.
+
+
- 9. Контакты Оператора по вопросам обработки персональных данных
+ 10. Контакты Оператора по вопросам обработки персональных данных
E-mail:{" "}
diff --git a/tradein-mvp/frontend/src/app/mera-public/sitemap.ts b/tradein-mvp/frontend/src/app/mera-public/sitemap.ts
new file mode 100644
index 00000000..8d90f386
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/mera-public/sitemap.ts
@@ -0,0 +1,86 @@
+import type { MetadataRoute } from "next";
+
+import { PUBLIC_ORIGIN, PUBLIC_ROUTES } from "./content";
+
+/**
+ * sitemap.xml публичного сайта «МЕРА».
+ *
+ * ПОЧЕМУ ВЛОЖЕННЫЙ, А НЕ В КОРНЕ `app/`. В отличие от `robots.ts` (один на
+ * приложение, см. его шапку), sitemap лежит в этом сегменте, потому что весь
+ * его контент — короткие адреса `PUBLIC_ROUTES`, а сам модуль импортирует из
+ * `./content`. Next отдаёт его по `/trade-in/mera-public/sitemap.xml`; Caddy
+ * рерайтит на него короткий `meraocenka.ru/sitemap.xml` тем же правилом, что
+ * и `robots.txt` (devops-агент, параллельная задача).
+ *
+ * `lastModified` — ФИКСИРОВАННАЯ ДАТА-КОНСТАНТА, А НЕ `new Date()`. Next
+ * пытается статически пререндерить `sitemap.ts` на этапе сборки; `new Date()`
+ * в модульной области — недетерминированный вызов, который либо запекает
+ * дату СБОРКИ (что вводит в заблуждение: страницы не менялись в день
+ * деплоя), либо, если Next решит считать функцию динамической из-за него,
+ * убирает файл из статического вывода без видимой причины. Дата открытия
+ * индексации — честное значение: именно тогда весь список ссылок стал
+ * актуальным целиком. Двигать дату вперёд нужно вручную, вместе с реальным
+ * изменением состава/контента страниц.
+ */
+const LAST_MODIFIED = new Date("2026-09-10T00:00:00Z");
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ const url = (path: string) => `${PUBLIC_ORIGIN}${path}`;
+
+ return [
+ {
+ url: url(PUBLIC_ROUTES.home),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ {
+ url: url(PUBLIC_ROUTES.estimate),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "weekly",
+ priority: 0.9,
+ },
+ {
+ url: url(PUBLIC_ROUTES.articles),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "weekly",
+ priority: 0.7,
+ },
+ {
+ url: url(PUBLIC_ROUTES.articleHowTo),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "monthly",
+ priority: 0.6,
+ },
+ {
+ url: url(PUBLIC_ROUTES.business),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "monthly",
+ priority: 0.5,
+ },
+ {
+ url: url(PUBLIC_ROUTES.docs),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "monthly",
+ priority: 0.4,
+ },
+ {
+ url: url(PUBLIC_ROUTES.oferta),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
+ {
+ url: url(PUBLIC_ROUTES.refund),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
+ {
+ url: url(PUBLIC_ROUTES.privacy),
+ lastModified: LAST_MODIFIED,
+ changeFrequency: "yearly",
+ priority: 0.3,
+ },
+ ];
+}
diff --git a/tradein-mvp/frontend/src/app/robots.ts b/tradein-mvp/frontend/src/app/robots.ts
new file mode 100644
index 00000000..ff0f8f29
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/robots.ts
@@ -0,0 +1,42 @@
+import type { MetadataRoute } from "next";
+
+import { PUBLIC_ORIGIN } from "./mera-public/content";
+
+/**
+ * robots.txt для публичного сайта «МЕРА» (meraocenka.ru).
+ *
+ * ПОЧЕМУ ФАЙЛ ЛЕЖИТ В КОРНЕ `app/`, А НЕ В `app/mera-public/`. Next отдаёт
+ * `MetadataRoute.Robots` строго по адресу `/robots.txt` относительно
+ * `basePath` — с basePath=/trade-in это `/trade-in/robots.txt`. Короткий
+ * `meraocenka.ru/robots.txt` (без `/trade-in`) раздаёт Caddy отдельным
+ * `handle` (делает devops-агент параллельно) — он проксирует короткий путь на
+ * этот же маршрут.
+ *
+ * DISALLOW НАМЕРЕННО КОРОТКИЙ — ОДНА СТРОКА, А НЕ ПЕРЕЧЕНЬ ЗАКРЫТОГО КОНТУРА.
+ *
+ * Первая редакция перечисляла здесь весь B2B-периметр: `/v2`, `/admin`,
+ * `/scrapers`, `/history`, `/cache`, `/versions`, `/login`, `/team`,
+ * `/sale-share`, `/ui-preview`. Выглядело как защита, работало как её
+ * противоположность. Ни один из этих путей на meraocenka.ru не отдаётся
+ * вовсе: site-блок домена — белый список, всё вне его получает 404 (см.
+ * `handle { respond 404 }` в caddy/sites/apps.caddy). То есть запрещать
+ * тут нечего, а вот СПИСОК ИМЁН внутренних разделов robots.txt публикует
+ * всем — включая сканеры, которые читают его первым делом именно ради такой
+ * карты. Периметр держит Caddy, а robots.txt — файл для поисковых роботов,
+ * и лишнее знание в нём это чистый минус без плюса.
+ *
+ * `/api` остаётся: это единственное семейство путей, которое публичный домен
+ * действительно проксирует (`/trade-in/api/public/*`), — здесь запрет
+ * осмысленный, а не декоративный. Индексировать JSON-ответы ручек подсказок
+ * адреса поисковику незачем.
+ */
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/api"],
+ },
+ sitemap: `${PUBLIC_ORIGIN}/sitemap.xml`,
+ };
+}
diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
index 6299c473..6769b645 100644
--- a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
+++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
@@ -104,6 +104,19 @@ const PUBLIC_SHORT_PATHS = [
"/articles/kak-ocenit-kvartiru",
"/docs",
"/business",
+ // Машинные артефакты для поисковых роботов, а не страницы. React на них не
+ // рендерится вовсе — их отдают route handler'ы Next (`app/robots.ts` и
+ // `app/mera-public/sitemap.ts`), поэтому гвард на них физически не
+ // монтируется и practically эти две строки — no-op.
+ //
+ // И всё-таки они здесь. Гейт `RouteGuard.publicPaths.test.ts` держит
+ // инвариант «всё, что Caddy открыл наружу, гвард считает публичным»
+ // МЕХАНИЧЕСКИ, сверяя список с самим Caddyfile. Исключение из инварианта
+ // пришлось бы описывать отдельным списком, который начнёт протухать; две
+ // честные строки дешевле, и утверждение в них правдивое — эти адреса
+ // действительно публичные.
+ "/robots.txt",
+ "/sitemap.xml",
];
export function isPublicPath(rawPath: string): boolean {