diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py index 8fc3c5b4..79c782f3 100644 --- a/ops/glitchtip-auth-forwarder/forwarder.py +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -8,6 +8,13 @@ Persistent offset в /state/offset.json — не дублируем при resta Throttle: при >10 401 events за 60s — однократный digest event (чтобы не флудить GlitchTip storm'ом); индивидуальные events во время storm пропускаются. +before_send=_drop_basic_auth_noise (glitchtip-noise фикс): все события отсюда +дропаются перед отправкой в GlitchTip — 401 от неаутентифицированного запроса +не ошибка сервиса, это боты сканируют закрытый basic_auth'ом сайт. Раньше это +был крупнейший источник шума в трекере (3 738 issue). Скрипт по-прежнему тэйлит +лог и печатает `[forwarder] 401 event sent: ...` в stdout (docker logs) — просто +больше не шлёт эти события в issue-трекер. Смотри `_drop_basic_auth_noise` docstring. + Реальный Caddy JSON access log (v2) структура: { "level": "info", @@ -73,6 +80,41 @@ _shutdown = False _last_exc_sent: float = 0.0 _EXC_THROTTLE_S: float = 300.0 +# event_type-теги, которыми emit_event/emit_digest помечают КАЖДОЕ отправляемое +# событие (см. scope.set_tag("event_type", ...) ниже) — используются как ключ +# для before_send-фильтра. +_BASIC_AUTH_EVENT_TYPES = frozenset({"basic_auth_failed", "basic_auth_storm"}) + + +def _drop_basic_auth_noise(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] + """before_send-фильтр: 401 неаутентифицированного basic_auth-запроса — НЕ + ошибка сервиса, а expected-поведение сканеров-ботов, ломящихся в закрытый + basic_auth'ом gendsgn.ru (`GET /wp-admin/install.php` и подобное). До этого + фикса emit_event/emit_digest слали КАЖДЫЙ такой 401 individual-событием (или + storm-digest) в GlitchTip — remote_ip в message/тегах раздувал кардинальность + (3 738 issue, 2 019 различных заголовков, топ — 222 события на «GET + /wp-admin/install.p…»), топя содержательные алерты (OperationalError, sweep + failures) в шуме сканеров. + + Дропаем НА ИСТОЧНИКЕ (before_send), не постфактум-чисткой issue-трекера — + так шум не появляется вообще, а не изредка удаляется руками. Фильтруем по + тегу `event_type`, который ставят ТОЛЬКО emit_event/emit_digest — необработанные + исключения самого форвардера (`capture_exception` в конце `main()`, реальный + баг скрипта) этот тег не несут и проходят фильтр как есть (см. `except + Exception` ниже в `main()`). + """ + tags = event.get("tags") + event_type = None + if isinstance(tags, dict): + event_type = tags.get("event_type") + elif isinstance(tags, list): + # sentry_sdk в некоторых версиях сериализует tags как list[tuple[str, str]] + # вместо dict — на всякий случай поддерживаем обе формы. + event_type = dict(tags).get("event_type") if tags else None + if event_type in _BASIC_AUTH_EVENT_TYPES: + return None + return event + def _signal_handler(signum: int, frame: object) -> None: global _shutdown @@ -221,6 +263,7 @@ def main() -> None: traces_sample_rate=0.0, attach_stacktrace=False, send_default_pii=False, + before_send=_drop_basic_auth_noise, # Отключаем интеграции которые не нужны тонкому sidecar default_integrations=False, ) diff --git a/ops/glitchtip-auth-forwarder/test_forwarder.py b/ops/glitchtip-auth-forwarder/test_forwarder.py new file mode 100644 index 00000000..a9c089b3 --- /dev/null +++ b/ops/glitchtip-auth-forwarder/test_forwarder.py @@ -0,0 +1,77 @@ +"""Тесты для `_drop_basic_auth_noise` (before_send-фильтр, glitchtip-noise). + +Раньше форвардер слал КАЖДЫЙ basic_auth 401 (сканеры-боты, ломящиеся в закрытый +basic_auth'ом gendsgn.ru) individual-событием в GlitchTip — 3 738 issue, 2 019 +различных заголовков (remote_ip раздувал кардинальность), топя содержательный +сигнал. `_drop_basic_auth_noise` дропает эти события НА ИСТОЧНИКЕ (before_send), +но НЕ должен трогать unhandled-ошибки самого форвардера (реальный баг скрипта — +`capture_exception` без `event_type`-тега, аналог "500 должен пройти"). +""" + +from __future__ import annotations + +import os + +# DSN обязателен на module-level (`os.environ["GLITCHTIP_DSN"]`, fail-fast) — задаём +# ДО импорта forwarder.py, иначе импорт падает KeyError. +os.environ.setdefault("GLITCHTIP_DSN", "http://test@localhost/1") + +from forwarder import _BASIC_AUTH_EVENT_TYPES, _drop_basic_auth_noise + + +def test_drops_individual_basic_auth_401() -> None: + """emit_event() тегирует event_type=basic_auth_failed — 401 от бота-сканера, + не ошибка сервиса, должен быть отброшен (return None).""" + event = { + "tags": {"event_type": "basic_auth_failed", "remote_ip": "95.165.147.218"}, + "message": "basic_auth 401 — GET /wp-admin/install.php from 95.165.147.218", + } + assert _drop_basic_auth_noise(event, {}) is None + + +def test_drops_basic_auth_storm_digest() -> None: + """emit_digest() тегирует event_type=basic_auth_storm — тоже 401-класс, тоже + не ошибка сервиса, дропаем.""" + event = { + "tags": {"event_type": "basic_auth_storm"}, + "message": "basic_auth storm — 15 failed attempts in 60s", + } + assert _drop_basic_auth_noise(event, {}) is None + + +def test_drops_when_tags_serialized_as_list_of_tuples() -> None: + """Некоторые версии sentry_sdk сериализуют tags как list[tuple[str, str]] + вместо dict — фильтр обязан поддерживать обе формы.""" + event = {"tags": [("event_type", "basic_auth_failed")]} + assert _drop_basic_auth_noise(event, {}) is None + + +def test_passes_through_forwarder_own_crash() -> None: + """500-аналог: unhandled exception самого форвардера (capture_exception в + конце main(), реальный баг скрипта — напр. PermissionError на STATE_FILE) не + несёт event_type-тег → должен пройти НЕТРОНУТЫМ, не быть молча проглоченным + вместе с ботовым шумом.""" + event = { + "level": "error", + "exception": {"values": [{"type": "PermissionError", "value": "denied"}]}, + } + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_passes_through_event_without_tags() -> None: + event: dict = {"message": "something unrelated"} + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_passes_through_unrelated_tag_value() -> None: + event = {"tags": {"event_type": "something_else"}} + out = _drop_basic_auth_noise(dict(event), {}) + assert out == event + + +def test_basic_auth_event_types_are_exactly_the_two_emitters_use() -> None: + """Явная фиксация словаря — emit_event → basic_auth_failed, + emit_digest → basic_auth_storm (см. forwarder.py).""" + assert _BASIC_AUTH_EVENT_TYPES == frozenset({"basic_auth_failed", "basic_auth_storm"}) diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index d9b7aaff..8b76910e 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -66,17 +66,28 @@ logging.getLogger("httpx").setLevel(logging.WARNING) # worker (in-app scheduler зовёт task-функции напрямую; compose = postgres/backend/ # frontend), отдельного broker нет → мониторить нечего. if settings.glitchtip_dsn: - from app.observability.sentry_scrub import redact_telegram_bot_token + from app.observability.sentry_scrub import ( + redact_telegram_bot_token, + stabilize_retry_error_fingerprint, + ) def _before_send(event: dict[str, object], hint: dict[str, object]) -> dict[str, object] | None: - """Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) — - см. app/tgbot_main.py._before_send (идентичная композиция, тот же риск: - теперь этот процесс тоже держит TelegramClient в стек-фреймах при ошибке - sendMessage, а include_local_variables=False ниже — первый рубеж защиты).""" + """Композиция PII-scrub + Telegram bot-токен redaction (#tgsupport-web) + + RetryError fingerprint-стабилизация (glitchtip-noise) — см. + app/tgbot_main.py._before_send (та же композиция без последнего шага, + тот бот geocoder не зовёт). PII/token — тот же риск: теперь этот процесс + тоже держит TelegramClient в стек-фреймах при ошибке sendMessage, а + include_local_variables=False ниже — первый рубеж защиты. RetryError — + этот процесс обслуживает /api/v1/geocode/* (suggest/lookup/reverse), + которые ретраят Nominatim через tenacity; см. + sentry_scrub.stabilize_retry_error_fingerprint.""" scrubbed = scrub_pii_event(event, hint) # type: ignore[arg-type] if scrubbed is None: return None - return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value] + detokened = redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type] + if detokened is None: + return None + return stabilize_retry_error_fingerprint(detokened, hint) # type: ignore[arg-type,return-value] sentry_sdk.init( dsn=settings.glitchtip_dsn, diff --git a/tradein-mvp/backend/app/observability/sentry_scrub.py b/tradein-mvp/backend/app/observability/sentry_scrub.py index 51c01e94..7cb78fd0 100644 --- a/tradein-mvp/backend/app/observability/sentry_scrub.py +++ b/tradein-mvp/backend/app/observability/sentry_scrub.py @@ -25,6 +25,7 @@ import re from typing import Any from sentry_sdk.types import Event +from tenacity import RetryError _REDACTED = "[REDACTED]" # Ключи consumer-PII (нижний регистр; сверка case-insensitive). @@ -76,6 +77,31 @@ _URL_SECRET_QUERY_RE = re.compile( ) _URL_SECRET_QUERY_REPLACEMENT = r"\g<1>" + _REDACTED +# httpx error-message URL query stabilization (GlitchTip-noise review round 2, +# claim #1). `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) +# bakes the FULL request URL — INCLUDING query string — into the exception message: +# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/ +# search?q=<адрес>&format=json&limit=3'" (воспроизведено эмпирически: httpx.Response +# с params={"q": "<адрес>"} → raise_for_status() → именно этот текст). После +# app/services/geocoder.py `reraise=True` (стабилизирует ТИП исключения — RetryError +# → httpx.HTTPStatusError, см. комментарий у `_nominatim_lookup`) ИМЕННО этот текст +# становится GlitchTip title/value каждого события. `q=<адрес>` — переменная часть +# на КАЖДЫЙ вызов (ночной `geocode_missing_listings` — сотни разных адресов за +# прогон), значит per-address issue-explosion не устранён `reraise=True`, а просто +# переехал с RetryError на HTTPStatusError (тот же механизм: GlitchTip группирует по +# нестабильному тексту сообщения — это же подтверждают исходные 2 462 RetryError-issue, +# невозможные при группировке чисто по stacktrace/culprit). +# +# Отдельная регулярка от `_URL_SECRET_QUERY_RE` намеренно: та бьёт по ИМЕНИ известных +# secret-параметров (security-редактор), здесь — ЛЮБОЙ query string в httpx-стиле +# сообщении "for url '...'" (grouping-стабильность, не секретность — `q` не секрет). +# Режем query целиком (не только конкретные параметры) — host+path остаются +# стабильными для группировки, "for url '...'" — единственная форма, которую бьёт +# regex (не трогает произвольные строки с `?`, см. тест +# test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched). +_HTTPX_ERROR_URL_QUERY_RE = re.compile(r"(for url '[^'?]*)\?[^']*(')") +_HTTPX_ERROR_URL_QUERY_REPLACEMENT = r"\g<1>?" + _REDACTED + r"\g<2>" + def _scrub(obj: Any) -> None: """Рекурсивно заменить значения PII-ключей в dict на [REDACTED] (in-place).""" @@ -90,32 +116,34 @@ def _scrub(obj: Any) -> None: _scrub(item) -def _redact_url_secrets_inplace(obj: Any) -> None: - """Рекурсивно (IN-PLACE, как `_scrub`) заменяет значения секрет-подобных - query-параметров (`?token=...`, `?proxy_key=...` и т.п.) на [REDACTED] в - КАЖДОЙ строке event — не ключ-based: секрет утекает через httpx span - `url`/`query` data и через текст исключений (`str(exc)` httpx содержит полный - request URL), а не только через известные PII-поля формы. Мутирует dict/list - на месте (НЕ пересоздаёт структуру, в отличие от `_redact_strings`) — - сохраняет identity верхнеуровневого `event`, на что опирается контракт - `scrub_pii_event`/`before_send` и существующие тесты (`out is event`). +def _regex_redact_inplace(obj: Any, pattern: re.Pattern[str], replacement: str) -> None: + """Рекурсивно (IN-PLACE, как `_scrub`) прогоняет `pattern.sub(replacement, ...)` + по КАЖДОЙ строке event (не ключ-based) — общий обход, переиспользуемый и для + URL-секретов (`_URL_SECRET_QUERY_RE`), и для стабилизации httpx error-message + URL (`_HTTPX_ERROR_URL_QUERY_RE`): в обоих случаях переменные данные утекают + через httpx span `url`/`query` data и через текст исключений (`str(exc)` httpx + содержит полный request URL), а не только через известные PII-поля формы. + Мутирует dict/list на месте (НЕ пересоздаёт структуру, в отличие от + `_redact_strings`) — сохраняет identity верхнеуровневого `event`, на что + опирается контракт `scrub_pii_event`/`before_send` и существующие тесты + (`out is event`). """ if isinstance(obj, dict): for key, value in obj.items(): if isinstance(value, str): - redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value) + redacted = pattern.sub(replacement, value) if redacted != value: obj[key] = redacted else: - _redact_url_secrets_inplace(value) + _regex_redact_inplace(value, pattern, replacement) elif isinstance(obj, list): for i, value in enumerate(obj): if isinstance(value, str): - redacted = _URL_SECRET_QUERY_RE.sub(_URL_SECRET_QUERY_REPLACEMENT, value) + redacted = pattern.sub(replacement, value) if redacted != value: obj[i] = redacted else: - _redact_url_secrets_inplace(value) + _regex_redact_inplace(value, pattern, replacement) # tuple намеренно не обрабатываем: sentry_sdk event — это JSON-совместимая # структура (dict/list/str/int/...), tuple там не встречается, а даже если бы # встретился — он immutable, in-place правка невозможна (см. `_scrub`, тот же @@ -123,16 +151,21 @@ def _redact_url_secrets_inplace(obj: Any) -> None: def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: - """Redact consumer-PII + URL query-string секретов из error event перед отправкой. + """Redact consumer-PII + URL query-string секретов/nondeterministic-данных из + error event перед отправкой. - Композиция (обе — in-place, сохраняют identity `event`): (1) ключ-based + Композиция (все — in-place, сохраняют identity `event`): (1) ключ-based dict-scrub consumer-PII полей формы (как раньше), (2) full-text regex-проход по ВСЕМУ event, вырезающий значения секрет-подобных query-параметров в любой строке (proxy/API-ключи в исходящих URL сторонних сервисов, напр. mobileproxy - changeip — #security-audit). Второй шаг не завязан на конкретные ключи полей — - ловит секрет в frame locals, breadcrumb, exception message и т.д., где он может - оказаться независимо от include_local_variables/traces_sample_rate. Возвращает - event (не None). + changeip — #security-audit), (3) full-text regex-проход, стабилизирующий httpx + error-message URL (`for url '...?...'`) — убирает переменный query string + (адрес геокодинга и т.п.), от которого GlitchTip group-title плодит issue на + каждый вызов (GlitchTip-noise review round 2, claim #1; см. комментарий у + `_HTTPX_ERROR_URL_QUERY_RE`). (2) и (3) не завязаны на конкретные ключи полей — + ловят секрет/переменные данные в frame locals, breadcrumb, exception message + и т.д., где они могут оказаться независимо от + include_local_variables/traces_sample_rate. Возвращает event (не None). """ if not isinstance(event, dict): return event @@ -141,7 +174,8 @@ def scrub_pii_event(event: Event, _hint: dict[str, Any]) -> Event | None: _scrub(request.get("data")) _scrub(event.get("extra")) _scrub(event.get("contexts")) - _redact_url_secrets_inplace(event) + _regex_redact_inplace(event, _URL_SECRET_QUERY_RE, _URL_SECRET_QUERY_REPLACEMENT) + _regex_redact_inplace(event, _HTTPX_ERROR_URL_QUERY_RE, _HTTPX_ERROR_URL_QUERY_REPLACEMENT) return event @@ -174,3 +208,61 @@ def redact_telegram_bot_token(event: Event, _hint: dict[str, Any]) -> Event | No if not isinstance(event, dict): return event return _redact_strings(event) # type: ignore[return-value] + + +# ── RetryError fingerprint stabilization (GlitchTip noise-reduction) ──────── +# tenacity.RetryError.__str__() тащит repr() последнего Future +# (`RetryError[]`) — +# memory address объекта, случайный на каждый вызов процесса. Пока geocoder.py +# ретраил Nominatim без `reraise=True`, каждое исчерпание ретраев (Nominatim +# недоступен/rate-limit/403) улетало в GlitchTip как RetryError с этим +# нестабильным текстом → одна и та же причина плодила отдельный issue на КАЖДОЕ +# исчерпание (2 462 issue из 7 461 в трекере на момент фикса). `reraise=True` +# в app/services/geocoder.py устраняет RetryError на этом пути (пробрасывает +# реальное исключение) — но реальное исключение (httpx.HTTPStatusError) само +# несёт нестабильный текст (URL с адресом в query), поэтому group-стабильность +# для geocoder держит НЕ эта функция, а `_HTTPX_ERROR_URL_QUERY_RE` в +# `scrub_pii_event` (см. её комментарий, GlitchTip-noise review round 2 claim #1). +# +# Функция ниже — belt-and-suspenders для ЛЮБОГО кода, который ретраит через +# tenacity БЕЗ `reraise=True` (живой пример на момент фикса: `BaseScraper._http_get` +# в packages/scraper-kit — retry-декоратор НЕ reraise'ит, сознательно оставлен на +# этот фолбэк, а не на URL-стабилизацию: ретраятся listing detail URL БЕЗ query +# string — переменная часть там в ПУТИ (offer id), которую `_HTTPX_ERROR_URL_QUERY_RE` +# не покрывает; см. review round 2 claim #3). Схлопывает RetryError в ОДИН +# persistent issue per (culprit, класс исключения-причины) — culprit обязателен: +# БЕЗ него RetryError с одинаковым типом причины из НЕСВЯЗАННЫХ подсистем (напр. +# geocoder и scraper_kit одновременно ретраят httpx и оба ловят HTTPStatusError) +# схлопнулись бы в ОДИН issue — потеря сигнала хуже исходного шума (review round 2 +# claim #2). Источник culprit — `event["logger"]`: sentry_sdk `LoggingIntegration` +# ставит его в имя logger'а (`logging.getLogger(__name__)`, напр. +# "app.services.geocoder" vs "scraper_kit.providers.yandex.detail") на КАЖДОМ +# `logger.exception(...)`/`logger.error(...)` — стабильно per-модуль, не зависит от +# конкретного запроса. Остальная часть fingerprint собрана ТОЛЬКО из стабильных +# данных — имя типа исключения-причины (небольшой фиксированный словарь вроде +# "HTTPStatusError"/"ConnectTimeout") — НИКАКИХ переменных данных запроса (адрес, +# IP, id объявления и т.п.), иначе проблема повторится в других терминах. +def stabilize_retry_error_fingerprint(event: Event, hint: dict[str, Any]) -> Event | None: + """before_send-хук: схлопывает tenacity.RetryError в один persistent issue per + (источник, тип причины) — РАЗНЫЕ источники (geocoder / scraper_kit / будущий + retry-код) НЕ схлопываются друг с другом, даже если тип причины совпадает. + + Определяет тип exception через `hint["exc_info"]` (реальный объект + исключения, тот же контракт что sentry_sdk передаёт в before_send) — не + парсит уже сериализованный event dict, надёжнее к изменениям формата SDK. + `isinstance` (не сравнение `type(...).__name__` со строкой) — иначе любой + посторонний класс с совпадающим именем ложно матчился бы, а подкласс + `tenacity.RetryError` — промахивался бы. Не-RetryError события возвращает без + изменений (OperationalError, алерты scraper sweep'ов и т.п. фильтр не трогает). + """ + if not isinstance(event, dict): + return event + exc_info = hint.get("exc_info") if isinstance(hint, dict) else None + exc_value = exc_info[1] if exc_info and len(exc_info) > 1 else None + if not isinstance(exc_value, RetryError): + return event + cause = exc_value.__cause__ or exc_value.__context__ + cause_type = type(cause).__name__ if cause is not None else "Unknown" + culprit = event.get("logger") or event.get("transaction") or "unknown" + event["fingerprint"] = ["retry-exhausted", str(culprit), cause_type] + return event diff --git a/tradein-mvp/backend/app/scheduler_main.py b/tradein-mvp/backend/app/scheduler_main.py index 3bb8af4a..e5f93ed0 100644 --- a/tradein-mvp/backend/app/scheduler_main.py +++ b/tradein-mvp/backend/app/scheduler_main.py @@ -44,7 +44,24 @@ if settings.glitchtip_dsn: from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration - from app.observability.sentry_scrub import scrub_pii_event + from app.observability.sentry_scrub import ( + scrub_pii_event, + stabilize_retry_error_fingerprint, + ) + + def _before_send(event: dict, hint: dict) -> dict | None: # type: ignore[type-arg] + """PII-scrub + RetryError fingerprint-стабилизация (glitchtip-noise). + + Этот процесс гоняет `geocode_missing_listings` (ночной batch, сотни + адресов за прогон) — @retry-декорированные Nominatim-хелперы + (app/services/geocoder.py) на исчерпанных ретраях исторически плодили + по отдельному GlitchTip issue на КАЖДЫЙ адрес (RetryError.__str__() + тащит нестабильный repr() Future). См. sentry_scrub docstring. + """ + scrubbed = scrub_pii_event(event, hint) + if scrubbed is None: + return None + return stabilize_retry_error_fingerprint(scrubbed, hint) sentry_sdk.init( dsn=settings.glitchtip_dsn, @@ -52,7 +69,7 @@ if settings.glitchtip_dsn: release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=0.0, send_default_pii=False, - before_send=scrub_pii_event, + before_send=_before_send, integrations=[ SqlalchemyIntegration(), HttpxIntegration(), diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 420728dd..2d3c7096 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -742,7 +742,23 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No return oblast_fallback -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +# reraise=True (GlitchTip-noise fix): без него tenacity на исчерпанных ретраях +# бросает СВОЙ tenacity.RetryError, чей str() тащит repr() последнего Future +# (`` — адрес объекта в памяти, разный на КАЖДЫЙ вызов). GlitchTip +# группирует по этому нестабильному тексту → одна и та же причина (Nominatim +# недоступен/rate-limit) плодила отдельный issue на каждое исчерпание ретраев +# (2 462 issue из 7 461 в трекере). reraise=True пробрасывает РЕАЛЬНОЕ исключение +# (httpx.HTTPStatusError/TimeoutException) — стабильный ТИП+стек. НО httpx.HTTPStatusError +# сам несёт нестабильный ТЕКСТ (str() содержит полный request URL, включая query +# string с адресом — `for url '...search?q=<адрес>&...'`) — group-стабильность на +# ЭТОМ пути держит `_HTTPX_ERROR_URL_QUERY_RE` в app/observability/sentry_scrub.py +# (`scrub_pii_event`, часть before_send-композиции обоих entrypoint), которая режет +# query string из httpx-style "for url '...'" сообщений (GlitchTip-noise review +# round 2, claim #1 — reraise=True сам по себе НЕ закрывает per-address explosion). +# Отдельно — `stabilize_retry_error_fingerprint` (та же sentry_scrub.py) на случай +# если голый tenacity.RetryError (не httpx-исключение) всплывёт откуда-то ещё +# (belt-and-suspenders для retry-кода без reraise=True, напр. scraper_kit). +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) async def _nominatim_lookup(address: str, city_hint: str | None = None) -> GeocodeResult | None: """OSM Nominatim — бесплатно, без ключа, 1 req/sec policy. @@ -941,7 +957,8 @@ async def _nominatim_query_city_aware( return _dedupe_nominatim_items(ekb_data, bare_data)[:limit] -@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4)) +# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). +@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True) async def _nominatim_suggest( query: str, limit: int = 8, city_hint: str | None = None ) -> list[GeocodeSuggestion]: @@ -1987,7 +2004,8 @@ def _format_reverse_address(addr: dict) -> str | None: return ", ".join(parts) -@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8)) +# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум). +@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8), reraise=True) async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None: """Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon. diff --git a/tradein-mvp/backend/tests/test_sentry_scrub.py b/tradein-mvp/backend/tests/test_sentry_scrub.py index 1e8d8ba5..67d26650 100644 --- a/tradein-mvp/backend/tests/test_sentry_scrub.py +++ b/tradein-mvp/backend/tests/test_sentry_scrub.py @@ -15,6 +15,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.observability.sentry_scrub import ( redact_telegram_bot_token, scrub_pii_event, + stabilize_retry_error_fingerprint, ) @@ -259,3 +260,216 @@ def test_composed_before_send_scrubs_pii_and_token_together() -> None: assert out["request"]["data"]["client_phone"] == "[REDACTED]" frame_url = out["exception"]["values"][0]["stacktrace"]["frames"][0]["vars"]["url"] assert "8663867262:AAExampleSecretPartAbCdEf123" not in frame_url + + +# ── RetryError fingerprint stabilization (glitchtip-noise, #) ─ +# +# tenacity.RetryError.__str__() тащит repr() последнего Future — memory address +# объекта, случайный на каждый вызов процесса. Раньше (без `reraise=True` в +# app/services/geocoder.py) каждое исчерпание ретраев Nominatim улетало в +# GlitchTip как RetryError с этим нестабильным текстом → одна и та же причина +# плодила отдельный issue на КАЖДОЕ исчерпание (2 462 issue из 7 461 в трекере). +# Тесты ниже бьют по `stabilize_retry_error_fingerprint` напрямую — belt-and- +# suspenders слой для retry-кода БЕЗ reraise=True (напр. scraper_kit — +# geocoder.py `reraise=True` устраняет RetryError на своём пути, но остаётся +# фолбэком общего назначения), и по контракту before_send: 401-класс (RetryError) +# схлопывается ПО ИСТОЧНИКУ (не глобально — review round 2 claim #2: разные +# подсистемы с совпавшим типом причины НЕ сливаются), содержательные категории +# (500-подобный generic Exception, OperationalError) проходят НЕТРОНУТЫМИ. + +from tenacity import RetryError # noqa: E402 + + +def _hint_for(exc: BaseException) -> dict: + """Строит hint в форме, которую sentry_sdk реально передаёт в before_send — + `exc_info = (type, value, traceback)` (contract stabilize_retry_error_fingerprint + полагается именно на эту форму, не на уже сериализованный event dict).""" + return {"exc_info": (type(exc), exc, exc.__traceback__)} + + +def _raise_retry_error_from(cause: BaseException) -> RetryError: + try: + raise cause + except type(cause) as caught: + try: + raise RetryError(None) from caught + except RetryError as retry_exc: + return retry_exc + + +def test_stabilize_retry_error_sets_stable_fingerprint() -> None: + """RetryError коллапсится в persistent issue по (culprit, имени типа причины) — + НЕ по нестабильному str(RetryError) (repr() Future с memory address). Без + `event["logger"]` (напр. capture_exception без LoggingIntegration) culprit + падает на явный "unknown", а не пропадает из fingerprint молча.""" + exc = _raise_retry_error_from(TimeoutError("Nominatim timed out")) + out = stabilize_retry_error_fingerprint({"level": "error"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "unknown", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_has_no_variable_data() -> None: + """Fingerprint не должен содержать IP/id объявления/адрес и т.п. — только + culprit (logger-имя модуля) + фиксированное имя типа исключения-причины + (маленький словарь: HTTPStatusError/ConnectTimeout/TimeoutError/...).""" + exc = _raise_retry_error_from( + ValueError("addr='ул. Ленина 1', ip=95.165.147.218, listing_id=12345") + ) + out = stabilize_retry_error_fingerprint({}, _hint_for(exc)) + assert out is not None + fingerprint_text = " ".join(out["fingerprint"]) + assert "95.165.147.218" not in fingerprint_text + assert "12345" not in fingerprint_text + assert out["fingerprint"] == ["retry-exhausted", "unknown", "ValueError"] + + +def test_stabilize_retry_error_fingerprint_uses_logger_as_culprit() -> None: + """`event["logger"]` (sentry_sdk LoggingIntegration ставит его = имя модуля, + вызвавшего logger.exception/.error) идёт в fingerprint как culprit — стабильно + per-модуль, не переменные данные запроса.""" + exc = _raise_retry_error_from(TimeoutError("timed out")) + out = stabilize_retry_error_fingerprint({"logger": "app.services.geocoder"}, _hint_for(exc)) + assert out is not None + assert out["fingerprint"] == ["retry-exhausted", "app.services.geocoder", "TimeoutError"] + + +def test_stabilize_retry_error_fingerprint_does_not_collapse_unrelated_subsystems() -> None: + """Review round 2 claim #2: RetryError с ОДИНАКОВЫМ типом причины из + НЕСВЯЗАННЫХ подсистем (geocoder vs scraper_kit) НЕ должны схлопнуться в один + issue — разные проблемы не сливаются, даже если типы причины совпали.""" + exc_geocoder = _raise_retry_error_from(TimeoutError("nominatim timed out")) + exc_scraper = _raise_retry_error_from(TimeoutError("yandex detail timed out")) + out_geocoder = stabilize_retry_error_fingerprint( + {"logger": "app.services.geocoder"}, _hint_for(exc_geocoder) + ) + out_scraper = stabilize_retry_error_fingerprint( + {"logger": "scraper_kit.providers.yandex.detail"}, _hint_for(exc_scraper) + ) + assert out_geocoder is not None + assert out_scraper is not None + assert out_geocoder["fingerprint"] != out_scraper["fingerprint"] + + +class _DecoyRetryError(Exception): + """Посторонний класс, СЛУЧАЙНО названный так же, как tenacity.RetryError — + но НЕ его подкласс. Строковое сравнение имён (старый баг, review round 2 + claim #4) ложно матчило бы такое; isinstance — нет.""" + + +_DecoyRetryError.__name__ = "RetryError" # type: ignore[misc] + + +def test_stabilize_retry_error_ignores_lookalike_class_by_name() -> None: + """type(exc).__name__ == "RetryError" НЕ должно быть достаточно — только + реальный tenacity.RetryError (или его подкласс) триггерит fingerprint-хук.""" + exc = _DecoyRetryError("unrelated exception, same class __name__ by accident") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_operational_error_untouched() -> None: + """401-аналог задачи: OperationalError — содержательная категория (реальный + сбой БД), фильтр её НЕ трогает (см. задачу #4 — не выключить сигнал вместе с + шумом).""" + from sqlalchemy.exc import OperationalError + + exc = OperationalError("SELECT 1", {}, Exception("connection refused")) + event = {"level": "error", "message": "db connection failed"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_leaves_generic_exception_untouched() -> None: + """500-аналог задачи: обычное необработанное исключение (не RetryError) + проходит без изменений.""" + exc = RuntimeError("scraper city-sweep failed") + event = {"level": "error"} + out = stabilize_retry_error_fingerprint(dict(event), _hint_for(exc)) + assert out == event + assert "fingerprint" not in out + + +def test_stabilize_retry_error_no_exc_info_untouched() -> None: + """capture_message-based события (нет exc_info) — фильтр не трогает, напр. + scrape_runs.py consecutive-failure алерты (content-ful, должны доходить).""" + event = {"level": "error", "message": "Scraper source 'avito' has 5 consecutive failed runs"} + out = stabilize_retry_error_fingerprint(dict(event), {}) + assert out == event + + +def test_stabilize_retry_error_handles_non_dict_event() -> None: + assert stabilize_retry_error_fingerprint(None, {}) is None # type: ignore[arg-type] + + +# ── httpx error-message URL query stabilization (glitchtip-noise review round 2, +# claim #1) ──────────────────────────────────────────────────────────────── +# +# `httpx.HTTPStatusError.__str__()` (raised by `response.raise_for_status()`) bakes +# the FULL request URL — including query string — into the exception message. +# Воспроизведено эмпирически (httpx.Response(403, request=...).raise_for_status()): +# "Client error '403 Forbidden' for url 'https://nominatim.openstreetmap.org/ +# search?q=<адрес>&format=json&limit=3'". После `reraise=True` в geocoder.py +# (устраняет RetryError, но НЕ этот текст) именно ЭТА строка становится GlitchTip +# title/value — переменный `q=<адрес>` на каждый вызов воспроизводит тот же +# per-address issue-explosion, который reraise=True должен был устранить, просто +# сменивший класс исключения (RetryError → HTTPStatusError). Тесты бьют по +# `scrub_pii_event` напрямую (композиция, реально применяемая в before_send). + +_NOMINATIM_403_TEMPLATE = ( + "Client error '403 Forbidden' for url " + "'https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=3'\n" + "For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/403" +) + + +def _httpx_error_event(message: str) -> dict: + return {"exception": {"values": [{"type": "HTTPStatusError", "value": message}]}} + + +def test_scrub_pii_event_stabilizes_httpx_error_url_query() -> None: + """Query string режется целиком из httpx-style 'for url' сообщения — host+path + остаются стабильными для группировки.""" + encoded_ekb = "%D0%95%D0%BA%D0%B0%D1%82%D0%B5%D1%80%D0%B8%D0%BD%D0%B1%D1%83%D1%80%D0%B3" + event = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query=encoded_ekb)) + out = scrub_pii_event(event, {}) + assert out is not None + value = out["exception"]["values"][0]["value"] + assert "search?[REDACTED]'" in value + assert "%D0%95" not in value + assert "nominatim.openstreetmap.org/search" in value # host+path сохранены + + +def test_scrub_pii_event_httpx_url_query_stabilization_collapses_different_addresses() -> None: + """Два РАЗНЫХ адреса (переменная часть query) после редактора дают + ИДЕНТИЧНЫЙ текст сообщения — GlitchTip group-title больше не плодит issue + на каждый адрес (review round 2 claim #1).""" + event_a = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Ленина+1")) + event_b = _httpx_error_event(_NOMINATIM_403_TEMPLATE.format(query="ул.+Мира+42%2C+кв.+5")) + out_a = scrub_pii_event(event_a, {}) + out_b = scrub_pii_event(event_b, {}) + assert out_a is not None + assert out_b is not None + assert out_a["exception"]["values"][0]["value"] == out_b["exception"]["values"][0]["value"] + + +def test_scrub_pii_event_httpx_url_without_query_untouched() -> None: + """URL без query string (напр. scraper detail page — переменная часть в + ПУТИ, не в query) остаётся нетронутым — regex матчит только `?...`.""" + message = "Client error '404 Not Found' for url 'https://realty.yandex.ru/offer/12345/'" + event = _httpx_error_event(message) + out = scrub_pii_event(event, {}) + assert out is not None + assert out["exception"]["values"][0]["value"] == message + + +def test_scrub_pii_event_httpx_url_query_stabilization_leaves_unrelated_text_untouched() -> None: + """Regex бьёт только по 'for url \\'...?...\\'' — произвольный текст с `?` и + кавычками не должен ложно матчиться.""" + benign = "Вопрос: 'что такое ЖК \"Солнечный\"?' — уточните адрес" + event = {"extra": {"note": benign}} + out = scrub_pii_event(event, {}) + assert out is not None + assert out["extra"]["note"] == benign diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx new file mode 100644 index 00000000..a240140b --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/AccuracyV3.tsx @@ -0,0 +1,154 @@ +/** + * AccuracyV3 — «Точность»: KPI-плитки + таблица сверок прогноза с фактом + * сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент. + * + * KPI-значения — из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это + * плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения + * замером (см. шапку файла). + * + * Таблица сверок — `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было + * заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому + * строки — витринный пример формата отчёта, а не факт. Ровно поэтому подпись + * под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · …» — фраза, + * прямо утверждающая, что за строками стоят настоящие обезличенные сделки. + * Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись + * честно называет таблицу иллюстрацией формата, а вторую половину исходной + * фразы («"выше средней цены" — разница со средней ценой сделки по похожим + * квартирам в районе») сохраняет — она объясняет, ЧТО значит колонка, а не + * заявляет что-то о происхождении данных. + * + * Таблица размечена ARIA role="table/row/columnheader/cell" (а не ): + * на мобильном брейке (см. landing-v3.module.css) она превращается в карточки + * без горизонтального скролла, а implicit table-роли всё равно снимаются + * Safari при смене display у настоящей
— тот же квирк, что уже описан + * в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а + * не CSS-костыль. + */ + +import { + ACCURACY_PERIOD_LABEL, + ACCURACY_PLACEHOLDER, + PROOF_ROWS_PLACEHOLDER, +} from "../../marketing-v3"; +import styles from "../../landing-v3.module.css"; + +const KPI_TILES = [ + { + value: ACCURACY_PLACEHOLDER.priceError, + label: "средняя ошибка по цене", + note: ACCURACY_PLACEHOLDER.priceErrorNote, + }, + { + value: ACCURACY_PLACEHOLDER.daysError, + label: "точность по сроку продажи", + note: ACCURACY_PLACEHOLDER.daysErrorNote, + }, + { + value: ACCURACY_PLACEHOLDER.coverage, + label: "сделок попали в диапазон", + note: ACCURACY_PLACEHOLDER.coverageNote, + }, +] as const; + +export function AccuracyV3() { + return ( +
+
+
+ +

+ Мы сверяем каждый прогноз с реальной сделкой +

+

+ {`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`} +

+
+ +
    + {KPI_TILES.map((tile) => ( +
  • +
    {tile.value}
    +
    {tile.label}
    +
    {tile.note}
    +
  • + ))} +
  • +
    + {ACCURACY_PLACEHOLDER.checks} +
    +
    проверок за год
    +
    + {ACCURACY_PLACEHOLDER.checksNote} +
    +
  • +
+ +
+
+ Объект + Мера сказала + Факт сделки + Расхождение + + Выше средней цены по району + +
+ {PROOF_ROWS_PLACEHOLDER.map((row) => ( +
+
+ {row.addr} + {row.meta} +
+
+ {row.said} +
+
+ {row.fact} +
+
+ {row.err} +
+
+
+
{row.gain}
+
{row.gainPct}
+
+
+
+ ))} +
+

+ Таблица иллюстрирует формат отчёта, это не список конкретных сделок. + «Выше средней цены» — разница со средней ценой сделки по похожим + квартирам в районе. +

+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx new file mode 100644 index 00000000..7ef9ee8d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CityPicker.tsx @@ -0,0 +1,140 @@ +"use client"; + +/** + * CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73). + * + * Презентационный клиентский остров: сегодня публичный расчёт выключен + * (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь + * город никуда не отправляется — состояние живёт только в этом компоненте и + * показывает посетителю, что сервис знает больше одного города области. + * Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора + * наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/ + * sticky-баром, самой формы здесь нет. + * + * Список городов и деление на «полное покрытие» / «данных меньше» берутся из + * `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно + * объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса» + * по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет, + * а гадать нельзя (правило честности в content.ts). + */ + +import { useEffect, useId, useRef, useState } from "react"; + +import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry"; + +import { PRIMARY_CITY, REGION_NAME } from "../../content"; +import styles from "../../landing-v3.module.css"; + +export function CityPicker() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [city, setCity] = useState(DEFAULT_CITY.label); + const wrapRef = useRef(null); + const buttonRef = useRef(null); + const panelId = useId(); + + // Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx. + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Escape — закрыть и вернуть фокус на кнопку. + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const normalizedQuery = query.trim().toLowerCase(); + const filtered = normalizedQuery + ? OBLAST_CITIES.filter((c) => + c.label.toLowerCase().includes(normalizedQuery), + ) + : OBLAST_CITIES; + + return ( +
+ + + {open ? ( +
+
+ setQuery(e.target.value)} + placeholder="Поиск города" + aria-label="Поиск города" + /> +
+
+ {filtered.length > 0 ? ( + filtered.map((c) => ( + + )) + ) : ( +

+ Города нет в списке. Мы работаем там, где хватает данных по + сделкам — напишите нам, и добавим. +

+ )} +
+
+ ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "} + {REGION_NAME.toUpperCase()} +
+
+ ) : null} +
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx new file mode 100644 index 00000000..b7281015 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -0,0 +1,52 @@ +/** + * CostOfErrorV3 — «Цена ошибки»: три плитки про стоимость неверной цены + * (макет v3, ~строки 376-400, без id). Серверный компонент. + * + * Значения и подписи — из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts), + * заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) — тот же источник + * правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный + * литерал «150». + */ + +import { SERVICE_PRICE_RUB } from "../../content"; +import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3"; +import styles from "../../landing-v3.module.css"; + +export function CostOfErrorV3() { + return ( +
+
+
+ +

+ {`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`} +

+
+ +
    + {COST_OF_ERROR_PLACEHOLDER.map((tile, index) => { + const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1; + return ( +
  • +
    {tile.value}
    +
    {tile.title}
    +

    + {tile.text} +

    +
  • + ); + })} +
+
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx new file mode 100644 index 00000000..1ff10d5d --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FooterV3.tsx @@ -0,0 +1,145 @@ +/** + * FooterV3 — подвал лэндинга v3 (макет ~ строки 470-516): четыре колонки + + * нижняя строка с реквизитами. + * + * РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН + * 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`, + * `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор, + * что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в + * другой сетке. Документы включают Политику возврата и почту поддержки: + * макет их не показывал, а это обязательные элементы платного контура + * (маркировка эквайера, идентифицируемость оператора по 152-ФЗ). + * + * Способы оплаты (МИР / СБП / VISA / MASTERCARD в макете) НЕ рендерятся: + * приём платежей не подключён (см. TODO у нижней строки) — заявлять способы + * оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе». + * + * «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3): + * раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на + * этой же странице) — отдельного маршрута отчёта не существует. Оба + * внутренних якоря — через `/mera-public#...`: подвал рендерится и на + * оферте/возврате/ПДн, где голый `#check` никуда не долистал бы. + */ + +import Link from "next/link"; + +import { safeUrl } from "@/lib/safeUrl"; + +import { + LEGAL_ENTITY, + OFFER_PATH, + PRIVACY_PATH, + REFUND_PATH, + REGION_NAME, + SUPPORT_EMAIL, + SUPPORT_TELEGRAM_LABEL, + SUPPORT_TELEGRAM_URL, +} from "../../content"; +import styles from "../../landing-v3.module.css"; + +export function FooterV3() { + const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL); + const year = new Date().getFullYear(); + + return ( +
+
+
+
+
+

+ Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "} + {REGION_NAME}. +

+
+ +
+

Разделы

+
+ + Главная + + + Проверить квартиру + + + Статьи + + + МЕРА для бизнеса + +
+
+ +
+

Связаться

+

+ Почта:{" "} + + {SUPPORT_EMAIL} + +

+ {telegramHref ? ( +

+ Поддержка в Telegram:{" "} + + {SUPPORT_TELEGRAM_LABEL} + +

+ ) : null} +
+ +
+

Документы

+
+ + Публичная оферта + + + Политика возврата + + + Обработка персональных данных + +
+
+
+ +
+
+ © {year} МЕРА + {LEGAL_ENTITY ? ( + + {LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "} + {LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address} + + ) : null} + + Оценка носит информационный характер и не является офертой или + отчётом об оценке. + +
+ {/* TODO(платёжный контур): бейджи способов оплаты (МИР / СБП / VISA / + MASTERCARD, как в макете) — рендерить только когда приём оплаты + будет подключён backend'ом. Сегодня `PUBLIC_ESTIMATE_ENABLED = + false`, оплаты нет вовсе, а заявлять способы оплаты, которых нет, + — нарушение ст. 5 ФЗ «О рекламе». Гейт G6, см. + mera-b2c-paid-flow-decision.md. */} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx new file mode 100644 index 00000000..2adf9381 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx @@ -0,0 +1,303 @@ +"use client"; + +/** + * FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3, + * `МЕРА для себя v3.dc.html` строки ~129-170). + * + * ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена + * `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине: + * `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на + * `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в + * `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне + * границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей + * на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного + * сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом + * сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый + * адрес — не продающая формулировка, а обман конкретного человека (см. шапку + * `../../marketing-v3.ts`). + * + * Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`, + * `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют + * состояния города, которым владеет шапка сайта — вне участка этой задачи + * («первый экран: герой и карточка»), и вносили бы числовые литералы (33%, + * 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`. + * + * Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см. + * TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`. + */ + +import { useId, useRef, useState } from "react"; +import type { FormEvent } from "react"; + +import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content"; +import styles from "../../landing-v3.module.css"; + +/** Три состояния квартиры — дословно из макета (строка 734 исходника). */ +const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const; +type Condition = (typeof CONDITIONS)[number]; + +type FieldName = "address" | "area" | "rooms" | "floor" | "condition"; + +type Feedback = + | { kind: "none" } + | { kind: "error"; field: FieldName; text: string } + | { kind: "info"; title: string; lines: readonly string[] }; + +const NONE: Feedback = { kind: "none" }; + +export function FreeCheckCard() { + const addressId = useId(); + const areaId = useId(); + const roomsId = useId(); + const floorId = useId(); + const conditionLabelId = useId(); + const feedbackId = useId(); + + const [address, setAddress] = useState(""); + const [area, setArea] = useState(""); + const [rooms, setRooms] = useState(""); + const [floor, setFloor] = useState(""); + const [condition, setCondition] = useState(""); + const [feedback, setFeedback] = useState(NONE); + + const addressRef = useRef(null); + const areaRef = useRef(null); + const roomsRef = useRef(null); + const floorRef = useRef(null); + const conditionGroupRef = useRef(null); + + function clearFeedback() { + setFeedback(NONE); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const trimmedAddress = address.trim(); + const trimmedArea = area.trim(); + const trimmedRooms = rooms.trim(); + const trimmedFloor = floor.trim(); + + if (!trimmedAddress) { + setFeedback({ + kind: "error", + field: "address", + text: "Укажите адрес — улицу и номер дома.", + }); + addressRef.current?.focus(); + return; + } + + if (!trimmedArea) { + setFeedback({ + kind: "error", + field: "area", + text: "Укажите площадь квартиры в квадратных метрах.", + }); + areaRef.current?.focus(); + return; + } + + if (!trimmedRooms) { + setFeedback({ + kind: "error", + field: "rooms", + text: "Укажите количество комнат.", + }); + roomsRef.current?.focus(); + return; + } + + if (!trimmedFloor) { + setFeedback({ + kind: "error", + field: "floor", + text: "Укажите этаж, например «7/16».", + }); + floorRef.current?.focus(); + return; + } + + if (!condition) { + setFeedback({ + kind: "error", + field: "condition", + text: "Выберите состояние квартиры.", + }); + conditionGroupRef.current?.focus(); + return; + } + + if (PUBLIC_ESTIMATE_ENABLED) { + // TODO(backend-периметр): здесь появится реальный запрос к + // POST /trade-in/coverage. По успешному ответу — переключить карточку в + // ветку результата и отрендерить `FreeResultV3` с полученными + // analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR. + return; + } + + setFeedback({ + kind: "info", + title: "Бесплатную проверку по адресу мы пока не открыли", + lines: [ + `Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`, + "Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.", + ], + }); + } + + const errorField = feedback.kind === "error" ? feedback.field : null; + const describedBy = feedback.kind === "none" ? undefined : feedbackId; + + return ( +
+
+ Бесплатная проверка +
+ +
+
+ + +
+ + + + + +
+ +
+ + Состояние + +
+ {CONDITIONS.map((name) => ( + + ))} +
+
+ + +

+ БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД +

+ + {/* Живая область держим в DOM постоянно — регион, добавленный в + момент ошибки, часть скринридеров не озвучивает (тот же приём, + что в ../AddressForm.tsx). */} +
+ {feedback.kind === "error" && ( +
+

{feedback.text}

+
+ )} + {feedback.kind === "info" && ( +
+

{feedback.title}

+ {feedback.lines.map((line) => ( +

+ {line} +

+ ))} +
+ )} +
+ +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx new file mode 100644 index 00000000..5cbd9c78 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeResultV3.tsx @@ -0,0 +1,100 @@ +/** + * FreeResultV3 — ветка «результат» карточки «Бесплатная проверка» (макет v3, + * строки ~176-204 `МЕРА для себя v3.dc.html`). + * + * НЕ ВЫЗЫВАЕТСЯ СЕГОДНЯ НИОТКУДА. Компонент собран и типизирован, но нигде не + * рендерится в живом дереве: `FreeCheckCard.tsx` при сабмите (см. TODO рядом с + * `PUBLIC_ESTIMATE_ENABLED` там) всегда остаётся в форме и честно говорит, что + * онлайн-проверки ещё нет — потому что анонимных ручек `/geocode/suggest` и + * `/trade-in/estimate` не существует (см. `../../content.ts`). Как только + * появится `POST /trade-in/coverage`, эту ветку нужно подключить: завести + * `phase`-состояние в `FreeCheckCard`, по успешному ответу переключаться в + * `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа + * ручки — не константами. + * + * Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри + * компонента, — вызывающий код сегодня мог бы по ошибке подставить туда + * `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений» + * увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не + * продающая формулировка, а обман конкретного человека — см. шапку + * `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на + * вызывающей стороне и делает её явной в диффе, когда придёт время подключать + * реальные данные. + * + * Замыленная рублёвая цифра из макета («9,75 млн ₽», `filter: blur`) сюда + * НЕ перенесена вообще — вместо неё абстрактная плашка-скелет без единой + * цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`). + */ + +import styles from "../../landing-v3.module.css"; + +export interface FreeResultV3Props { + /** Число похожих объявлений — крупная цифра первой плитки, напр. "14". */ + analogs: string; + /** Подпись под числом объявлений. */ + analogsLabel: string; + /** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */ + days: string; + /** Подпись под сроком продажи. */ + daysLabel: string; + /** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */ + onBack: () => void; +} + +export function FreeResultV3({ + analogs, + analogsLabel, + days, + daysLabel, + onBack, +}: FreeResultV3Props) { + return ( +
+

ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ

+ +
+
+
{analogs}
+
{analogsLabel}
+
+
+
{days}
+
{daysLabel}
+
+
+ +
+ {/* Скелет без единой цифры: замыленную сумму из макета намеренно не + переносим, см. JSDoc выше. */} +