Merge remote-tracking branch 'origin/main' into HEAD
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m1s

# Conflicts:
#	tradein-mvp/backend/app/services/estimator.py
This commit is contained in:
bot-backend 2026-09-05 22:40:52 +05:00
commit aaf8119408
51 changed files with 1944 additions and 219 deletions

View file

@ -175,10 +175,28 @@ gendsgn.ru {
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
# X-Internal-Auth-Secret ЗДЕСЬ БОЛЬШЕ НЕ ПОДСТАВЛЯЕТСЯ (#3324). До этой
# правки Caddy инжектил его в КАЖДЫЙ запрос этого хопа — включая
# анонимный, до всякого логина trade-in.
#
# ПОЧЕМУ ОН БЫЛ МЁРТВЫМ. Единственное место, где backend читает этот
# ЗАГОЛОВОК, — `app/core/rbac.py` (legacy trusted-header ветка): он
# сверяется ТОЛЬКО после того, как в запросе нашёлся непустой
# X-Authenticated-User (иначе ветка отдаёт 401 раньше, на «no
# authenticated user»). А X-Authenticated-User на этом же хопе строкой
# ниже удаляется — то есть пара «имя + секрет» через Caddy прийти не
# может по построению, сравнение недостижимо. Второй потребитель
# секрета, приёмник вебхуков GlitchTip (`app/api/v1/glitchtip.py`),
# берёт его из query-параметра `?secret=`, а не из заголовка, и на эту
# правку не реагирует.
#
# ЧТО ОТСЕКАЕТ ПОДДЕЛКУ ЗАГОЛОВКОВ ИЗНУТРИ gendesign_shared — проверка
# в rbac.py, а не факт подстановки в Caddy; она остаётся нетронутой.
# Инжект работал в обратную сторону: раздавал внутренний секрет по
# анонимному пути (он же виден в env контейнера Caddy). Легитимный
# dual-mode трафик — внутрисетевой (`docker exec tradein-backend curl
# -H 'X-Authenticated-User: …' -H 'X-Internal-Auth-Secret: …'`,
# см. auth/roles.yaml) — ходит мимо Caddy и сам несёт оба заголовка.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
@ -186,7 +204,6 @@ gendsgn.ru {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -224,9 +241,13 @@ gendsgn.ru {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
# удаление вместо Set с пустым {http.auth.user.id}), и по той же
# причине здесь больше нет инжекта X-Internal-Auth-Secret
# (#3324). На этом хопе он был мёртв ещё очевиднее: адресат —
# Next-сервер tradein-frontend, в его коде заголовок не читается
# и дальше в backend не пробрасывается (SSR не форвардит
# входящие заголовки — `headers()` во фронте не используется).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -463,7 +484,40 @@ meraocenka.ru {
# `_next/*` открыл бы анонимам ещё и `/_next/image` (оптимизация картинок,
# CPU-нагрузка по запросу), который на лэндинге не используется вообще:
# next/image в tradein-mvp/frontend/src/app/mera-public/ не импортируется.
#
# #3324: внутри разрешённой статики закрыто ПОДДЕРЕВО ЧУЖИХ МАРШРУТОВ.
# App Router раскладывает код постранично: `chunks/app/<маршрут>/page-<hash>.js`
# (замер на проде 02.09.2026 — лэндингу нужны РОВНО `chunks/app/layout-*`,
# `chunks/app/error-*`, `chunks/app/global-error-*`, `chunks/app/mera-public/*`
# и общие хэш-чанки прямо в `chunks/`). Всё остальное под `chunks/app/` —
# это /admin, /scrapers, /team, /history, /cache, /versions, /v2,
# /sale-share, /login, /ui-preview: страницы закрытого контура, которые
# аноним с публичного домена качал целиком и читал в них имена внутренних
# ручек. Матчер отсекает ровно «есть подкаталог, и он не mera-public» —
# `not` вместо негативного lookahead, RE2 его не поддерживает.
#
# `_not-found` в исключениях — это не маршрут продукта, а внутренняя
# страница Next (404 при клиентской навигации по битому href). Закрытого
# кода не несёт, а без исключения клиентский переход падал бы в
# error-boundary вместо честного «не найдено».
#
# Ограничение честно: это убирает КОД, но не имена файлов — карта чанков
# лежит в `webpack-*.js`, который лэндингу нужен, поэтому перечень
# маршрутов из неё по-прежнему вычитывается. По той же причине оставлены
# без фильтра и `static/css/app/<маршрут>/*`: это тот же класс «имена, а не
# код», и городить второй матчер ради него смысла нет. Полное разделение —
# split build (отдельный public-бандл), отдельная задача.
#
# `respond` внутри `handle` выполняется раньше `reverse_proxy` (порядок
# директив Caddy), поэтому вложенный матчер не зависит от сортировки
# handle-блоков между собой.
handle /trade-in/_next/static/* {
@foreignRouteChunk {
path_regexp ^/trade-in/_next/static/chunks/app/[^/]+/
not path /trade-in/_next/static/chunks/app/mera-public/* /trade-in/_next/static/chunks/app/_not-found/*
}
respond @foreignRouteChunk 404
reverse_proxy tradein-frontend:3000 {
header_up -X-Authenticated-User
}
@ -499,9 +553,12 @@ meraocenka.ru {
# strip_prefix — та же причина, что у B2B-хопа (:127): basePath Next'а не
# часть маршрута FastAPI.
#
# X-Internal-Auth-Secret здесь НЕ подставляется (в отличие от :130):
# публичные ручки его не проверяют, а инжектить внутренний секрет в хоп с
# анонимного домена — расширять доверие без нужды.
# X-Internal-Auth-Secret здесь НЕ подставляется: публичные ручки его не
# проверяют, а инжектить внутренний секрет в хоп с анонимного домена —
# расширять доверие без нужды. С #3324 это уже не отличие от B2B-блока
# gendsgn.ru, а общее правило: инжекта не осталось нигде в Caddyfile —
# заголовок нужен только внутрисетевому dual-mode трафику, который ходит
# мимо прокси и несёт его сам.
handle /trade-in/api/public/* {
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {

View file

@ -119,6 +119,51 @@ check "meraocenka.ru/trade-in/api/* — must 404 (не проксируем API)
# Ловит расширение матчера обратно до `/trade-in/_next/*`.
check "meraocenka.ru/_next/image — must 404 (не открываем оптимизатор)" "$BASE_MERA/trade-in/_next/image?url=%2Ftest.png&w=64&q=75" 404
# 2c-bis. Внутри разрешённой статики закрыто поддерево ЧУЖИХ маршрутов (#3324):
# App Router кладёт код постранично в chunks/app/<маршрут>/, и до этой
# правки аноним скачивал с публичного домена бандлы /admin, /team,
# /scrapers — с именами внутренних ручек внутри.
#
# КОД 404 ЗДЕСЬ НЕДОСТАТОЧЕН: несуществующий чанк Next тоже отдаёт 404,
# поэтому проверка не отличила бы «Caddy отсёк» от «Caddy проксировал, а
# файла нет» — и осталась бы зелёной после отката матчера. Отличаем по
# ТЕЛУ: `respond 404` Caddy пустой (0 байт), 404 от Next — непустой
# (замер на проде 02.09.2026: 9 байт).
check_caddy_404() {
local desc="$1" url="$2"
local out code size
out=$(curl -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 15 "$url" 2>/dev/null)
code=${out%% *}
size=${out##* }
if [ "$code" = "404" ] && [ "$size" = "0" ]; then
echo "PASS: $desc ($url -> 404, пустое тело = отсёк Caddy)"
else
echo "FAIL: $desc ($url -> got '${out:-<no response>}', expected '404 0')"
fail=1
fi
}
check_caddy_404 "meraocenka.ru — чанки /admin не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/page-smoke.js"
check_caddy_404 "meraocenka.ru — чанки /admin/analytics не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/admin/analytics/page-smoke.js"
check_caddy_404 "meraocenka.ru — чанки /team не раздаются" \
"$BASE_MERA/trade-in/_next/static/chunks/app/team/page-smoke.js"
# Обратная сторона того же матчера: статика САМОГО лэндинга обязана остаться
# живой. Без этой строки «починка» вида «404 на весь chunks/app/» выглядела бы
# успешной, а публичный сайт молча остался бы без JS.
layout_chunk=$(curl -s --max-time 15 "$BASE_MERA/" 2>/dev/null \
| grep -o '/trade-in/_next/static/chunks/app/layout-[^"]*\.js' | head -1)
if [ -z "$layout_chunk" ]; then
# Пустая строка вместо пути дала бы запрос к корню и зелёную проверку ни о
# чём — поэтому это FAIL, а не «пропустим».
echo "FAIL: не нашёл layout-чанк в HTML лэндинга (сам лэндинг сломан?)"
fail=1
else
check "meraocenka.ru — корневой layout-чанк лэндинга жив (200)" "$BASE_MERA$layout_chunk" 200
fi
# 2d. Публичный API МЕРЫ (#2911). Ровно две ручки под /api/public/mera/*
# доступны анонимно на обоих доменах; ВЕСЬ /api/v1/* на публичном домене
# по-прежнему 404.

View file

@ -78,7 +78,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import TextClause
from app.core.auth import get_role
from app.core.auth import get_role, yaml_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
@ -288,7 +288,9 @@ def _upsert_quota_override(
)
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
def _batch_quota_status(
db: Session, usernames: list[str], known_roles: dict[str, str] | None = None
) -> dict[str, dict[str, Any]]:
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
@ -351,13 +353,21 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
role = get_role(username)
except KeyError:
role = None
# #3316: get_role ходит в реестр, а вызывающий уже прочитал роли этих
# же строк — иначе батч снова стал бы N+1 (ловит
# test_list_employees_query_count_is_not_n_plus_1). Роль реестра —
# ровно то, что вернул бы get_role: он спрашивает реестр первым.
role: str | None
if known_roles is not None and username in known_roles:
role = known_roles[username]
else:
try:
role = get_role(username)
except KeyError:
role = None
if role == "admin":
unlimited = True
elif role is not None:
elif yaml_role(username) is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
@ -432,6 +442,20 @@ async def create_employee(
`identity_db` реестр (строка сотрудника), `db` продуктовая квота;
в дефолтном режиме это одна и та же сессия и одна транзакция.
"""
# #3316 defense-in-depth: имя, за которым в roles.yaml уже числятся права
# (admin/pilot/analyst), занять нельзя. Роль резолвится из реестра первой
# (app.core.auth.get_role), так что эскалации не было бы и без этой
# проверки — но совпадение имён само по себе означает двух разных людей с
# одним логином, и дешевле отказать на входе, чем разбирать это в логах.
legacy = yaml_role(body.username)
if legacy is not None and legacy != "employee":
logger.warning(
"create_employee: %r refused — username занят в roles.yaml (role=%s)",
body.username,
legacy,
)
raise HTTPException(status_code=409, detail="username reserved in roles config")
schema = identity_schema()
existing = identity_db.execute(
text(f"SELECT id FROM {schema.users_table} WHERE username = :u"),
@ -762,7 +786,11 @@ async def list_employees(
.all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
quota_by_username = _batch_quota_status(
db,
[row["username"] for row in rows],
known_roles={row["username"]: row["role"] for row in rows},
)
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]

View file

@ -8,6 +8,11 @@ from repo root. We deliberately do NOT share code between repos via
When updating one copy, update the other.
РАСХОЖДЕНИЕ С ЗЕРКАЛОМ (#3316, намеренное — не «синхронизировать» обратно):
здесь `get_role` резолвит роль СНАЧАЛА из реестра людей (`tradein_users.role` /
`auth.users.role`), и только потом из YAML. У основного бэкенда реестра нет,
там копия остаётся YAML-only.
Caddy gates the whole site with basic_auth (см. `caddy/users.caddy.snippet`)
и пропускает в backend заголовок `X-Authenticated-User: <username>` через
`header_up X-Authenticated-User {http.auth.user.id}` в каждом reverse_proxy.
@ -25,13 +30,15 @@ import logging
import re
from functools import lru_cache
from pathlib import Path
from typing import Literal, TypedDict
from typing import Literal, TypedDict, cast
import yaml
logger = logging.getLogger(__name__)
Role = Literal["admin", "pilot", "analyst", "expired"]
# legacy roles.yaml-роли + роли реестра ('admin'|'manager'|'employee', CHECK
# tradein м.192 / auth м.004). Оба набора приходят из одного `get_role` (#3316).
Role = Literal["admin", "pilot", "analyst", "expired", "manager", "employee"]
class UserScope(TypedDict):
@ -155,8 +162,74 @@ def _load_roles_config() -> dict:
# ---------------------------------------------------------------------------
def yaml_role(username: str) -> Role | None:
"""Роль из roles.yaml (без похода в реестр) или None, если юзера там нет.
Нужна там, где спрашивают именно про legacy-файл, а не про эффективную роль:
`team.create_employee` (#3316) не даёт занять имя, за которым в YAML уже
числятся права.
"""
users: dict[str, Role] = _load_roles_config()["users"]
return users.get(username)
def _registry_role(username: str) -> str | None:
"""Роль из реестра людей (`tradein_users.role` / `auth.users.role`) или None.
None означает «реестр про этого юзера ничего не сказал»: строки нет, роль
пустая, либо реестр вообще недоступен. Во всех трёх случаях решение
остаётся за roles.yaml падение БД не имеет права выключить legacy-вход.
Осознанный компромисс (#3316 review): последняя ветка — недоступный
реестр на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно
то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий
имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в
`team.create_employee`. Если коллизия всё же появится (ручной INSERT в
реестр, расширение roles.yaml) сбой БД станет окном эскалации, и тогда
эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не
дописывать проверки у вызывающих.
Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение
едет bind-параметром: снаружи в SQL не попадает ничего.
"""
try:
from sqlalchemy import text
from app.services.identity_store import identity_schema, identity_session
schema = identity_schema()
with identity_session() as db:
row = db.execute(
text(f"SELECT role FROM {schema.users_table} WHERE username = :username"),
{"username": username},
).fetchone()
except Exception:
logger.exception(
"registry role lookup failed for %r — fallback to roles.yaml",
username,
)
return None
if row is None or not row.role:
return None
return str(row.role)
def get_role(username: str) -> Role:
"""Return the role for *username* or raise KeyError if unknown."""
"""Эффективная роль *username*: реестр (БД) первый, roles.yaml — fallback.
Raises KeyError, если юзера нет ни там, ни там.
#3316: раньше роль резолвилась ТОЛЬКО из roles.yaml, при том что люди
заводятся в БД (`tradein_users`) два дефекта разом. Вверх: сотрудник,
чьё имя совпало с YAML-админом, получал admin (IDOR по чужим оценкам +
безлимит квоты). Вниз: сотрудник, которого в YAML нет, получал KeyError
403 на СОБСТВЕННУЮ оценку. Единственный источник истины теперь один, и он
здесь вызывающие (rbac, trade_in, team, account_quota) не меняются.
"""
db_role = _registry_role(username)
if db_role is not None:
return cast(Role, db_role)
config = _load_roles_config()
users: dict[str, Role] = config["users"]
if username not in users:
@ -217,13 +290,22 @@ def get_user_scope(username: str) -> UserScope:
"""
config = _load_roles_config()
role = get_role(username)
role_def = config["roles"][role]
role_def = config["roles"].get(role)
if role_def is None:
# Роль реестра (employee/manager) — её scope живёт в DB_ROLE_PATHS, а не
# в roles.yaml (#3316: get_role теперь может вернуть и такую роль).
from app.services.auth_session import get_db_role_scope
allowed_paths, deny_paths = get_db_role_scope(role)
else:
allowed_paths = list(role_def.get("paths", []) or [])
deny_paths = list(role_def.get("deny", []) or [])
display_name, org, email = get_profile_for_user(username)
return UserScope(
username=username,
role=role,
allowed_paths=list(role_def.get("paths", []) or []),
deny_paths=list(role_def.get("deny", []) or []),
allowed_paths=allowed_paths,
deny_paths=deny_paths,
brand=get_brand_for_user(username),
display_name=display_name,
org=org,

View file

@ -38,7 +38,7 @@ from fastapi.responses import JSONResponse, Response
from app.core.auth import get_role, is_path_allowed
from app.core.config import settings
from app.services.auth_session import get_db_role_scope, get_session_user
from app.services.auth_session import DB_ROLE_PATHS, get_db_role_scope, get_session_user
from app.services.identity_store import identity_session
logger = logging.getLogger(__name__)
@ -334,7 +334,11 @@ async def rbac_guard(
# scope-narrowed юзер не смог бы получить свою роль вовсе.
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
external_path = _EXTERNAL_PREFIX + path
if from_session:
# Матчер выбирается по РОДУ роли, а не только по источнику (#3316):
# с DB-first резолвом legacy trusted-header путь тоже может отдать роль
# реестра (employee/manager), а её паттернов в roles.yaml нет — сверка
# с `is_path_allowed` дала бы 403 на всё.
if from_session or role in DB_ROLE_PATHS:
allowed = _db_role_path_allowed(role, external_path)
else:
try:

View file

@ -46,7 +46,7 @@ from fastapi import HTTPException
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.auth import get_role
from app.core.auth import get_role, yaml_role
from app.core.config import settings
logger = logging.getLogger(__name__)
@ -61,8 +61,7 @@ def limit_exhausted_message(limit: int) -> str:
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
anon default_limit, см. #b2c-antiabuse-2)."""
return (
f"Лимит из {limit} оценок в этом месяце исчерпан. "
"За полной версией обращайтесь к Копылову."
f"Лимит из {limit} оценок в этом месяце исчерпан. За полной версией обращайтесь к Копылову."
)
@ -97,6 +96,14 @@ def is_unlimited(db: Session, username: str) -> bool:
return False
if role == "admin":
return True
# #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
# team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
# осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
# раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
# для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
# `_batch_quota_status` в списке «Команды»).
if yaml_role(username) is None:
return False
row = db.execute(
text(
"""

View file

@ -3359,7 +3359,10 @@ def _price_from_inputs(
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
anchor_label = "оценке Avito IMV"
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
_imv_mc = (
int(imv_anchor["market_count"])
@ -3396,7 +3399,10 @@ def _price_from_inputs(
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
anchor_label = "оценке Avito IMV"
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),

View file

@ -290,8 +290,9 @@ matplotlib.rcParams["font.family"] = "Manrope"
# ── Source pseudo-logos (текстовые pill-badges с брендовыми цветами источников) ─
# Неизвестный/снятый source (напр. историческое inactive 'n1', #2204) рендерится
# через безопасный fallback в _source_pill: серый фон + source.title() — код на
# исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# через безопасный fallback в _source_pill: серый фон + «Другой источник» (#3341 —
# сырой id тоже мог бы утечь именем площадки, source.title() больше не используется)
# — код на исторических строках НЕ падает. Цвета источников — реальные бренд-цвета
# сторонних площадок (Avito/Циан/...), НЕ часть внутренней design-системы —
# намеренно не конвертируются в наши OKLCH-токены.
_SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
@ -303,15 +304,59 @@ _SOURCE_LOGO_COLORS: dict[str, tuple[str, str]] = {
"etazhi": ("#e30613", "#fff"), # Этажи красный
}
# Публичные лейблы — канон `frontend/src/lib/source-registry.ts::SOURCES` (#3341,
# класс #3264): клиентский PDF не должен называть площадки-источники так же, как
# веб-отчёт и лендинг перестали делать это с 31.08. Ключи — ТОЛЬКО базовые id
# (алиасы валюации/написания резолвятся через _SOURCE_CANONICAL/_canonical_source
# ДО обращения сюда — иначе два источника правды для одной площадки).
_SOURCE_DISPLAY_NAMES: dict[str, str] = {
"avito": "Avito",
"cian": "Циан",
"domklik": "Домклик · Сбер",
"yandex": "Я.Недвижимость",
"avito": "Источник 1",
"cian": "Источник 2",
"yandex": "Источник 3",
"domklik": "Источник 4",
"etazhi": "Источник 5",
"rosreestr": "Росреестр",
"etazhi": "Этажи",
}
# Алиас → базовый id. Зеркалит группировку publicLabel/dot в
# frontend/src/lib/source-registry.ts (одна площадка = один номер/цвет там же);
# правишь один реестр — проверь другой (#3341 review: без канонизации
# estimate.sources_used = [avito, avito_imv, cian, cian_valuation, ...]
# (estimator.py `_canonical_sources`, listing+valuation union) рендерил
# ДВЕ одинаковые пилюли «Источник 1, Источник 1» и серую точку у алиаса —
# ни avito_imv/cian_valuation/yandex_valuation/domclick/etagi не было ключом
# ни в _SOURCE_LOGO_COLORS, ни (после дедупа лейблов) в _SOURCE_DISPLAY_NAMES).
_SOURCE_CANONICAL: dict[str, str] = {
"avito_imv": "avito",
"cian_valuation": "cian",
"yandex_valuation": "yandex",
"domclick": "domklik",
"etagi": "etazhi",
}
def _canonical_source(source: str) -> str:
"""Алиас (valuation-вариант / альтернативное написание) → базовый id площадки."""
return _SOURCE_CANONICAL.get(source, source)
def _public_sources(ids: list[str]) -> list[str]:
"""Канонизирует id и дедуплицирует с сохранением порядка первого появления.
`estimate.sources_used` отсортированное объединение listing-id и их
valuation-алиасов (estimator.py `_canonical_sources`), напр.
`[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]`.
Без канонизации+дедупа ДО среза `[:5]` рендерились дубли лейблов и «Источник 3»
(yandex) мог быть вытеснен алиасом другой площадки (#3341 review)."""
seen: set[str] = set()
result: list[str] = []
for raw in ids:
canon = _canonical_source(raw)
if canon not in seen:
seen.add(canon)
result.append(canon)
return result
def _source_logo_pill(source: str) -> str:
"""Source pill — мягкий HUD-чип (.source-pill, ObjectSummary.tsx dot-идиома):
@ -319,10 +364,12 @@ def _source_logo_pill(source: str) -> str:
непрозрачный цветной чип; теперь единая с остальным документом мягкая палитра
(Mera v2), бренд-цвет остаётся только акцентной точкой. Название из
_SOURCE_DISPLAY_NAMES (короткий bounded набор, max ~18 символов)
overflow-wrap на всякий случай, если source незнаком и попадёт .title()
произвольной длины (см. fallback ниже)."""
dot = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
overflow-wrap на всякий случай, если source незнаком (см. fallback ниже).
Канонизирует алиасы (avito_imv avito и т.п.) ДО lookup, чтобы цвет/лейбл
совпадали с базовой площадкой (#3341 review)."""
canon = _canonical_source(source)
dot = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))[0]
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
"<span class='source-pill' style='display:inline-flex;align-items:center;gap:4pt;"
f"margin:0 4pt 4pt 0;background:{_CARD_BG};color:{_BODY};vertical-align:middle;"
@ -333,11 +380,13 @@ def _source_logo_pill(source: str) -> str:
def _source_badge_inline(source: str | None) -> str:
"""Маленький source badge для table cells (без фона)."""
"""Маленький source badge для table cells (без фона). Канонизирует алиасы
ДО lookup см. _source_logo_pill."""
if not source:
return f"<span style='color:{_MUTED};'>—</span>"
bg, fg = _SOURCE_LOGO_COLORS.get(source, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(source, source.title())
canon = _canonical_source(source)
bg, fg = _SOURCE_LOGO_COLORS.get(canon, (_MUTED, "#fff"))
name = _SOURCE_DISPLAY_NAMES.get(canon, "Другой источник")
return (
f"<span style='display:inline-block;padding:1pt 4pt;background:{bg};color:{fg};"
f"font-size:{_FS_XS};font-weight:700;border-radius:2pt;'>{_html.escape(name)}</span>"
@ -1167,12 +1216,12 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
advice_discount_text = (
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
f"ДомКлик и продажи агентств недвижимости"
f"сделки площадок и продажи агентств недвижимости"
)
else:
advice_discount_text = (
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
"Росреестр, ДомКлик и продажи агентств недвижимости"
"Росреестр, сделки площадок и продажи агентств недвижимости"
)
disclaimer_html = ""
@ -1357,7 +1406,11 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
# count «с учётом ремонта» не существует, второе число было идентично n_total.
# Source logos (pseudo) — берём из estimate.sources_used (не захардкоженный список).
sources_to_show = estimate.sources_used or []
# #3341 review: sources_used — union listing+valuation алиасов (estimator.py
# `_canonical_sources`), напр. [avito, avito_imv, cian, cian_valuation, ...] —
# _public_sources канонизирует+дедуплицирует ДО среза [:5], иначе дубли пилюль
# и «Источник 3» мог быть вытеснен алиасом другой площадки.
sources_to_show = _public_sources(estimate.sources_used or [])
sources_html = "".join(_source_logo_pill(s) for s in sources_to_show[:5])
# Params правой колонки — параметры поиска (НЕ конкретной квартиры)
@ -1598,10 +1651,13 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
# #3341 review: канонизация+дедуп (_public_sources) ДО фильтра/среза [:5] — те же
# причины, что и на странице листингов (см. коммент там).
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
deal_sources = [s for s in (estimate.sources_used or []) if s in _deal_source_keys]
canonical_sources = _public_sources(estimate.sources_used or [])
deal_sources = [s for s in canonical_sources if s in _deal_source_keys]
if not deal_sources:
deal_sources = [s for s in (estimate.sources_used or [])]
deal_sources = canonical_sources
sources_html = "".join(_source_logo_pill(s) for s in deal_sources[:5])
area = float(input_snapshot.get("area_m2", 0) or 0)
@ -1847,7 +1903,7 @@ def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
<td style="padding:6pt 4pt;">
<div class="bold">Расходы на рекламу</div>
<div style="font-size:{_FS_XS};color:{_MUTED};">Ежемесячное базовое продвижение объекта
на Циан, Авито, Я.Недвижимости</div>
на основных площадках объявлений</div>
</td>
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
бесплатно</td>

View file

@ -447,6 +447,19 @@ async def run_domclick_detail_backfill(
enrichment = await fetch_detail(source_url, browser_fetcher=bf, cookies=cookies)
if save_detail_enrichment(db, listing_id, enrichment):
counters.enriched += 1
else:
# #3332, та же дыра в тождестве, что и у пустого пула: карточку
# забрали, а строки уже нет (удалена/деактивирована между
# снимком и UPDATE) — попытка была, исхода не было. Тихо
# терять её нельзя: расхождение читается как потерянный блок.
counters.failed += 1
logger.warning(
"domclick_detail_backfill: run_id=%d listing %s — карточка "
"разобрана, но UPDATE не нашёл строку id=%d",
run_id,
source_url,
listing_id,
)
consecutive_blocks = 0
consecutive_soft = 0
@ -471,6 +484,15 @@ async def run_domclick_detail_backfill(
# Прогон 5399 умер именно так: три «блока» подряд, из них два
# 500 от сайдкара и один пустой пул, отказов площадки — ноль.
if _caused_by_empty_pool(e):
# #3332: попытка уже посчитана в attempted (строка выше), а этот
# выход из цикла шёл мимо ВСЕХ исходов — тождество
# attempted = enriched + failed + blocked ломалось ровно на 1
# (прод: 5 прогонов с diff=1, каждый оборванный пустым пулом).
# Исход честно failed, а не blocked: к площадке не ходили, это
# отказ нашей стороны — тот же разряд, что у транспортных сбоев
# ниже. Причина не теряется: в записи прогона стоит
# no_proxy_stop=1 и mark_failed с текстом про пул.
counters.failed += 1
logger.error(
"domclick_detail_backfill: run_id=%d СТОП — пул прокси пуст, "
"к площадке не ходили. enriched=%d attempted=%d",

View file

@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
# значило бы завести проверку, которая в проде не срабатывает никогда.
REJECTION_RULE = (
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
"диапазон цены сделки (30 000600 000 ₽/м² для Екатеринбурга) применён "
"к выборке до расчёта, по цене самой сделки."
)
@ -123,7 +123,7 @@ NOTE = (
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
"она бывает занижена сторонами, и тогда "
"строка выглядит как промах оценщика, хотя врёт документ. "
"строка выглядит как промах расчёта, хотя врёт документ. "
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "

View file

@ -305,9 +305,12 @@ def collect_landing_metrics(db: Session) -> list[dict[str, Any]]:
).first()
if row is not None and row.n:
n = int(row.n)
# Публичная подпись метрики: площадка не называется (норма publicLabel,
# решение 31.08.2026); фактически это Домклик — единственный источник,
# где триггер пишет стартовую цену. Текст персистится в landing_stats.note.
base_note = (
f"Только Домклик (единственный источник, где триггер пишет стартовую цену), "
f"наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
"Только одна из площадок (единственный источник, где фиксируется "
f"стартовая цена), наблюдение от {_PRICE_SPAN_DAYS} дней, изменения свыше "
f"{_PRICE_MAX_ABS_PCT}% отброшены как смена объекта"
)
metrics.append(

View file

@ -194,7 +194,7 @@ def test_needs_repair_client_not_lifted_by_cosmetic_anchor() -> None:
"""
est = _run_estimate(_anchor(6_700_000, "cosmetic"), "needs_repair")
assert est.median_price_rub == int(_BASE_MEDIAN * 0.94)
assert "скорректирована по оценке Avito IMV" not in (est.confidence_explanation or "")
assert "скорректирована по оценочной модели площадки" not in (est.confidence_explanation or "")
def test_euro_anchor_not_applied_raw_to_unknown_repair_client() -> None:

View file

@ -0,0 +1,225 @@
"""Чекпоинт avito_city_sweep доживает до финализатора (#3319).
Точку писала ровно одна строка end-of-anchor heartbeat в конце итерации цикла
якорей. Финализаторы её не стирали (все писатели в runs.py мержат jsonb:
`counters || :counters`), дыра в другом: выходы, случившиеся РАНЬШЕ первой такой
записи, точки не оставляли вовсе cancel/SIGTERM-дрейн на границе первого якоря
и ранний done #1950 («SERP собран, detail заблокирован») на якоре №1. Ими и
кончается типичный прод-прогон с `anchors_done: 1` из 5.
Замер «0 из 67 прогонов за 60 дней несут done_buckets» тут НЕ доказательство:
строка записи появилась только 26.08.2026 (#3074) при такте avito 7 суток —
выборка почти целиком из эры, где механизма не существовало.
Три инварианта, ради которых тест:
1. done-выход несёт done_buckets иначе точка существует только в логе.
2. Якорь, умерший по таймауту, НЕ пройден: SERP мог успеть, detail нет.
Пройденным его записать = резюм пропустит его навсегда и молча.
3. SIGTERM-дрейн отличим от полного обхода (counters.interrupted=1) и
участвует в резюме статус у обоих 'done', счётчики частичные.
"""
from __future__ import annotations
import os
# Settings собирается автофикстурой conftest'а и требует database_url. Выставляем
# до остальных импортов — так же, как в test_3074_avito_anchor_checkpoint.py.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
import types
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
ANCHOR_A = (56.83, 60.60, "ekb-center")
ANCHOR_B = (56.79, 60.63, "ekb-south")
class _FakeDb:
"""Все UPDATE'ы с counters (heartbeat И финализаторы) складываются по порядку."""
def __init__(self) -> None:
self.writes: list[dict[str, Any]] = []
def execute(self, _stmt: Any, params: dict[str, Any] | None = None) -> Any:
if params and "counters" in params:
self.writes.append(json.loads(params["counters"]))
return MagicMock()
def commit(self) -> None: ...
def rollback(self) -> None: ...
class _FakeAsyncSession:
def __init__(self, *_a: Any, **_kw: Any) -> None: ...
async def __aenter__(self) -> _FakeAsyncSession:
return self
async def __aexit__(self, *_e: Any) -> None:
return None
class _FakeScraper:
"""Двойник AvitoScraper: помнит визиты, роняет заданный якорь заданной ошибкой."""
visited: list[tuple[float, float]] = [] # noqa: RUF012 — тестовый сборник
raise_on: tuple[float, float] | None = None
exc: type[BaseException] | None = None
lots_per_anchor: int = 0
def __init__(self, *_a: Any, **_kw: Any) -> None:
self._browser = None
self._cffi = None
async def fetch_around(self, lat: float, lon: float, *_a: Any, **_kw: Any) -> list:
_FakeScraper.visited.append((lat, lon))
if _FakeScraper.raise_on == (lat, lon) and _FakeScraper.exc is not None:
raise _FakeScraper.exc("якорь сорвался")
return [MagicMock() for _ in range(_FakeScraper.lots_per_anchor)]
def _config() -> types.SimpleNamespace:
return types.SimpleNamespace(
scraper_fetch_mode="cffi",
scraper_proxy_url=None,
use_proxy_pool_browser=False,
browser_http_endpoint=None,
environment="test",
avito_serp_ok_not_banned=True,
)
async def _run(
*,
raise_on: tuple[float, float] | None = None,
exc: type[BaseException] | None = None,
shutdown_after_first: bool = False,
saved: tuple[int, int] = (0, 0),
lots_per_anchor: int = 0,
) -> _FakeDb:
from scraper_kit.orchestration import pipeline as pl
_FakeScraper.visited = []
_FakeScraper.raise_on = raise_on
_FakeScraper.exc = exc
_FakeScraper.lots_per_anchor = lots_per_anchor
db = _FakeDb()
def _shutdown() -> bool:
return shutdown_after_first and bool(_FakeScraper.visited)
with (
patch.object(pl, "AvitoScraper", _FakeScraper),
patch.object(pl, "AsyncSession", _FakeAsyncSession),
patch.object(pl, "save_listings", lambda *_a, **_kw: saved),
patch.object(pl.runs, "is_cancelled", lambda *_a: False),
):
await pl.run_avito_city_sweep(
db, # type: ignore[arg-type]
run_id=3319,
config=_config(),
matcher=MagicMock(),
enrichment=MagicMock(),
anchors=[ANCHOR_A, ANCHOR_B],
enrich_houses=False,
enrich_imv=False,
detail_top_n=0,
shutdown_requested=_shutdown,
)
return db
@pytest.mark.asyncio
async def test_done_exit_carries_checkpoint() -> None:
"""Финализатор полного обхода несёт done_buckets, а не голые счётчики."""
db = await _run()
assert db.writes[-1].get("done_buckets") == ["ekb-center", "ekb-south"], (
"финальный (done) выход отдал counters без чекпоинта — точки в прогоне нет"
)
@pytest.mark.asyncio
async def test_serp_ok_done_exit_carries_checkpoint() -> None:
"""Ранний done-выход #1950 («SERP собран, detail заблокирован») — тоже.
Именно этим выходом кончается типичный прод-прогон, и он происходит РАНЬШЕ
единственной строки, которая писала точку.
"""
from scraper_kit.orchestration import pipeline as pl
db = await _run(
raise_on=(ANCHOR_B[0], ANCHOR_B[1]),
exc=pl.AvitoBlockedError,
saved=(1, 0), # SERP intake > 0 → ветка ставит 'done', а не 'banned'
lots_per_anchor=1,
)
last = db.writes[-1]
assert "enrichment_abort_note" in last, "сработала не та ветка выхода"
assert last.get("done_buckets") == ["ekb-center"], (
"ранний done-выход потерял якорь, пройденный до блокировки"
)
@pytest.mark.asyncio
async def test_timed_out_anchor_is_not_checkpointed() -> None:
"""Якорь, умерший по таймауту, не считается пройденным.
Иначе резюм пропустит его навсегда, и это будет незаметно: прогон
завершается штатно, просто часть города не собирается никогда.
"""
db = await _run(raise_on=(ANCHOR_A[0], ANCHOR_A[1]), exc=TimeoutError)
ckpt = db.writes[-1].get("done_buckets")
assert "ekb-center" not in ckpt, "якорь-таймаут попал в чекпоинт"
assert "ekb-south" in ckpt, "исправный якорь не зафиксирован"
@pytest.mark.asyncio
async def test_drain_exit_is_distinguishable_from_full_done() -> None:
"""SIGTERM-дрейн помечен interrupted=1; полный обход — нет."""
drained = await _run(shutdown_after_first=True)
full = await _run()
assert drained.writes[-1].get("interrupted") == 1, (
"оборванный дрейном прогон неотличим от полного обхода"
)
assert drained.writes[-1].get("done_buckets") == ["ekb-center"]
assert "interrupted" not in full.writes[-1], "полный обход помечен как оборванный"
def _prev_run(counters: dict[str, Any]) -> types.SimpleNamespace:
return types.SimpleNamespace(
prev_id=4707,
prev_status="done",
prev_counters=counters,
same_params=True,
age_h=2.0,
interval_days="7",
)
def test_drained_done_is_resumable_but_clean_done_is_not() -> None:
"""Метка дрейна доходит до решения о резюме — иначе она диагностика ради себя."""
from scraper_kit.orchestration.scheduler import _resume_decision
ckpt = {"done_buckets": ["ekb-center"], "resume_chain": 0}
resume_from, verdict = _resume_decision(_prev_run({**ckpt, "interrupted": 1}))
assert resume_from == 4707, f"дрейн не подхвачен: {verdict}"
resume_from, verdict = _resume_decision(_prev_run(ckpt))
assert resume_from is None, "полный обход подхватывать нечего"
assert verdict["resume_reason"] == "status_done"
# Прогон из эры до #3074: ключей нет вовсе — метка дрейна не должна менять
# вердикт «нечего подхватывать» на что-то другое.
_, verdict = _resume_decision(_prev_run({}))
assert verdict["resume_reason"] == "status_done"
_, verdict = _resume_decision(_prev_run({"interrupted": 1}))
assert verdict["resume_reason"] == "no_checkpoint"

View file

@ -0,0 +1,179 @@
"""Тождество счётчиков domclick_detail_backfill: attempted == сумма исходов (#3332).
`counters.attempted` инкрементируется ДО попытки, исход дописывается уже в
разборе результата. Обрыв «пул прокси пуст» (`_caused_by_empty_pool`
`no_proxy_stop = True; break`) уходил из цикла между этими двумя точками, поэтому
текущая попытка оставалась без исхода: прод дал 5 прогонов с
`attempted - (enriched + failed + blocked) == 1`. Расхождение читается как
потерянный отказ площадки, хотя площадку не трогали вовсе.
Исход выбран `failed`, а не `blocked`: к площадке не ходили, отказала наша
инфраструктура тот же разряд, что у транспортных сбоев (`_is_transport_failure`,
#3283). Причину прогона несёт `no_proxy_stop=1` + `mark_failed`, счётчик её не
подменяет.
Вторая дыра того же класса (закрыта здесь же): `save_detail_enrichment` вернул
False карточка разобрана, но строки уже нет; попытка была, исхода не было.
Проверка ПО ЗНАЧЕНИЮ: сравниваются числа, а не «не бросило исключение».
Харнесс зеркалит tests/test_3196_domclick_ban_kind.py.
"""
from __future__ import annotations
import os
import sys
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.domclick_exceptions import DomClickBlockedError # noqa: E402
from scraper_kit.proxy_errors import NoProxyAvailableError # noqa: E402
from app.tasks.domclick_detail_backfill import ( # noqa: E402
DomClickDetailBackfillResult,
run_domclick_detail_backfill,
)
_FETCH = "app.tasks.domclick_detail_backfill.fetch_detail"
_SAVE = "app.tasks.domclick_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.domclick_detail_backfill.runs_mod"
_SLEEP = "app.tasks.domclick_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.domclick_detail_backfill.settings"
_BROWSER_FETCHER = "app.tasks.domclick_detail_backfill.BrowserFetcher"
_SESSION_SVC = "app.tasks.domclick_detail_backfill.domclick_session_svc"
def _make_snapshot(n: int) -> list[dict]:
return [
{
"id": i + 1,
"source_url": f"https://ekaterinburg.domclick.ru/card/sale__flat__{i + 1}",
}
for i in range(n)
]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_session_svc() -> MagicMock:
svc = MagicMock()
svc.load_session.return_value = {"CAS_ID": "123"}
svc.COOKIE_EXPIRY_WARN_DAYS = 5
svc.session_expires_at.return_value = datetime.now(tz=UTC) + timedelta(days=30)
return svc
def _mock_browser_fetcher_cls() -> MagicMock:
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance)
def _empty_pool_block() -> DomClickBlockedError:
"""Как это приходит с прода: fetch_detail заворачивает ЛЮБОЙ сбой фетча в
DomClickBlockedError через `raise ... from exc`, поэтому пустой пул лежит в
__cause__ и снаружи выглядит блокировкой."""
blocked = DomClickBlockedError("browser fetch failed")
blocked.__cause__ = NoProxyAvailableError("domclick")
return blocked
async def _run(
fetch: AsyncMock, *, snapshot: int, save_ok: bool = True
) -> tuple[DomClickDetailBackfillResult, MagicMock]:
db = _mock_db(_make_snapshot(snapshot))
runs = MagicMock()
with (
patch(_SETTINGS, MagicMock(browser_http_endpoint="http://browser:9000")),
patch(_SESSION_SVC, _mock_session_svc()),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, _mock_browser_fetcher_cls()),
patch(_FETCH, fetch),
patch(_SAVE, return_value=save_ok),
patch(_SLEEP, new_callable=AsyncMock),
):
counters = await run_domclick_detail_backfill(
db,
run_id=3332,
params={"batch_size": snapshot, "budget_sec": 3600, "max_consecutive_blocks": 5},
)
return counters, runs
def _assert_identity(counters: DomClickDetailBackfillResult, *, expected_attempted: int) -> None:
outcomes = counters.enriched + counters.failed + counters.blocked
assert counters.attempted == expected_attempted, (
f"attempted={counters.attempted}, ожидали {expected_attempted} попыток"
)
assert counters.attempted == outcomes, (
f"тождество нарушено: attempted={counters.attempted}, "
f"сумма исходов={outcomes} (enriched={counters.enriched} "
f"failed={counters.failed} blocked={counters.blocked}), "
f"потеряно {counters.attempted - outcomes} попыток без исхода"
)
@pytest.mark.asyncio
async def test_empty_pool_mid_run_keeps_identity() -> None:
"""2 успеха, на 3-й попытке пул пуст → attempted=3 == 2 enriched + 1 failed."""
fetch = AsyncMock(side_effect=[MagicMock(), MagicMock(), _empty_pool_block()])
counters, runs = await _run(fetch, snapshot=5)
_assert_identity(counters, expected_attempted=3)
assert counters.failed == 1, (
f"failed={counters.failed}: оборванная пустым пулом попытка должна быть "
"засчитана отказом НАШЕЙ стороны"
)
assert counters.blocked == 0, (
f"blocked={counters.blocked}: площадка не отказывала, запроса к ней не было — "
"начислять блок значит лгать про причину (#3283)"
)
# Причина обрыва по-прежнему в записи прогона, а не подменена счётчиком.
runs.mark_failed.assert_called_once()
assert runs.mark_failed.call_args.args[3]["no_proxy_stop"] == 1
@pytest.mark.asyncio
async def test_missing_row_on_save_keeps_identity() -> None:
"""save_detail_enrichment вернул False (строки уже нет) → попытка не теряется."""
fetch = AsyncMock(return_value=MagicMock())
counters, _ = await _run(fetch, snapshot=2, save_ok=False)
_assert_identity(counters, expected_attempted=2)
assert counters.enriched == 0 and counters.failed == 2, (
f"enriched={counters.enriched} failed={counters.failed}: "
"непрошедший UPDATE — не успех и не блок"
)
@pytest.mark.asyncio
async def test_blocked_and_enriched_counted_once() -> None:
"""Контроль на противоположную ошибку: блоки/успехи по-прежнему по одному разу."""
fetch = AsyncMock(
side_effect=[
MagicMock(),
DomClickBlockedError("refused", status=403),
MagicMock(),
]
)
counters, _ = await _run(fetch, snapshot=3)
_assert_identity(counters, expected_attempted=3)
assert (counters.enriched, counters.blocked, counters.failed) == (2, 1, 0), (
f"enriched={counters.enriched} blocked={counters.blocked} failed={counters.failed}, "
"ожидали 2/1/0 — правка #3332 не должна начислять исход дважды"
)

View file

@ -0,0 +1,267 @@
"""Gate test (#3341): клиентский PDF не должен называть реальные площадки-источники.
Канон `frontend/src/lib/source-registry.ts::SOURCES` (публичные лейблы, закреплены
для веб-отчёта/лендинга с 31.08, #3264-scope): avito/avito_imv → «Источник 1»,
cian/cian_valuation «Источник 2», yandex/yandex_valuation «Источник 3»,
domklik/domclick «Источник 4», etagi/etazhi «Источник 5», rosreestr «Росреестр»,
неизвестный id «Другой источник» (НЕ сырой id им тоже может утечь имя площадки).
WeasyPrint стаблен (как в test_pdf_security.py) тесты дергают только HTML-билдеры.
"""
from __future__ import annotations
import os
import re
import sys
from datetime import UTC, datetime, timedelta
from uuid import uuid4
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from unittest.mock import MagicMock
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
from app.services.brand import Brand # noqa: E402
from app.services.exporters import trade_in_pdf as mod # noqa: E402
_GENERIC = Brand(
slug="generic",
name="Trade-In",
logo_url=None,
primary_color="#1d4ed8",
accent_color="#f59e0b",
footer_text=None,
pdf_disclaimer=None,
)
_SNAPSHOT = {
"address": "Екатеринбург, ул. Ленина, 1",
"area_m2": 50.0,
"rooms": 2,
"floor": 3,
"total_floors": 9,
"year_built": 2010,
"house_type": "panel",
"repair_state": "standard",
"has_balcony": True,
}
# Все известные id источников (листинги + valuation-варианты + сделки) + неизвестный.
_ALL_SOURCE_IDS = [
"avito",
"avito_imv",
"cian",
"cian_valuation",
"yandex",
"yandex_valuation",
"domklik",
"domclick",
"etazhi",
"rosreestr",
]
# Запрещённые в видимом тексте PDF реальные имена площадок (#pdf-honesty/#3264).
_FORBIDDEN_NAMES = [
"Avito",
"AVITO",
"Авито",
"Циан",
"ЦИАН",
"Cian",
"ДомКлик",
"Домклик",
"ДОМКЛИК",
"Domclick",
"Я.Недвижимость",
"Яндекс",
"Yandex",
"Этажи",
"Avito_Imv",
"Rosreestr",
]
def _analog(**overrides) -> AnalogLot:
base = dict(
address="ул. Тест, 1",
area_m2=50.0,
rooms=2,
floor=3,
total_floors=9,
price_rub=5_000_000,
price_per_m2=100_000,
listing_date=None,
days_on_market=None,
source="avito",
source_url="https://avito.ru/1",
distance_m=200,
)
base.update(overrides)
return AnalogLot(**base)
def _estimate(**overrides) -> AggregatedEstimate:
base = dict(
estimate_id=uuid4(),
median_price_rub=10_000_000,
range_low_rub=9_000_000,
range_high_rub=11_000_000,
median_price_per_m2=200_000,
confidence="high",
n_analogs=15,
period_months=24,
analogs=[],
actual_deals=[],
expires_at=datetime.now(UTC) + timedelta(days=30),
)
base.update(overrides)
return AggregatedEstimate(**base)
def _strip_tags(html: str) -> str:
"""Видимый текст без разметки — href/src (легальные URL объявлений) не проверяем."""
return re.sub(r"<[^>]+>", " ", html)
# ── Part A: helper-функции напрямую (source-pill / badge fallback) ───────────
@pytest.mark.parametrize("forbidden", _FORBIDDEN_NAMES)
def test_source_logo_pill_never_leaks_forbidden_names(forbidden: str) -> None:
"""Ни один известный id, ни неизвестный не рендерят реальное имя площадки."""
html = "".join(mod._source_logo_pill(sid) for sid in [*_ALL_SOURCE_IDS, "n1"])
assert forbidden not in html
@pytest.mark.parametrize("forbidden", _FORBIDDEN_NAMES)
def test_source_badge_inline_never_leaks_forbidden_names(forbidden: str) -> None:
html = "".join(mod._source_badge_inline(sid) for sid in [*_ALL_SOURCE_IDS, "n1"])
assert forbidden not in html
def test_source_logo_pill_known_ids_map_to_public_labels() -> None:
assert "Источник 1" in mod._source_logo_pill("avito")
assert "Источник 1" in mod._source_logo_pill("avito_imv")
assert "Источник 2" in mod._source_logo_pill("cian")
assert "Источник 2" in mod._source_logo_pill("cian_valuation")
assert "Источник 3" in mod._source_logo_pill("yandex")
assert "Источник 3" in mod._source_logo_pill("yandex_valuation")
assert "Источник 4" in mod._source_logo_pill("domklik")
assert "Источник 4" in mod._source_logo_pill("domclick")
assert "Источник 5" in mod._source_logo_pill("etazhi")
assert "Источник 5" in mod._source_logo_pill("etagi")
assert "Росреестр" in mod._source_logo_pill("rosreestr")
def test_source_logo_pill_unknown_id_falls_back_to_other_source() -> None:
"""Неизвестный source → «Другой источник», НЕ сырой id (сам тоже мог бы назвать площадку)."""
html = mod._source_logo_pill("something_new")
assert "Другой источник" in html
assert "Something_New" not in html
assert "something_new" not in html
def test_source_badge_inline_unknown_id_falls_back_to_other_source() -> None:
html = mod._source_badge_inline("something_new")
assert "Другой источник" in html
assert "Something_New" not in html
assert "something_new" not in html
# ── Part B: билдеры страниц (listings/deals/cover/offer) end-to-end ──────────
def test_listings_page_all_sources_no_forbidden_names() -> None:
analogs = [
_analog(source=sid, source_url=f"https://example.org/{i}", distance_m=i * 10)
for i, sid in enumerate([*_ALL_SOURCE_IDS, "n1"])
]
est = _estimate(sources_used=_ALL_SOURCE_IDS, analogs=analogs, n_analogs=len(analogs))
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
text = _strip_tags(html)
for forbidden in _FORBIDDEN_NAMES:
assert forbidden not in text, f"{forbidden!r} leaked into listings page"
# Listings page shows only listing sources (first 5 of sources_used/analogs by
# distance) — rosreestr (deals) legitimately doesn't reach it here, see deals-page
# test below for that assertion.
assert "Источник 1" in text
def test_listings_page_dedupes_alias_sources_into_single_pill() -> None:
"""estimator.py `_canonical_sources` — sources_used — отсортированное union
listing-id + их valuation-алиасов, напр.
[avito, avito_imv, cian, cian_valuation, domklik, yandex, yandex_valuation]
(#3341 review). Листинги должны рендерить РОВНО одну пилюлю на канонический
источник (не по одной на алиас), и алиас обязан нести цвет точки базовой
площадки, не серый fallback."""
sources_used = [
"avito",
"avito_imv",
"cian",
"cian_valuation",
"domklik",
"yandex",
"yandex_valuation",
]
est = _estimate(sources_used=sources_used)
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
for label in ("Источник 1", "Источник 2", "Источник 3", "Источник 4"):
count = html.count(label)
assert count == 1, f"{label!r} should render exactly once, got {count}"
# avito_imv (valuation-алиас) обязан нести тот же бренд-цвет точки, что и avito.
assert "#00aaff" in mod._source_logo_pill("avito")
assert "#00aaff" in mod._source_logo_pill("avito_imv")
def test_deals_page_all_sources_no_forbidden_names() -> None:
deals = [
_analog(source=sid, source_url=f"https://example.org/{i}", distance_m=i * 10)
for i, sid in enumerate(["etazhi", "domklik", "rosreestr", "n1"])
]
est = _estimate(
sources_used=["etazhi", "domklik", "rosreestr"],
actual_deals=deals,
)
html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC)
text = _strip_tags(html)
for forbidden in _FORBIDDEN_NAMES:
assert forbidden not in text, f"{forbidden!r} leaked into deals page"
assert "Росреестр" in text
def test_cover_advice_discount_text_no_forbidden_names_without_pct() -> None:
"""Ветка без expected_sold_price_rub (_discount_pct → None) — только else-текст."""
est = _estimate()
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
text = _strip_tags(html)
assert "ДомКлик" not in text
assert "сделки площадок" in text
assert "Росреестр" in text
def test_cover_advice_discount_text_no_forbidden_names_with_pct() -> None:
"""С expected_sold_price_rub _discount_pct возвращает %, покрываем именно
эту f-строку (review #3341: без sold-цены тест не доходил до неё)."""
est = _estimate(expected_sold_price_rub=9_000_000)
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
text = _strip_tags(html)
assert "на 10%" in text
assert "сделки площадок" in text
assert "ДомКлик" not in text
assert "Росреестр" in text
def test_offer_page_advertising_row_no_brand_names() -> None:
est = _estimate()
html = mod._build_offer_page(est, _SNAPSHOT, _GENERIC)
text = _strip_tags(html)
assert "на основных площадках объявлений" in text
assert "Циан" not in text
assert "Авито" not in text
assert "Я.Недвижимости" not in text

View file

@ -381,13 +381,14 @@ def test_listings_page_no_crash_with_no_sources() -> None:
def test_listings_sources_from_estimate_not_hardcoded() -> None:
"""sources_used=['yandex'] → только yandex pill, не avito/cian/domklik/..."""
"""sources_used=['yandex'] → только источник 3 (yandex) pill, не 1/2/4/... (#3341:
публичные лейблы, не реальные названия площадок)."""
est = _estimate(sources_used=["yandex"])
html = mod._build_listings_page(est, _SNAPSHOT, _GENERIC)
assert "Я.Недвижимость" in html
assert "Источник 3" in html
# avito and cian should NOT appear if not in sources_used
assert "Avito" not in html
assert "Циан" not in html
assert "Источник 1" not in html
assert "Источник 2" not in html
def test_deals_sources_from_estimate_not_hardcoded() -> None:
@ -395,9 +396,9 @@ def test_deals_sources_from_estimate_not_hardcoded() -> None:
est = _estimate(sources_used=["rosreestr"])
html = mod._build_deals_page(est, _SNAPSHOT, _GENERIC)
assert "Росреестр" in html
# Этажи/Домклик — не должны появиться
assert "Этажи" not in html
assert "Домклик" not in html
# источник 5 (этажи) / источник 4 (домклик) — не должны появиться (#3341)
assert "Источник 5" not in html
assert "Источник 4" not in html
def test_examples_rows_none_price_per_m2_no_crash() -> None:

View file

@ -42,6 +42,23 @@ def _reset_auth_cache() -> None:
auth_mod.reset_cache_for_tests()
@pytest.fixture(autouse=True)
def _legacy_yaml_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""Реестр в ЭТОМ файле молчит — здесь проверяется legacy-путь roles.yaml.
#3316 сделал `get_role` DB-first (реестр → YAML-fallback), и без этой
изоляции результат файла зависел бы от ОКРУЖЕНИЯ: локально без БД шла
YAML-ветка и всё было зелено, а в CI, где реестр засеян миграцией 193
(kopylov=manager, user*=employee), те же ассерты краснели. Тест, который
отвечает по-разному в двух окружениях, не проверяет ничего.
Здесь закреплена ровно YAML-семантика (разбор файла, globs, поведение
guard'а и /me на trusted-header пути); DB-first, приоритет реестра и
эквивалентность scope employeepilot покрыты tests/test_role_single_source.py.
"""
monkeypatch.setattr(auth_mod, "_registry_role", lambda username: None)
# ---------------------------------------------------------------------------
# Test app — использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py), а не копию.
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,188 @@
"""#3316 — роль резолвится из ОДНОГО источника: реестр (БД) первый, roles.yaml — fallback.
Проверяется значение роли, а не факт вызова механизма:
* имя из roles.yaml, заведённое в реестре сотрудником роль `employee`
(эскалации в admin нет: ни IDOR по чужим оценкам, ни безлимитной квоты);
* сотрудник, которого в roles.yaml НЕТ роль резолвится, ownership-check
пропускает его к СВОЕЙ оценке и держит на чужой (раньше был KeyError 403);
* legacy-юзер (есть в YAML, в реестре строки нет) роль ровно как раньше
сверяется ВЕСЬ маппинг roles.yaml, а не один аккаунт;
* реестр недоступен fallback на YAML (падение БД не выключает legacy-вход).
FastAPI здесь не поднимается: резолвер чистая функция от (реестр, YAML),
реестр подменяется фейковой сессией.
"""
from __future__ import annotations
import os
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from types import SimpleNamespace
from typing import Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
from app.core import auth
from app.services import identity_store
_MISSING = object()
class _FakeSession:
"""Отдаёт одну строку `SELECT role ...` — или ничего, если роли нет."""
def __init__(self, role: str | None) -> None:
self.role = role
self.sql: str | None = None
self.params: dict[str, Any] | None = None
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> Any:
self.sql = str(sql)
self.params = params
row = None if self.role is None else SimpleNamespace(role=self.role)
return SimpleNamespace(fetchone=lambda: row)
@pytest.fixture
def registry(monkeypatch: pytest.MonkeyPatch) -> Callable[..., _FakeSession | None]:
"""`registry(role)` — что реестр отвечает на запрос роли.
role=None строки нет (legacy-юзер); role=_MISSING реестр падает.
"""
def install(role: str | None | object) -> _FakeSession | None:
if role is _MISSING:
@contextmanager
def broken_session() -> Iterator[Any]:
raise RuntimeError("registry down")
yield # pragma: no cover — нужен, чтобы функция была генератором
monkeypatch.setattr(identity_store, "identity_session", broken_session)
return None
session = _FakeSession(role) # type: ignore[arg-type]
@contextmanager
def fake_session() -> Iterator[_FakeSession]:
yield session
monkeypatch.setattr(identity_store, "identity_session", fake_session)
return session
return install
def _yaml_users() -> dict[str, str]:
return dict(auth._load_roles_config()["users"])
def _yaml_admin() -> str:
for username, role in _yaml_users().items():
if role == "admin":
return username
pytest.skip("в auth/roles.yaml нет ни одного admin — тест неприменим")
def test_registry_employee_beats_yaml_admin(registry: Callable[..., Any]) -> None:
"""Эскалация закрыта: имя YAML-админа + строка `employee` в реестре = employee."""
victim_name = _yaml_admin()
session = registry("employee")
assert auth.get_role(victim_name) == "employee"
# username едет bind-параметром, а не склейкой в SQL.
assert session.params == {"username": victim_name}
assert victim_name not in (session.sql or "")
def test_employee_absent_from_yaml_resolves_and_owns_estimate(
registry: Callable[..., Any],
) -> None:
"""Сотрудник вне roles.yaml: роль есть, своя оценка читается, чужая — нет."""
name = "employee_not_in_yaml_3316"
assert name not in _yaml_users()
registry("employee")
from fastapi import HTTPException
from app.api.v1.trade_in import _assert_estimate_access
# Продуктовое поведение проверяется ПЕРВЫМ: до #3316 здесь прилетал 403
# («user not in roles config») на СОБСТВЕННУЮ оценку сотрудника.
_assert_estimate_access(name, name)
assert auth.get_role(name) == "employee"
with pytest.raises(HTTPException) as exc:
_assert_estimate_access("someone_else", name)
assert exc.value.status_code == 404
def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> None:
"""В реестре строки нет → роли ВСЕХ YAML-юзеров ровно те же, что и были."""
registry(None)
users = _yaml_users()
assert users, "roles.yaml без юзеров — сверять нечего"
assert {username: auth.get_role(username) for username in users} == users
def _yaml_pilot() -> str:
for username, role in _yaml_users().items():
if role == "pilot":
return username
pytest.skip("в auth/roles.yaml нет ни одного pilot — тест неприменим")
def test_prod_config_pilot_in_yaml_employee_in_registry(registry: Callable[..., Any]) -> None:
"""Прод-конфигурация 12 из 13 аккаунтов: строка в реестре ЕСТЬ и роль там иная.
Реестр главнее (`employee`), а объём прав от этого не меняется: scope
DB-роли `employee` обязан совпадать с вчерашним YAML-scope роли `pilot`.
Списки сверяются целиком дрейф ЛЮБОГО из двух ловится здесь, а не
тихой потерей/выдачей раздела в проде.
"""
from app.services.auth_session import DB_ROLE_PATHS
registry("employee")
assert auth.get_role(_yaml_pilot()) == "employee"
pilot = auth._load_roles_config()["roles"]["pilot"]
allow, deny = DB_ROLE_PATHS["employee"]
assert sorted(allow) == sorted(pilot["paths"])
assert sorted(deny) == sorted(pilot["deny"] or [])
def test_prod_config_pilot_in_yaml_manager_in_registry(registry: Callable[..., Any]) -> None:
"""Конфигурация kopylov: YAML pilot + реестр manager → manager.
Лишних путей на tradein-периметре это не даёт: manager отличается от
employee ровно одним префиксом `/api/v1/team/**` (дашборд «Команды»,
ВНЕ `/trade-in/**`), а deny-списки совпадают.
"""
from app.services.auth_session import DB_ROLE_PATHS
registry("manager")
assert auth.get_role(_yaml_pilot()) == "manager"
emp_allow, emp_deny = DB_ROLE_PATHS["employee"]
mgr_allow, mgr_deny = DB_ROLE_PATHS["manager"]
extra = set(mgr_allow) - set(emp_allow)
assert extra == {"/api/v1/team/**"}
assert not any(p.startswith("/trade-in") for p in extra)
assert sorted(mgr_deny) == sorted(emp_deny)
def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None:
"""Реестр недоступен → legacy-вход продолжает работать по YAML."""
registry(_MISSING)
assert auth.get_role(_yaml_admin()) == "admin"
def test_yaml_role_is_yaml_only(registry: Callable[..., Any]) -> None:
"""Предикат гварда create_employee смотрит ИМЕННО в YAML, мимо реестра."""
registry("employee")
assert auth.yaml_role(_yaml_admin()) == "admin"
assert auth.yaml_role("employee_not_in_yaml_3316") is None

View file

@ -63,7 +63,18 @@ describe("describeCoverage", () => {
it("когда дата известна у всех — доля не упоминается вовсе", () => {
// «дата известна у 30 из 30» — шум: доля есть, но она ничего не сообщает.
const [, ageTile] = describeCoverage(probe({ n_listings: 30, n_with_age: 30 })).tiles;
expect(ageTile.label).toBe("столько в среднем висит объявление");
expect(ageTile.label).toBe("столько и дольше висит каждое второе объявление");
});
it("возраст подписан как медиана — «в среднем» под percentile_cont(0.5) неправда", () => {
// Бэкенд считает `percentile_cont(0.5)` (trade_in.py::_coverage_probe).
// На возрасте объявлений распределение скошено вправо: среднее заметно
// больше медианы, и подпись «в среднем» называла бы другую величину.
for (const over of [{ n_with_age: 6 }, { n_with_age: 34 }]) {
const [, ageTile] = describeCoverage(probe(over)).tiles;
expect(ageTile.label).not.toMatch(/в среднем/i);
expect(ageTile.label).toMatch(/каждое второе|половин|медиан/i);
}
});
it("город вне покрытия — говорим прямо и не предлагаем платный шаг", () => {
@ -142,9 +153,49 @@ describe("describeCityExpectation", () => {
const said = describeCityExpectation("Ревда");
expect(said?.text).toContain("проверках");
expect(said?.text).not.toMatch(/точност/i);
// Доли складываются в целое: 16 уверенных + 84 остальных — иначе текст
// утверждает больше, чем измерено.
expect(said?.text).toContain("84");
});
it("остаток разложен: где считаем с широким разбросом, а где не считаем вовсе", () => {
// Прежний текст называл остаток одним числом (100 уверенные) и обещал
// расчёт на всём нём. Но пустые пробы сидят внутри этого остатка, а
// `describeCoverage` отвечает на них «рядом не продаётся ничего похожего»
// и платный шаг не предлагает. Числа берём из замера, а не из строки:
// подстрокой «3» нашлось бы и внутри «83».
for (const measure of CITY_COVERAGE) {
const thin = 100 - measure.confidentPct - measure.emptyPct;
const said = describeCityExpectation(measure.city);
const nums = (said?.text.match(/\d+/g) ?? []).map(Number);
expect(measure.emptyPct, `${measure.city}: нечего разделять`).toBeGreaterThan(0);
expect(nums, `${measure.city}: нет доли уверенных`).toContain(measure.confidentPct);
expect(nums, `${measure.city}: нет доли «посчитаем, но шире»`).toContain(thin);
expect(nums, `${measure.city}: нет доли пустых когорт`).toContain(measure.emptyPct);
// Инвариант ЗАМЕРА, а не арифметики: уверенные и пустые обязаны
// оставлять место третьей доле, иначе рендер печатает «Ещё в 0».
expect(
measure.confidentPct + measure.emptyPct,
`${measure.city}: средней доле не осталось места`,
).toBeLessThan(100);
// Про пустые когорты сказано, что мы НЕ считаем, — иначе текст обещает
// расчёт там, где следующий же экран в нём откажет.
expect(said?.text, `${measure.city}: обещан расчёт на пустой когорте`).toMatch(
/считать не станем/,
);
}
});
it("когда средней доли не осталось — предложения про неё нет вовсе", () => {
// Перезамер вида 85 уверенных + 15 пустых законен, и «Ещё в 0 данных
// меньше» на нём было бы сообщением о доле, которой не существует.
const said = describeCityExpectation("Ревда", {
city: "Ревда",
confidentPct: 85,
emptyPct: 15,
sampleN: 100,
});
expect(said?.text).not.toMatch(/Ещё в 0|в 0 /);
expect((said?.text.match(/\d+/g) ?? []).map(Number)).toEqual([85, 100, 15]);
expect(said?.text).toMatch(/считать не станем/);
});
it("неизвестный город не выдумывает величину", () => {

View file

@ -0,0 +1,177 @@
/**
* Реальные названия площадок-источников не должны попадать в видимый
* клиентский UI (лендинг «Мера» + компоненты отчёта trade-in).
*
* Канон: `src/lib/source-registry.ts` `publicLabel`/`sourcePublicLabel()`.
* Одна площадка = один и тот же нейтральный номер («Источник N»), Росреестр
* исключение (не листинговая площадка, юр-риска в названии нет). Причина
* тот же юр-риск, что закрывал #2211/#3264-scope: конкретное название
* площадки в клиентском отчёте коммерческая информация, которую посетитель
* видеть не должен.
*
* ОТЛИЧИЕ от соседнего `public-copy-no-infra.test.ts`: тот гейт специально
* сканирует ВЕСЬ текст файла, включая комментарии («на боевой базе» в
* комментарии так же вредно, как в строке комментарий может стать
* заготовкой будущей копии). Здесь наоборот: название площадки в комментарии
* ЗАКОННО и нужно разработчику оно объясняет происхождение данных
* (`// avito_imv — реальная Avito IMV-оценка`), а `sourceLabel`/`SOURCES`
* в `source-registry.ts` обязаны хранить реальные лейблы для админки. Поэтому
* перед проверкой из текста вырезаются `//`- и `/* *\/`-комментарии, и гейт
* смотрит только на то, что реально дойдёт до посетителя в виде строки.
*
* Ограничение вырезки комментариев: `//` не режется, если ему предшествует
* `:` (т.е. это `https://`/`http://` внутри строки, а не начало комментария).
* Полноценный JS/TS-парсер тут избыточен в скоупе сканирования нет `//`
* иначе как в URL или в начале комментария (проверено вручную).
*
* Второе отличие от инфра-гейта: тот matches подстрокой (`includes`)
* инфраструктурные имена никогда не встречаются внутри TS-идентификаторов.
* Названия площадок встречаются: тип `CianValuationSummary`, хук
* `useEstimateCianPriceChanges`, переменная `cianId` это ЗАКОННЫЙ код
* (правило задачи: имена переменных/типов/хуков не трогаем, меняется только
* то, что видит человек). Поэтому здесь матчинг по границе слова
* (Unicode-aware `\p{L}`/`\p{N}`, не ASCII `\b`, иначе он вообще не сработает
* на кириллице), а не голый `includes`.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const MERA_PUBLIC_ROOT = join(__dirname, "..");
const TRADE_IN_COMPONENTS_ROOT = join(__dirname, "../../../components/trade-in");
/**
* Реальные названия площадок. Список ЯВНЫЙ (не эвристика по паттерну)
* см. докстринг соседнего инфра-гейта про тот же выбор: эвристика ловила бы
* законные вещи (напр. домены listing-ссылок avito.ru/cian.ru, которые
* остаются в href по правилу «URL объявлений не трогаем»).
*
* Матчинг НАМЕРЕННО регистрозависимый: строчные `"avito"` / `"cian"` /
* `"yandex"` это id источников (`keys: ["avito", "avito_imv"]`,
* `sourcePublicLabel("cian")`), и они законны. Поэтому капс-варианты
* (плитки, сноски статей «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» жил в статье и
* проходил гейт) и словоформы («Домклике», «Яндексу») перечислены явно.
* Яндекс/Yandex здесь и как площадка (Я.Недвижимость), и как геокодер
* («Yandex / Nominatim» в подсказке адреса): посетителю не нужен ни тот,
* ни другой.
*/
const BANNED = [
"Avito",
"AVITO",
"Авито",
"АВИТО",
"Циан",
"ЦИАН",
"Циана",
"Циане",
"Cian",
"CIAN",
"Я.Недвижимость",
"Я.НЕДВИЖИМОСТЬ",
"ДомКлик",
"Домклик",
"ДОМКЛИК",
"Домклике",
"Домклика",
"Domclick",
"DOMCLICK",
"Яндекс",
"ЯНДЕКС",
"Яндексу",
"Яндекса",
"Яндекс.Недвижимость",
"Yandex",
"YANDEX",
"Этажи",
"ЭТАЖИ",
"Avito Оценка",
"Я.Оценка",
"Циан Оценка",
// Бренд-термины оценочной модели Avito: «IMV» / «Information Market Value».
// Идентификаторы (IMVBenchmark, imvPrice) границей слова не задеваются.
"IMV",
"Information Market Value",
];
/**
* Файлы копии в скоупе: лендинг «Мера» + компоненты отчёта trade-in.
* `src/app/scrapers/**` и `src/components/scrapers/**` (admin-only, отдельные
* top-level каталоги) вне обоих корней сканирования их сюда не заносит.
*/
function copySources(dir: string, acc: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (name === "__tests__" || name === "node_modules") continue;
const full = join(dir, name);
if (statSync(full).isDirectory()) {
copySources(full, acc);
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
acc.push(full);
}
}
return acc;
}
/** Вырезает `/* *\/`- и `//`-комментарии; `//` внутри `https://` не режет. */
function stripComments(text: string): string {
// Newlines внутри блочного комментария сохраняем (заменяем на "\n" по числу
// строк), иначе после вырезки съедутся номера строк в отчёте о находке.
const noBlock = text.replace(/\/\*[\s\S]*?\*\//g, (m) =>
"\n".repeat((m.match(/\n/g) ?? []).length),
);
return noBlock
.split("\n")
.map((line) => {
// Ищем `//`, которому НЕ предшествует `:` (т.е. не часть `http(s)://`).
const idx = line.search(/(?<!:)\/\//);
return idx === -1 ? line : line.slice(0, idx);
})
.join("\n");
}
/**
* true, если `needle` встречается в `line` как отдельное слово не часть
* более длинного TS-идентификатора (`CianValuationSummary`, `cianId`).
* `\b` тут не годится: он ASCII-only и на кириллице не сработает вообще
* (ни один кириллический символ не входит в `\w`), поэтому граница
* вручную через Unicode `\p{L}`/`\p{N}`.
*/
function hasWholeWord(line: string, needle: string): boolean {
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, "u");
return re.test(line);
}
describe("публичная копия не содержит реальных названий площадок", () => {
const files = [
...copySources(MERA_PUBLIC_ROOT),
...copySources(TRADE_IN_COMPONENTS_ROOT),
];
it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => {
expect(files.length).toBeGreaterThan(20);
expect(files.some((f) => f.endsWith("HeroSummary.tsx"))).toBe(true);
expect(files.some((f) => f.endsWith("IMVBenchmark.tsx"))).toBe(true);
});
it.each(BANNED)("нигде в видимой копии не встречается «%s»", (needle) => {
const hits: string[] = [];
for (const file of files) {
const raw = readFileSync(file, "utf8");
const text = stripComments(raw);
text.split("\n").forEach((line, i) => {
if (hasWholeWord(line, needle)) {
hits.push(`${file}:${i + 1}`);
}
});
}
expect(
hits,
`«${needle}» — реальное название площадки в видимой клиентской копии. ` +
`Используй sourcePublicLabel() из src/lib/source-registry.ts вместо ` +
`хардкода: посетитель отчёта не должен видеть, какие площадки реально ` +
`участвовали в оценке (юр-риск, #3264-scope). В комментариях это имя ` +
`законно — гейт сюда не смотрит.`,
).toEqual([]);
});
});

View file

@ -0,0 +1,85 @@
/**
* Какие сделки попадают в игру «Угадай цену».
*
* Витрина отсортирована по полноте и свежести, а не по разнообразию, поэтому
* первые три её строки на проде оказывались тремя студиями с одной улицы: игра
* трижды спрашивала цену одной и той же квартиры. Проверяется ЗНАЧЕНИЕ выбора
* какие именно строки взяты, а не факт вызова функции.
*/
import { describe, expect, it } from "vitest";
import { pickVariedDeals } from "../_components/v3/deal-view";
import type { ShowcaseDeal } from "../public-api";
const deal = (rooms: number, street: string | null, area_m2 = 40): ShowcaseDeal =>
({
district: "Кировский",
rooms,
area_m2,
floor: 3,
total_floors: 9,
deal_quarter: "II квартал 2026",
predicted_rub: 5_000_000,
fact_rub: 5_000_000,
err_pct: 0,
n_analogs: 8,
note: "",
street_name: street,
street_scheme: null,
lat: null,
lon: null,
}) as ShowcaseDeal;
/** Порядок — как на витрине: студии одной улицы стоят первыми. */
const showcase: readonly ShowcaseDeal[] = [
deal(0, "улица Ленина", 25),
deal(0, "улица Ленина", 27),
deal(0, "улица Ленина", 29),
deal(2, "улица Ленина", 62),
deal(1, "улица Мира", 38),
deal(2, "улица Щорса", 58),
];
describe("pickVariedDeals", () => {
it("три раунда — три разные комнатности и три разные улицы", () => {
const picked = pickVariedDeals(showcase, 3);
expect(picked).toHaveLength(3);
expect(picked.map((d) => d.rooms)).toEqual([0, 1, 2]);
expect(picked.map((d) => d.street_name)).toEqual([
"улица Ленина",
"улица Мира",
"улица Щорса",
]);
// Прежний slice(0, 3) взял бы три студии с Ленина — вот они, и их нет.
expect(picked.map((d) => d.area_m2)).toEqual([25, 38, 58]);
});
it("когда разнообразия нет — берём сколько есть, а не показываем меньше раундов", () => {
// Игра не рендерится, пока раундов меньше трёх. Отдать два «ради чистоты
// выбора» значило бы погасить секцию из-за однородной витрины.
const same = [deal(0, "улица Ленина", 25), deal(0, "улица Ленина", 27), deal(0, "улица Ленина", 29)];
expect(pickVariedDeals(same, 3)).toHaveLength(3);
});
it("строки без улицы не считаются повтором улицы", () => {
// `street_name` пуст примерно у 8% строк — такая строка про улицу молчит.
const noStreet = [deal(1, null, 38), deal(1, "улица Мира", 40), deal(2, null, 58)];
expect(pickVariedDeals(noStreet, 2).map((d) => d.area_m2)).toEqual([38, 58]);
});
it("пустая строка в street_name — не ключ улицы, а её отсутствие", () => {
// `!== null` считал "" настоящим названием, и вторая такая строка
// вылетала как «повтор улицы» — повтора здесь нет, есть два молчания.
// Третья строка — та, которую при этом брали ВМЕСТО второй.
const blank = [deal(1, "", 38), deal(2, "", 58), deal(2, "улица Мира", 60)];
expect(pickVariedDeals(blank, 2).map((d) => d.area_m2)).toEqual([38, 58]);
});
it("выбор детерминирован — страница рендерится на сервере", () => {
// Разный набор на сервере и в браузере — это разъехавшаяся гидрация,
// поэтому никакого случайного выбора здесь быть не может.
const first = pickVariedDeals(showcase, 3);
const second = pickVariedDeals(showcase, 3);
expect(first.map((d) => d.area_m2)).toEqual(second.map((d) => d.area_m2));
});
});

View file

@ -66,7 +66,7 @@ export function CostOfErrorV3({ stats }: { stats: LandingStats }) {
tiles.push({
key: "share",
value: share.text,
title: "столько объявлений на Домклике снижали цену",
title: "столько объявлений на одной из площадок снижали цену",
text: [share.note, share.sample].filter(Boolean).join(" · "),
});
}

View file

@ -51,9 +51,12 @@ export function SourcesV3() {
<h3 className={styles.srcCardTitle}>{group.title}</h3>
<ul className={styles.srcChips} role="list">
{group.items.map((item) => (
<li key={item} className={styles.srcChip}>
<span className={styles.srcChipDot} aria-hidden="true" />
{item}
<li key={item.label} className={styles.srcChip}>
<span
className={`${styles.srcChipDot} ${styles[`srcChipDot--${item.dot}`] ?? ""}`}
aria-hidden="true"
/>
{item.label}
</li>
))}
</ul>

View file

@ -63,7 +63,7 @@ export function TwoPathsV3() {
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
не хочется вести показы и торг передайте продажу нашим
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
по цене из вашего отчёта, стоимость фиксированная и известна
по цене из вашего отчёта, стоимость услуг фиксированная и известна
заранее. Ни к чему не обязывает просто узнайте условия.
</p>
<a

View file

@ -130,6 +130,42 @@ export function toGameRound(deal: ShowcaseDeal, index: number): GameRound {
};
}
/**
* Строки витрины для игры РАЗНЫЕ по типу квартиры и по улице.
*
* `slice(0, n)` брал первые в порядке витрины, а он отсортирован по полноте и
* свежести (`_sort_key` в `landing_showcase_deals.py`), а не по разнообразию:
* на проде это давало три студии с одной улицы, и игра выглядела как одна и та
* же квартира трижды. Жадно берём первую строку, у которой И комнатность, И
* улица ещё не встречались; когда таких не осталось добираем первыми
* попавшимися, потому что раундов должно быть столько, сколько просили.
*
* Порядок строго по входу: страница рендерится на сервере, и любой `random`
* здесь означал бы разный набор на сервере и в браузере.
*/
export function pickVariedDeals(deals: readonly ShowcaseDeal[], n: number): ShowcaseDeal[] {
const picked: ShowcaseDeal[] = [];
const rooms = new Set<number>();
const streets = new Set<string>();
for (const deal of deals) {
if (picked.length >= n) break;
if (rooms.has(deal.rooms)) continue;
// Улицы нет примерно у 8% строк — такая строка не «повторяет улицу», она
// про неё молчит, и держать её из-за этого вне игры не за что. Проверка
// именно на пустоту, а не на `!== null`: пустая строка — то же самое
// молчание, и второй такой строке нельзя вылетать как «повтору улицы».
if (deal.street_name && streets.has(deal.street_name)) continue;
picked.push(deal);
rooms.add(deal.rooms);
if (deal.street_name) streets.add(deal.street_name);
}
for (const deal of deals) {
if (picked.length >= n) break;
if (!picked.includes(deal)) picked.push(deal);
}
return picked;
}
/**
* Разброс расхождений ПОКАЗАННЫХ строк.
*

View file

@ -98,7 +98,7 @@ export const HOWTO = {
heading: "1. Почему цена соседа — плохой ориентир",
body: "В объявлении вы видите цену предложения, а не сделки. Между ними почти всегда есть разрыв: продавец заложил торг, а покупатель этот торг забрал. Насколько именно — не знает никто: цены сделок Росреестр публикует без привязки к объявлению, и сопоставить одно с другим нельзя. Зато отлично видно, как продавец сам двигает цену, пока квартира не продаётся. По объявлениям Екатеринбурга, провисевшим дольше трёх месяцев, цену снижали 62% продавцов — в медиане на 4,9% от той, с которой они начинали. В первый месяц продажи снижают только 7%.",
source:
"ЗАМЕР 18.07.2026 · 6202 ОБЪЯВЛЕНИЯ ДОМКЛИК В ЕКАТЕРИНБУРГЕ, ПРОВИСЕВШИЕ НЕ МЕНЬШЕ ТРЁХ НЕДЕЛЬ · ЭТО ДВИЖЕНИЕ ЦЕНЫ В ОБЪЯВЛЕНИИ, А НЕ СКИДКА НА СДЕЛКЕ",
"ЗАМЕР 18.07.2026 · 6202 ОБЪЯВЛЕНИЯ ОДНОЙ ИЗ ПЛОЩАДОК В ЕКАТЕРИНБУРГЕ, ПРОВИСЕВШИЕ НЕ МЕНЬШЕ ТРЁХ НЕДЕЛЬ · ЭТО ДВИЖЕНИЕ ЦЕНЫ В ОБЪЯВЛЕНИИ, А НЕ СКИДКА НА СДЕЛКЕ",
memo: "Ориентироваться нужно на цены сделок, а не на цены объявлений. Объявление говорит, чего хочет продавец; сделка — что готов заплатить рынок.",
},
s2: {
@ -115,7 +115,7 @@ export const HOWTO = {
after:
"Если под фильтр попадает меньше пяти квартир — расширяйте радиус, а не критерии. Сравнивать «двушку» с «трёшкой» бессмысленно, а соседний квартал обычно живёт по той же цене. МЕРА поступает так же: сначала перестаёт требовать близкий год постройки, потом расширяет радиус до 2 км, потом допускает площадь ±25% и соседнюю комнатность — и лишь в крайнем случае уходит на 3 и 5 км.",
footnote:
"ЭТО ТЕ ЖЕ ПОРОГИ, ЧТО СТОЯТ В ОЦЕНЩИКЕ МЕРЫ: РАДИУС 1 КМ, ПЛОЩАДЬ ±15%, МИНИМУМ ПЯТЬ АНАЛОГОВ",
"ЭТО ТЕ ЖЕ ПОРОГИ, ЧТО СТОЯТ В РАСЧЁТЕ МЕРЫ: РАДИУС 1 КМ, ПЛОЩАДЬ ±15%, МИНИМУМ ПЯТЬ АНАЛОГОВ",
},
s3: {
id: "s3",

View file

@ -26,7 +26,8 @@ import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
import {
LIVE_LISTING_SOURCES,
SOURCES,
sourceLabel,
sourceDot,
sourcePublicLabel,
} from "@/lib/source-registry";
// ---------------------------------------------------------------------------
@ -308,9 +309,16 @@ export const SECONDARY_CITIES: readonly string[] = OBLAST_CITIES.filter(
// Источники данных
// ---------------------------------------------------------------------------
export interface SourceChip {
/** Публичный лейбл — «Источник N» / «Росреестр», без названия площадки (юр-риск). */
readonly label: string;
/** CSS-класс цветной точки — `.src-dot.<dot>` в trade-in.css / `srcDot<Dot>` в landing-v3.module.css. */
readonly dot: string;
}
export interface SourceGroup {
readonly title: string;
readonly items: readonly string[];
readonly items: readonly SourceChip[];
readonly note: string;
}
@ -330,22 +338,28 @@ export interface SourceGroup {
* Осознанное сужение: в `SOURCES` у сделок есть ещё «Этажи» (kind: "deals"),
* но на публичной странице говорим только про Росреестр это продуктовое
* решение владельца («сделки Росреестра + объявления площадок»), а не
* недосмотр. Лейбл берём через `sourceLabel`, чтобы не разъехаться с реестром.
* недосмотр. Лейбл публичный (`publicLabel`, «Источник N»), а не название
* площадки (юр-риск, владелец продукта запретил показывать бренды посетителю
* лендинга); цвет точки (`dot`) при этом сохраняется он опознаёт источник
* между секциями, как и раньше.
*/
export const SOURCE_GROUPS: readonly SourceGroup[] = [
{
title: "Зарегистрированные сделки",
items: [sourceLabel("rosreestr")],
items: [{ label: sourcePublicLabel("rosreestr"), dot: sourceDot("rosreestr") }],
note: "Цены, по которым квартиры действительно перешли к новым собственникам — по договорам купли-продажи. Сначала смотрим сделки по вашему дому, а если их мало — по ближайшему окружению.",
},
{
title: "Объявления о продаже",
items: LIVE_LISTING_SOURCES.map((s) => s.label),
items: LIVE_LISTING_SOURCES.map((s) => ({ label: s.publicLabel, dot: s.dot })),
note: "Что просят прямо сейчас за похожие квартиры: площадь, этаж, тип дома, состояние.",
},
{
title: "Оценочные модели площадок",
items: SOURCES.filter((s) => s.kind === "valuation").map((s) => s.label),
items: SOURCES.filter((s) => s.kind === "valuation").map((s) => ({
label: s.publicLabel,
dot: s.dot,
})),
note: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.",
},
];

View file

@ -17,12 +17,14 @@
* объявления, а не срок продажи: выборка цензурирована, эти квартиры ещё
* продаются, реальная экспозиция проданных всегда меньше. Написать под
* цифрой «за столько продаются» соврать; поэтому подпись здесь одна и
* тестом запинена. И при `median === null` (бэкенд занулил её, когда
* тестом запинена. Величина при этом МЕДИАННАЯ (`percentile_cont(0.5)` на
* бэкенде), а не средняя «в среднем» под ней было такой же неправдой. И при `median === null` (бэкенд занулил её, когда
* возраст известен меньше чем по пяти объявлениям) плитки просто нет
* ноль вместо неизвестного значения был бы худшей из ошибок.
*/
import { CITY_COVERAGE, CITY_COVERAGE_SOURCE } from "./landing-facts";
import type { CityCoverageMeasure } from "./landing-facts";
import type { CoverageProbe } from "./public-api";
export interface CoverageTile {
@ -73,10 +75,14 @@ function tilesFor(probe: CoverageProbe): CoverageTile[] {
const partial = probe.n_with_age < probe.n_listings;
tiles.push({
value: `${probe.median_listing_age_days} дн.`,
// Формулировка запинена тестом — см. шапку модуля.
// Формулировка запинена тестом — см. шапку модуля. «В среднем» отсюда
// убрано 02.09: бэкенд считает `percentile_cont(0.5)`
// (`trade_in.py::_coverage_probe`) — это МЕДИАНА, и на скошенном вправо
// возрасте объявлений она заметно меньше среднего. «Каждое второе» —
// то же самое утверждение словами, которые не надо расшифровывать.
label: partial
? `столько в среднем висит объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}`
: "столько в среднем висит объявление",
? `столько и дольше висит каждое второе объявление; дата известна у ${probe.n_with_age} из ${probe.n_listings}`
: "столько и дольше висит каждое второе объявление",
});
}
@ -99,17 +105,36 @@ export interface CityExpectation {
* что выбрасывать города из списка не за что. Разница между ними
* количественная её и показываем числом, а не отсутствием опции.
*/
export function describeCityExpectation(city: string): CityExpectation | null {
const measure = CITY_COVERAGE.find((m) => m.city === city);
export function describeCityExpectation(
city: string,
// Замер вторым аргументом — чтобы ветку «средней доли не осталось» можно
// было проверить, не подделывая CITY_COVERAGE: сегодня такого города в
// замере нет, а появиться он может при первом же перезамере.
measure: CityCoverageMeasure | undefined = CITY_COVERAGE.find((m) => m.city === city),
): CityExpectation | null {
if (!measure) return null;
const rest = 100 - measure.confidentPct;
// Остаток от уверенных — НЕ однородный: в него входят и пустые пробы, где
// считать нечего. Пока он назывался одним числом, текст обещал расчёт и на
// них тоже, а `describeCoverage` в той же ситуации отвечает «не считаем» —
// две наши же строки спорили друг с другом. Делим остаток надвое.
const thin = 100 - measure.confidentPct - measure.emptyPct;
const head =
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} ` +
"проверках из 100. ";
// Средней доли может не остаться вовсе (перезамер вида 85 уверенных + 15
// пустых) — тогда предложение про неё выпадает целиком. «Ещё в 0 данных
// меньше» было бы не округлением, а сообщением о доле, которой нет.
const empty = thin > 0 ? measure.emptyPct : 100 - measure.confidentPct;
const tail =
`В оставшихся ${empty} рядом не находится ни одной похожей квартиры — ` +
"так и скажем, а считать не станем.";
return {
text:
`В городе ${city} выборки хватает на уверенный расчёт в ${measure.confidentPct} проверках ` +
`из 100; в остальных ${rest} данных меньше — расчёт мы всё равно сделаем, но разброс ` +
`будет шире, и в ответе это будет написано. В ${measure.emptyPct} случаях из 100 рядом ` +
"не находится ни одной похожей квартиры.",
thin > 0
? `${head}Ещё в ${thin} данных меньше — расчёт мы сделаем, но разброс будет шире, и в ` +
`ответе это будет написано. ${tail}`
: head + tail,
source: `${CITY_COVERAGE_SOURCE}. Прогнано адресов: ${measure.sampleN}.`,
};
}

View file

@ -3019,6 +3019,36 @@
background: var(--b2c-accent);
}
/*
* Цвет точки источника тот же опознаватель, что в отчёте (src-dot.* в
* trade-in.css), но лендинг (`landing-v3.module.css`) отдельный CSS-модуль
* без доступа к переменным из trade-in.css (разные скоупы). Значения ниже
* продублированы 1:1 из trade-in.css (--src-* рядом с viz-палитрой), чтобы
* цвет точки совпадал между лендингом и отчётом. Правишь там правь здесь:
* связь держится этим комментарием, а не кодом.
*
* Сами цвета фирменные цвета площадок (решение 31.08.2026): название
* заменено на «Источник N», опознаватель цвет, и он намеренно узнаваем.
*/
.srcChipDot--cian {
background: #0468ff; /* trade-in.css --src-cian */
}
.srcChipDot--avito {
background: #00aaff; /* trade-in.css --src-avito */
}
.srcChipDot--dom {
background: #21a038; /* trade-in.css --src-dom */
}
.srcChipDot--yandex {
background: #ffcc00; /* trade-in.css --src-yandex */
}
.srcChipDot--etagi {
background: #f15044; /* trade-in.css --src-etagi */
}
.srcChipDot--rosreestr {
background: var(--b2c-ink); /* trade-in.css --fg */
}
.srcNote {
margin: 0;
font-size: 14px;

View file

@ -12,7 +12,7 @@ import { StepsV3 } from "./_components/v3/StepsV3";
import { StickyCtaV3 } from "./_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "./_components/v3/TwoPathsV3";
import { WhatYouGetV3 } from "./_components/v3/WhatYouGetV3";
import { toGameRound } from "./_components/v3/deal-view";
import { pickVariedDeals, toGameRound } from "./_components/v3/deal-view";
import { fetchLandingStats, fetchShowcase } from "./public-api";
import styles from "./landing.module.css";
@ -81,7 +81,9 @@ const MIN_ROUNDS = 3;
export default async function MeraPublicPage() {
const [stats, showcase] = await Promise.all([fetchLandingStats(), fetchShowcase()]);
const deals = showcase?.deals ?? [];
const rounds = deals.slice(0, MIN_ROUNDS).map(toGameRound);
// Не первые три, а три РАЗНЫЕ — иначе игра предлагает угадать одну и ту же
// студию с одной улицы трижды (см. `pickVariedDeals`).
const rounds = pickVariedDeals(deals, MIN_ROUNDS).map(toGameRound);
return (
<>

View file

@ -40,7 +40,8 @@ import { LIVE_LISTING_SOURCES } from "@/lib/source-registry";
// #2211 находка 7: список источников объявлений выводится из source-registry
// (источник n1 полностью выключен #2204 → его тут больше нет), а не хардкодится числом.
const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.label).join(", ");
// Публичный лейбл (не название площадки) — клиентская страница отчёта, юр-риск.
const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.publicLabel).join(", ");
function useEstimateId() {
if (typeof window === "undefined") return null;
@ -348,7 +349,7 @@ export default function TradeInPage() {
<footer className="page-foot">
<div>
Мера · MVP ·{" "}
<span className="mono">data: Avito + Cian + Yandex + Росреестр</span>
<span className="mono">data: {LISTING_SOURCE_NAMES} + Росреестр</span>
</div>
<div style={{ display: "flex", gap: 18 }}>
<a href="#">Документация</a>

View file

@ -1,11 +1,14 @@
"use client";
import type { CianValuationSummary } from "@/types/trade-in";
import { sourcePublicLabel } from "@/lib/source-registry";
interface Props {
data: CianValuationSummary | null | undefined;
}
const SRC_LABEL = sourcePublicLabel("cian_valuation");
function formatRub(n: number | null | undefined): string {
if (n == null) return "—";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)} млн ₽`;
@ -64,9 +67,9 @@ export function CianValuationCard({ data }: Props) {
if (!data || data.chart.length === 0) return null;
return (
<section className="cian-valuation" aria-label="Оценка Cian">
<section className="cian-valuation" aria-label={`Оценка ${SRC_LABEL}`}>
<header className="cian-valuation__head">
<span className="cian-valuation__title">Оценка Cian</span>
<span className="cian-valuation__title">Оценка {SRC_LABEL}</span>
<ChangeBadge pct={data.chart_change_pct} direction={data.chart_change_direction} />
</header>

View file

@ -5,7 +5,7 @@
*/
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
import { openRosreestrWithAddress } from "@/lib/rosreestr";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
interface Props {
estimate: AggregatedEstimate;
@ -100,7 +100,7 @@ export function DealsCard({ estimate }: Props) {
{Object.entries(sourceCounts).map(([src, c]) => (
<span key={src} className="source-chip">
<span className={`src-dot ${sourceDot(src)}`} />{" "}
{sourceLabel(src)} ·{" "}
{sourcePublicLabel(src)} ·{" "}
<span className="mono" style={{ color: "var(--muted)" }}>
{c} сделок
</span>
@ -139,7 +139,7 @@ export function DealsCard({ estimate }: Props) {
function DealRow({ deal }: { deal: AnalogLot }) {
const src = deal.source ?? "";
const dot = sourceDot(src);
const label = sourceLabel(src);
const label = sourcePublicLabel(src);
// Tier badge для rosreestr deals (PR M / #564 Phase 3).
// T0_per_house — точный кадастровый match (high confidence).

View file

@ -152,7 +152,7 @@ export function EstimateForm({
{/* Address with autocomplete */}
<div className="field autocomplete">
<label className="field-label" htmlFor="addr">
Адрес <span className="hint">Yandex / Nominatim</span>
Адрес <span className="hint">улица, дом</span>
<span className="req">обязательно</span>
</label>
<div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>

View file

@ -13,7 +13,7 @@ import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
import { dealsAsOfLabel } from "@/lib/rosreestr";
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
import { HeroTransparency } from "./HeroTransparency";
@ -309,9 +309,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
)}
<div className="photo-meta">
{heroPhoto
? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}`
? `фото аналога${estimate.sources_used[0] ? ` · ${sourcePublicLabel(estimate.sources_used[0])}` : ""}`
: estimate.sources_used.length > 0
? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}`
? `${sourcePublicLabel(estimate.sources_used[0])} · ${analogsCaption}`
: "Нет фото"}
</div>
</div>
@ -529,19 +529,19 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
)}
</div>
{/* Компактный ряд бенчмарков: Avito IMV · Cian · ДКП
{/* Компактный ряд бенчмарков: оценочные модели площадок (avito_imv, cian_valuation) · ДКП
Вторичный контекст-якорь под hero, не конкурирует по размеру. */}
{!insufficientData && (imvPrice !== null || estimate.cian_valuation?.sale_price_rub != null || showDkp) && (
<div className="hero-benchmarks">
{imvPrice !== null && (
<div className="bench-chip">
<span className="bench-chip__src">Avito <abbr title="Information Market Value — оценка рыночной стоимости от Avito">IMV</abbr></span>
<span className="bench-chip__src" title="Оценочная модель площадки — независимая оценка рыночной стоимости">{sourcePublicLabel("avito_imv")}</span>
<span className="bench-chip__val mono">{formatMln(imvPrice)} </span>
</div>
)}
{estimate.cian_valuation?.sale_price_rub != null && (
<div className="bench-chip">
<span className="bench-chip__src">Cian</span>
<span className="bench-chip__src">{sourcePublicLabel("cian_valuation")}</span>
<span className="bench-chip__val mono">
{formatMln(estimate.cian_valuation.sale_price_rub)}
</span>
@ -595,10 +595,10 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{imvPrice !== null && (
<div
className="price-figure-imv mono"
title="Индекс Market Value Avito по этому дому — реальный рыночный якорь"
title={`Оценочная модель площадки (${sourcePublicLabel("avito_imv")}) по этому дому — реальный рыночный якорь`}
style={{ marginTop: 6, fontSize: 12, color: "var(--muted)" }}
>
Avito <abbr title="Information Market Value — оценка рыночной стоимости от Avito">IMV</abbr>: {formatMln(imvPrice)}
{sourcePublicLabel("avito_imv")}, оценочная модель: {formatMln(imvPrice)}
</div>
)}
</div>
@ -689,7 +689,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{formatMln(dM)}
</div>
<div className="exp left">{estimate.actual_deals.length} сделок</div>
<div className="exp right">Росреестр · ДомКлик</div>
<div className="exp right">Росреестр · {sourcePublicLabel("domklik")}</div>
</div>
</div>
);
@ -771,7 +771,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{estimate.sources_used.length > 0 ? (
estimate.sources_used.map((src) => (
<span key={src} className="source-chip">
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)}
<span className={`src-dot ${sourceDot(src)}`} /> {sourcePublicLabel(src)}
</span>
))
) : (

View file

@ -1,12 +1,15 @@
"use client";
import type { IMVBenchmarkResponse } from "@/types/trade-in";
import { sourcePublicLabel } from "@/lib/source-registry";
interface Props {
benchmark: IMVBenchmarkResponse | undefined;
isLoading: boolean;
}
const SRC_LABEL = sourcePublicLabel("avito_imv");
function formatRub(n: number | null | undefined): string {
if (n == null) return "—";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)} млн ₽`;
@ -17,7 +20,7 @@ export function IMVBenchmark({ benchmark, isLoading }: Props) {
if (isLoading) {
return (
<div className="imv-benchmark imv-benchmark--loading">
<span className="imv-benchmark__label">Avito IMV</span>
<span className="imv-benchmark__label">Оценка {SRC_LABEL}</span>
<span className="imv-benchmark__value">загрузка</span>
</div>
);
@ -40,27 +43,19 @@ export function IMVBenchmark({ benchmark, isLoading }: Props) {
diff_pct == null
? null
: diff_pct > 0
? `выше Avito на ${diff_pct.toFixed(1)}%`
? `выше ${SRC_LABEL} на ${diff_pct.toFixed(1)}%`
: diff_pct < 0
? `ниже Avito на ${Math.abs(diff_pct).toFixed(1)}%`
: "совпадает с Avito";
? `ниже ${SRC_LABEL} на ${Math.abs(diff_pct).toFixed(1)}%`
: `совпадает с ${SRC_LABEL}`;
return (
<section className="imv-benchmark" aria-label="Avito IMV benchmark">
<section className="imv-benchmark" aria-label={`Оценка ${SRC_LABEL}`}>
<header className="imv-benchmark__head">
<span className="imv-benchmark__title">Avito <abbr title="Information Market Value — оценка рыночной стоимости от Avito">IMV</abbr></span>
<a
href="https://www.avito.ru/evaluation/realty"
target="_blank"
rel="noopener noreferrer"
className="imv-benchmark__source"
>
источник: avito.ru/evaluation/realty
</a>
<span className="imv-benchmark__title" title="Оценочная модель площадки — независимая оценка рыночной стоимости">{SRC_LABEL} · оценочная модель</span>
</header>
<div className="imv-benchmark__row">
<div className="imv-benchmark__metric">
<div className="imv-benchmark__metric-label">Avito рекомендует</div>
<div className="imv-benchmark__metric-label">{SRC_LABEL} рекомендует</div>
<div className="imv-benchmark__metric-value">
{formatRub(recommended_price)}
</div>

View file

@ -9,7 +9,7 @@ import type { AggregatedEstimate, AnalogLot, CianPriceChangeStats } from "@/type
import { safeUrl } from "@/lib/safeUrl";
import { useEstimateCianPriceChanges } from "@/lib/trade-in-api";
import { openRosreestrWithAddress } from "@/lib/rosreestr";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
interface Props {
estimate: AggregatedEstimate;
@ -200,7 +200,7 @@ export function ListingsCard({ estimate, estimateId }: Props) {
<div className="sources">
{Object.entries(sourceCounts).map(([src, count]) => (
<span key={src} className="source-chip">
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)} ·{" "}
<span className={`src-dot ${sourceDot(src)}`} /> {sourcePublicLabel(src)} ·{" "}
<span className="mono" style={{ color: "var(--muted)" }}>
{count}
</span>
@ -306,7 +306,7 @@ function AnalogRow({
}) {
const src = lot.source ?? "";
const dot = sourceDot(src);
const label = sourceLabel(src);
const label = sourcePublicLabel(src);
const distance =
lot.distance_m === null
? "—"

View file

@ -64,7 +64,7 @@ export function MapPicker({ onPick, onClose }: Props) {
const [loading, setLoading] = useState(false);
const [mapError, setMapError] = useState(false);
// True если последний reverse snap'нул marker на точку здания. Показываем
// тонкий hint «Точка дома по Яндексу», чтобы user понимал почему marker
// тонкий hint «Точка дома уточнена по карте», чтобы user понимал почему marker
// переехал на пару метров от его клика.
const [snapped, setSnapped] = useState(false);
// Координаты последнего выбора (snap-точка здания или raw клик).
@ -276,7 +276,7 @@ export function MapPicker({ onPick, onClose }: Props) {
}}
aria-live="polite"
>
Точка дома по Яндексу
Точка дома уточнена по карте
</div>
) : null}
</div>

View file

@ -11,9 +11,13 @@ import {
CartesianGrid,
} from "recharts";
import type { PriceHistoryYearPoint } from "@/types/trade-in";
import { sourcePublicLabel } from "@/lib/source-registry";
type Props = { points: PriceHistoryYearPoint[] };
const AVITO_LABEL = sourcePublicLabel("avito_imv");
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
const fmtK = (n: number) => `${Math.round(n / 1000)}k`;
interface PivotRow {
@ -49,7 +53,7 @@ export function PriceHistoryChart({ points }: Props) {
История цен в этом доме
</h4>
<small style={{ color: "var(--muted, #6b7280)" }}>
Медиана /м² по годам · только этот дом · Avito + Яндекс ({totalLots} лотов)
Медиана /м² по годам · только этот дом · {AVITO_LABEL} + {YANDEX_LABEL} ({totalLots} лотов)
</small>
</header>
{/* #835: декоративный чарт — aria-hidden (данные в тексте/легенде карточки). */}
@ -64,14 +68,9 @@ export function PriceHistoryChart({ points }: Props) {
<YAxis tickFormatter={fmtK} tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number, name: string) =>
[
`${value.toLocaleString("ru-RU")} ₽/м²`,
name === "avito_imv"
? "Avito"
: name === "yandex_valuation"
? "Яндекс"
: name,
]
// Recharts подставляет сюда `name` уже из <Line name={...}> ниже
// (человекочитаемый публичный лейбл), не dataKey — маппинг не нужен.
[`${value.toLocaleString("ru-RU")} ₽/м²`, name]
}
labelFormatter={(year) => `${year} год`}
/>
@ -79,19 +78,19 @@ export function PriceHistoryChart({ points }: Props) {
<Line
type="monotone"
dataKey="avito_imv"
stroke="var(--viz-1)"
stroke="var(--src-avito)"
strokeWidth={2}
dot={{ r: 3 }}
name="Avito"
name={AVITO_LABEL}
connectNulls
/>
<Line
type="monotone"
dataKey="yandex_valuation"
stroke="var(--viz-4)"
stroke="var(--src-yandex-line)"
strokeWidth={2}
dot={{ r: 3 }}
name="Яндекс"
name={YANDEX_LABEL}
connectNulls
/>
</LineChart>

View file

@ -46,7 +46,7 @@ export function SourcesProgress({ estimate, isPending }: Props) {
// estimate.sources_used.
const listingRows: SourceRow[] = LIVE_LISTING_SOURCES.map((s) => ({
key: s.id,
label: s.label,
label: s.publicLabel,
dotClass: s.dot,
status: used.has(s.id) ? "done" : "idle",
count: countBySource[s.id],

View file

@ -14,6 +14,7 @@
import { useSalesVsListings } from "@/lib/trade-in-api";
import { openRosreestrWithAddress } from "@/lib/rosreestr";
import { safeUrl } from "@/lib/safeUrl";
import { sourcePublicLabel } from "@/lib/source-registry";
import type { AggregatedEstimate, SalesListingPair } from "@/types/trade-in";
interface Props {
@ -247,7 +248,7 @@ function ListingBadge({
daysToDeal,
discountPct,
}: ListingBadgeProps) {
const sourceLabel = source ? source.toUpperCase() : "—";
const sourceLabel = source ? sourcePublicLabel(source) : "—";
// discount < 0 → продано дешевле выставленного (типичный торг, success).
// discount > 0 → продано дороже выставленного (редко, обычно ошибка data, danger).
const discountClass =
@ -284,7 +285,7 @@ function ListingBadge({
href={url}
target="_blank"
rel="noopener noreferrer"
title={`Открыть объявление на ${sourceLabel.toLowerCase()}`}
title={`Открыть объявление (${sourceLabel})`}
>
{inner}
</a>

View file

@ -12,6 +12,8 @@
* компонент и расчёт берут всё отсюда.
*/
import { sourcePublicLabel } from "@/lib/source-registry";
/** Ставка-диапазон в долях от базы (0.05 = 5%). */
export interface PctRange {
lo: number;
@ -148,7 +150,7 @@ export function computeBreakdown(
{
key: "ads",
name: "Расходы на рекламу",
sub: `платные показы Циан / Авито, ${period}`,
sub: `платные показы ${sourcePublicLabel("cian")} / ${sourcePublicLabel("avito")}, ${period}`,
selfLo: rates.advertising.lo,
selfHi: rates.advertising.hi,
tradeLabel: "бесплатно",

View file

@ -1,5 +1,7 @@
// Утилиты страницы sale-share: цвет/размер тепловых маркеров + форматтеры.
import { sourcePublicLabel } from "@/lib/source-registry";
/**
* Цвет «тепла» доли в продаже: низкая (норма) зелёный, высокая (сигнал
* расселения / инвест-выхода / проблемного дома) красный. null серый.
@ -53,17 +55,14 @@ export function houseTypeLabel(t: string | null | undefined): string {
return HOUSE_TYPE_LABELS[t] ?? t;
}
// Источник n1 полностью выключен (#2204) → в справочнике его нет. Исторические
// строки из БД с source='n1' безопасно проходят через fallback `?? s` (сырой id).
const SOURCE_LABELS: Record<string, string> = {
cian: "Циан",
avito: "Avito",
yandex: "Я.Недв",
domklik: "ДомКлик",
rosreestr: "Росреестр",
};
/**
* Публичный лейбл источника («Источник N» / «Росреестр») делегирует в
* единый source-registry, не хардкодит локальную мапу площадок (юр-риск:
* клиентский отчёт не должен показывать название площадки, #3264-scope
* product decision). Неизвестный id (историческое `source='n1'`, #2204)
* «Другой источник», а не сырой id.
*/
export function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return SOURCE_LABELS[s] ?? s;
return sourcePublicLabel(s);
}

View file

@ -34,6 +34,23 @@
--viz-3: oklch(64% 0.15 150);
--viz-4: oklch(70% 0.15 80);
--viz-5: oklch(66% 0.17 30);
/* Цвет источника фирменный цвет площадки (решение 31.08.2026).
Названия площадок в клиентском UI заменены на «Источник N»
(`lib/source-registry.ts`), поэтому опознаватель остался один цвет,
и он намеренно повторяет бренд. Провенанс значений разный, и это важно
при правке: Avito #00AAFF и Яндекс #FFCC00 документированные
фирменные цвета; Этажи #F15044 снят с их сайта; Циан #0468FF и
ДомКлик #21A038 (зелёный Сбера) взяты по наблюдению брендбуки
закрыты, значения приблизительные. Avito и Циан соседние по тону
(голубой против синего) разведены светлотой, а не тоном. */
--src-avito: #00AAFF;
--src-cian: #0468FF;
--src-yandex: #FFCC00;
--src-dom: #21A038;
--src-etagi: #F15044;
/* Жёлтый #FFCC00 годится точке (5px пятно), но не 2px линии графика на
белом для штрихов затемнённый вариант того же тона. */
--src-yandex-line: #C9971F;
/* type */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
@ -1181,12 +1198,12 @@
width: 8px; height: 8px;
border-radius: 2px;
}
.source-chip .src-dot.cian { background: var(--viz-3); }
.source-chip .src-dot.avito { background: var(--viz-5); }
.source-chip .src-dot.dom { background: var(--viz-1); }
.source-chip .src-dot.yandex { background: var(--viz-4); }
.source-chip .src-dot.cian { background: var(--src-cian); }
.source-chip .src-dot.avito { background: var(--src-avito); }
.source-chip .src-dot.dom { background: var(--src-dom); }
.source-chip .src-dot.yandex { background: var(--src-yandex); }
.source-chip .src-dot.rosreestr { background: var(--fg); }
.source-chip .src-dot.etagi { background: var(--viz-2); }
.source-chip .src-dot.etagi { background: var(--src-etagi); }
.source-chip .src-dot.restate { background: var(--viz-2); }
.source-chip .src-dot.domrf { background: var(--accent-2); }

View file

@ -9,6 +9,10 @@ import { useMemo, useState } from "react";
import { tokens } from "./tokens";
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
import type { Analytics, SellTimeTier } from "./types";
import { sourcePublicLabel } from "@/lib/source-registry";
const AVITO_LABEL = sourcePublicLabel("avito_imv");
const YANDEX_LABEL = sourcePublicLabel("yandex_valuation");
interface AnalyticsViewProps {
// Required on the app path (v2/page.tsx -> SectionOverlay always supplies
@ -166,18 +170,18 @@ export default function AnalyticsView({
[avitoPts, yandexDots],
);
// L2 — the subtitle is mapper-built and hardcodes "Avito + Яндекс"; drop any
// series that drew no points so the subtitle matches the rendered legend (the
// swatches below are already gated on *Pts.length). If the note format changes
// the replace is a graceful no-op.
// L2 — the subtitle is mapper-built and hardcodes "AVITO_LABEL + YANDEX_LABEL"
// (public labels, source-registry); drop any series that drew no points so the
// subtitle matches the rendered legend (the swatches below are already gated
// on *Pts.length). If the note format changes the replace is a graceful no-op.
const activeSeriesNames = [
avitoPts.length > 0 ? "Avito" : null,
yandexPts.length > 0 ? "Яндекс" : null,
avitoPts.length > 0 ? AVITO_LABEL : null,
yandexPts.length > 0 ? YANDEX_LABEL : null,
].filter((s): s is string => s != null);
const historyNote =
activeSeriesNames.length > 0
? data.priceHistory.note.replace(
/Avito \+ Яндекс/,
new RegExp(`${AVITO_LABEL} \\+ ${YANDEX_LABEL}`),
activeSeriesNames.join(" + "),
)
: data.priceHistory.note;
@ -372,7 +376,7 @@ export default function AnalyticsView({
<span
style={{ width: 14, height: 2, background: tokens.accent }}
/>
Avito
{AVITO_LABEL}
</span>
) : null}
{yandexPts.length > 0 ? (
@ -387,7 +391,7 @@ export default function AnalyticsView({
<span
style={{ width: 14, height: 2, background: tokens.gold }}
/>
Яндекс
{YANDEX_LABEL}
</span>
) : null}
</div>
@ -397,7 +401,7 @@ export default function AnalyticsView({
style={{ width: "100%", height: 260, marginTop: 12 }}
preserveAspectRatio="none"
role="img"
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
aria-label={`График истории цен в этом доме: медиана ₽/м² по годам, серии ${AVITO_LABEL} и ${YANDEX_LABEL}`}
>
<title>История цен в этом доме медиана /м² по годам</title>
<g stroke={tokens.lineSoft2} strokeWidth={1}>
@ -447,7 +451,7 @@ export default function AnalyticsView({
strokeWidth={2}
style={{ cursor: "pointer" }}
>
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
<title>Архивные объявления {AVITO_LABEL} · нажмите, чтобы открыть</title>
{avitoPts.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
))}

View file

@ -21,6 +21,11 @@ import { useEffect, useRef } from "react";
import { tokens } from "./tokens";
import { pluralRu } from "./mappers";
import type { LocationData } from "./mappers";
import { LIVE_LISTING_SOURCES } from "@/lib/source-registry";
// Публичные лейблы листинговых источников (не реальные названия площадок,
// #2211/#3264-scope — юр-риск утечки бренда в клиентский отчёт).
const LISTING_SOURCE_NAMES = LIVE_LISTING_SOURCES.map((s) => s.publicLabel).join(", ");
// Default presentation data (unwired usage / not fetched yet): the honest
// loading state, never a fabricated coefficient.
@ -384,7 +389,7 @@ export function LocationDrawer({
color: tokens.body2,
}}
>
Агрегируем объявления (Циан, Я.Недвижимость, Авито, Домклик) и сделки
Агрегируем объявления ({LISTING_SOURCE_NAMES}) и сделки
Росреестра по сопоставимым квартирам. Медиана{" "}
<b style={{ color: tokens.ink2 }}>/м²</b> 3 оценки:{" "}
<b style={{ color: tokens.ink2 }}>

View file

@ -1693,7 +1693,7 @@ export default function ParamsPanel({
}}
>
<label htmlFor="pp-address" style={hintLabel}>
АДРЕС (YANDEX / NOMINATIM)
АДРЕС (УЛИЦА, ДОМ)
</label>
{/* Город (#2576) компактный дропдаун рядом с лейблом адреса, не
отдельная строка: не отнимает вертикальное место у и так плотной

View file

@ -75,7 +75,7 @@ import type {
SummaryRow,
} from "./types";
import { tokens } from "./tokens";
import { sourceLabel as registrySourceLabel } from "@/lib/source-registry";
import { sourcePublicLabel } from "@/lib/source-registry";
import { dealsAsOfLabel } from "@/lib/rosreestr";
// Total number of source slots — meta "N / M". Single source of truth is
@ -538,14 +538,20 @@ function numLabel(v: number | null): string {
// ── Source slots (FE groupBy — TODO BE-1) ───────────────────────────────────
// Each design slot maps to one or more API source keys. count = number of
// analogs + actual_deals whose .source matches; active = key present in
// sources_used (or any lots found). 'АВИТО ОЦЕНКА' also covers the avito_imv
// sources_used (or any lots found). The avito slot also covers the avito_imv
// anchor key (no lots of its own).
// name — публичный лейбл (#2211/#3264-scope: реальные названия площадок не
// должны утекать в клиентский отчёт). UPPER CASE — под дизайн плиток,
// letterSpacing в ResultPanel рассчитан на капс.
// Порядок плиток = порядок номеров «Источник 1…4» по возрастанию, Росреестр
// в центре (как и раньше): плитки рендерятся в порядке массива без сортировки
// (ResultPanel), и «2 · 3 · Росреестр · 1 · 4» читалось бы как ошибка.
const SOURCE_SLOTS: ReadonlyArray<{ name: string; keys: string[] }> = [
{ name: "ЦИАН", keys: ["cian"] },
{ name: "Я.НЕДВИЖИМОСТЬ", keys: ["yandex"] },
{ name: "РОСРЕЕСТР", keys: ["rosreestr"] },
{ name: "АВИТО", keys: ["avito", "avito_imv"] },
{ name: "ДОМКЛИК", keys: ["domklik", "domclick"] },
{ name: sourcePublicLabel("avito").toUpperCase(), keys: ["avito", "avito_imv"] },
{ name: sourcePublicLabel("cian").toUpperCase(), keys: ["cian"] },
{ name: sourcePublicLabel("rosreestr").toUpperCase(), keys: ["rosreestr"] },
{ name: sourcePublicLabel("yandex").toUpperCase(), keys: ["yandex"] },
{ name: sourcePublicLabel("domklik").toUpperCase(), keys: ["domklik", "domclick"] },
];
// #2081 round2: N1.RU + RESTATE dropped — мёртвые (всегда «нет данных», не
// учитываем). N1 с #2204 выключен полностью (backend + расписания). Домклик оставлен
@ -1483,13 +1489,14 @@ export function fmtDist(m: number | null | undefined): string {
})}${NBSP}км`;
}
// Source key -> brand/RU display label (badges + link source). Delegates to the
// single source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) echoes back verbatim
// via the registry's own fallback rather than inventing a label.
// Source key -> PUBLIC display label ("Источник N"). Delegates to the single
// source-registry roster (#2211) instead of a local hardcoded map — an
// unknown/inactive id (e.g. historical 'n1' rows, #2204) falls back to
// "Другой источник" rather than leaking the raw id or a площадка name into
// the client-facing report (юр-риск, product owner decision).
export function sourceLabel(s: string | null | undefined): string {
if (!s) return "—";
return registrySourceLabel(s.toLowerCase());
return sourcePublicLabel(s.toLowerCase());
}
/** Rosreestr deal tier -> RU caption next to the badge. */
@ -1858,7 +1865,7 @@ function buildPriceHistory(a: HouseAnalyticsResponse | null): PriceHistory {
.join(" ");
const totalLots = ph.reduce((s, p) => s + (p.n_lots ?? 0), 0);
const note = `Медиана ₽/м² по годам · только этот дом · Avito + Яндекс (${totalLots} ${pluralRu(
const note = `Медиана ₽/м² по годам · только этот дом · ${sourcePublicLabel("avito_imv")} + ${sourcePublicLabel("yandex_valuation")} (${totalLots} ${pluralRu(
totalLots,
["лот", "лота", "лотов"],
)})`;

View file

@ -26,8 +26,15 @@ export type SourceKind = "listing" | "deals" | "valuation";
export interface SourceMeta {
/** Идентификатор как в `estimate.sources_used` / `analog.source`. */
readonly id: string;
/** Краткий человекочитаемый лейбл для чипов. */
/** Краткий человекочитаемый лейбл для чипов. Используется ТОЛЬКО в админке/скраперах. */
readonly label: string;
/**
* Нейтральный публичный лейбл то, что видит посетитель лендинга/клиент отчёта.
* Юр-риск: реальные названия площадок не должны утекать в публичный UI (#3264-scope task).
* Одна площадка (avito/avito_imv, cian/cian_valuation, yandex/yandex_valuation)
* один и тот же номер, т.к. точка того же цвета опознаёт источник между блоками.
*/
readonly publicLabel: string;
/** CSS-класс цветной точки — `.src-dot.<dot>` в trade-in.css. */
readonly dot: string;
/** Тип вклада: объявления / сделки / внешняя оценка. */
@ -41,15 +48,45 @@ export interface SourceMeta {
* rosreestr валиден класс `rosreestr` (не мифический `ros`, у которого нет стиля).
*/
export const SOURCES = [
{ id: "avito", label: "Avito", dot: "avito", kind: "listing" },
{ id: "cian", label: "Циан", dot: "cian", kind: "listing" },
{ id: "yandex", label: "Я.Недвижимость", dot: "yandex", kind: "listing" },
{ id: "domklik", label: "ДомКлик", dot: "dom", kind: "listing" },
{ id: "rosreestr", label: "Росреестр", dot: "rosreestr", kind: "deals" },
{ id: "etagi", label: "Этажи", dot: "etagi", kind: "deals" },
{ id: "avito_imv", label: "Avito Оценка", dot: "avito", kind: "valuation" },
{ id: "yandex_valuation", label: "Я.Оценка", dot: "yandex", kind: "valuation" },
{ id: "cian_valuation", label: "Циан Оценка", dot: "cian", kind: "valuation" },
{ id: "avito", label: "Avito", publicLabel: "Источник 1", dot: "avito", kind: "listing" },
{ id: "cian", label: "Циан", publicLabel: "Источник 2", dot: "cian", kind: "listing" },
{
id: "yandex",
label: "Я.Недвижимость",
publicLabel: "Источник 3",
dot: "yandex",
kind: "listing",
},
{ id: "domklik", label: "ДомКлик", publicLabel: "Источник 4", dot: "dom", kind: "listing" },
{
id: "rosreestr",
label: "Росреестр",
publicLabel: "Росреестр",
dot: "rosreestr",
kind: "deals",
},
{ id: "etagi", label: "Этажи", publicLabel: "Источник 5", dot: "etagi", kind: "deals" },
{
id: "avito_imv",
label: "Avito Оценка",
publicLabel: "Источник 1",
dot: "avito",
kind: "valuation",
},
{
id: "yandex_valuation",
label: "Я.Оценка",
publicLabel: "Источник 3",
dot: "yandex",
kind: "valuation",
},
{
id: "cian_valuation",
label: "Циан Оценка",
publicLabel: "Источник 2",
dot: "cian",
kind: "valuation",
},
] as const satisfies readonly SourceMeta[];
const BY_ID: ReadonlyMap<string, SourceMeta> = new Map(SOURCES.map((s) => [s.id, s]));
@ -72,11 +109,19 @@ export function isKnownSource(id: string): boolean {
return BY_ID.has(id);
}
/** Лейбл источника; неизвестный id возвращается как есть (честно, без выдумки). */
/** Лейбл источника (реальное название площадки); только для админки/скраперов. */
export function sourceLabel(id: string): string {
return BY_ID.get(id)?.label ?? id;
}
/**
* Публичный лейбл источника то, что видит посетитель лендинга/клиент отчёта.
* Неизвестный id «Другой источник» (НЕ сырой id им может утечь название площадки).
*/
export function sourcePublicLabel(id: string): string {
return BY_ID.get(id)?.publicLabel ?? "Другой источник";
}
/** CSS-класс точки; неизвестный id → нейтральный `dom`. */
export function sourceDot(id: string): string {
return BY_ID.get(id)?.dot ?? "dom";

View file

@ -1183,6 +1183,24 @@ async def run_avito_city_sweep(
)
_done_anchors: set[str] = set(_skip_anchors)
def _ckpt(**extra: Any) -> dict[str, Any]:
"""Счётчики прогона ВМЕСТЕ с чекпоинтом — payload любого выхода (#3319).
До этого точку писала ровно одна строка end-of-anchor heartbeat в конце
итерации цикла. Финализаторы её НЕ стирали: все четыре писателя в runs.py
мержат jsonb (`counters || :counters`), уже записанный ключ переживал и
mark_done, и mark_banned, и mark_failed. Закрывается другая дыра выходы,
случившиеся РАНЬШЕ первой такой записи: cancel/SIGTERM-дрейн на границе
первого якоря и ранний done #1950 («SERP собран, detail заблокирован») на
якоре 1. Именно им и кончается типичный прод-прогон, у которого
`anchors_done: 1` из 5.
Замер «0 из 67 прогонов за 60 дней несут done_buckets» сам по себе этого НЕ
доказывает: строка записи появилась только 26.08.2026 (#3074), а такт avito
7 суток, так что выборка почти целиком из эры, где механизма не было.
"""
return {**counters.to_dict(), "done_buckets": sorted(_done_anchors), **extra}
_loc = get_city_location(city_slug)
# #262 wave 2: avito_slug у CityLocation Optional — не у каждого известного города
# он подтверждён (403/429 на исчерпанном пуле при проверке, либо omonym-коллизия).
@ -1287,7 +1305,7 @@ async def run_avito_city_sweep(
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.update_heartbeat(db, run_id, _ckpt())
return counters
elif shutdown_requested():
# Кооперативный SIGTERM-drain (#1182 Phase 3a): останавливаемся
@ -1301,8 +1319,13 @@ async def run_avito_city_sweep(
len(_anchors),
name,
)
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, counters.to_dict())
# #3319: 'done' с counters.interrupted=1 — НЕ полный обход
# (та же метка, что у rosreestr_dkp-дрейна). Без неё оборванный
# деплоем прогон неотличим от честно обошедшего все якоря:
# статус тот же, счётчики частичные, а резюм его не берёт.
# Читатели статуса не трогаем — 'done' остаётся 'done'.
runs.update_heartbeat(db, run_id, _ckpt(interrupted=1))
runs.mark_done(db, run_id, _ckpt(interrupted=1))
return counters
logger.info(
@ -1762,6 +1785,12 @@ async def run_avito_city_sweep(
_avito_anchor_timeout,
)
counters.errors_count += 1
# #3319: тот же инвариант, что у generic-except ниже. Якорь,
# умерший по таймауту, ПРОЙДЕН НЕ БЫЛ: SERP мог успеть, а
# detail/houses — нет, и какая именно часть осталась несобранной,
# здесь неизвестно. Считать его пройденным значит, что резюм
# пропустит его навсегда — молча, при штатно завершившемся прогоне.
_anchor_ok = False
except (AvitoBlockedError, AvitoRateLimitedError) as e:
logger.error(
"city-sweep run_id=%d ABORT at anchor #%d/%d (%s) — blocked: %s",
@ -1773,7 +1802,7 @@ async def run_avito_city_sweep(
)
counters.errors_count += 1
counters.anchors_done = idx
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.update_heartbeat(db, run_id, _ckpt())
# #1950: если SERP уже собрал лоты и заблокировало только detail/houses,
# ставим 'done' (не 'banned') — partial intake сохранён.
# За флагом avito_serp_ok_not_banned (default True).
@ -1794,14 +1823,14 @@ async def run_avito_city_sweep(
runs.mark_done(
db,
run_id,
{**counters.to_dict(), "enrichment_abort_note": _note}, # type: ignore[arg-type]
_ckpt(enrichment_abort_note=_note), # type: ignore[arg-type]
)
else:
runs.mark_banned(
db,
run_id,
str(e),
counters.to_dict(),
_ckpt(),
ban_kind=ban_kind_of_exception(e),
)
return counters
@ -1819,9 +1848,7 @@ async def run_avito_city_sweep(
# завершится штатно. Тот же инвариант, что у combo в yandex-свипе.
if _anchor_ok:
_done_anchors.add(name)
runs.update_heartbeat(
db, run_id, {**counters.to_dict(), "done_buckets": sorted(_done_anchors)}
)
runs.update_heartbeat(db, run_id, _ckpt())
# ── IMV-фаза: финальный обход тронутых домов ──────────
if enrich_imv and all_touched_house_ids:
@ -1831,7 +1858,7 @@ async def run_avito_city_sweep(
run_id,
len(all_touched_house_ids),
)
runs.mark_done(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, _ckpt())
return counters
elif shutdown_requested():
# SIGTERM-drain до IMV-фазы: финализируем без дорогой IMV-оценки.
@ -1841,7 +1868,10 @@ async def run_avito_city_sweep(
run_id,
len(all_touched_house_ids),
)
runs.mark_done(db, run_id, counters.to_dict())
# #3319: тоже дрейн, но якоря пройдены ВСЕ — резюмить нечего
# (пропустил бы весь список и собрал ноль), поэтому метка
# диагностическая, а не резюм-флаг `interrupted`.
runs.mark_done(db, run_id, _ckpt(imv_phase_drained=1))
return counters
logger.info(
@ -1855,7 +1885,7 @@ async def run_avito_city_sweep(
# без update_heartbeat, и reap_zombies помечает живой run
# 'zombie' → последующий mark_done становится no-op (дубль-sweep).
def _imv_heartbeat() -> None:
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.update_heartbeat(db, run_id, _ckpt())
imv_result = await enrichment.process_houses_imv_batch(
db,
@ -1867,7 +1897,7 @@ async def run_avito_city_sweep(
counters.imv_enriched += imv_result.saved
counters.imv_failed += imv_result.errors
counters.errors_count += imv_result.errors
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.update_heartbeat(db, run_id, _ckpt())
logger.info(
"city-sweep run_id=%d: IMV phase done — attempted=%d enriched=%d failed=%d",
run_id,
@ -1892,9 +1922,9 @@ async def run_avito_city_sweep(
db.rollback()
except Exception:
pass
runs.update_heartbeat(db, run_id, counters.to_dict())
runs.update_heartbeat(db, run_id, _ckpt())
runs.mark_done(db, run_id, counters.to_dict())
runs.mark_done(db, run_id, _ckpt())
logger.info(
"city-sweep run_id=%d done: anchors=%d/%d lots=%d (ins=%d/upd=%d) "
"houses=%d/%d detail=%d/%d imv=%d/%d errors=%d",
@ -1916,7 +1946,7 @@ async def run_avito_city_sweep(
except Exception as exc:
logger.exception("city-sweep run_id=%d: fatal error", run_id)
runs.mark_failed(db, run_id, str(exc), counters.to_dict())
runs.mark_failed(db, run_id, str(exc), _ckpt())
raise

View file

@ -638,7 +638,14 @@ def _resume_decision(row: Any) -> tuple[int | None, dict[str, Any]]:
}
_boot_reaped_zombie = row.prev_status == "zombie" and prev_counters.get("boot_reaped") is True
if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie:
# 'done' с counters.interrupted=1 — SIGTERM-drain (#3319): статус штатный, но обход
# оборван на границе корзины, часть дерева не собрана. Сам статус в _RESUME_STATUSES
# не добавлен НАМЕРЕННО: чистое 'done' — полный проход, резюмить у него нечего, а
# подхват такой точки означал бы, что источник больше никогда не обходится целиком.
# Метка — та же, что у rosreestr_dkp-дрейна (app/services/scheduler.py), поэтому ни
# один читатель статуса не меняется.
_drained_done = row.prev_status == "done" and bool(prev_counters.get("interrupted"))
if row.prev_status not in _RESUME_STATUSES and not _boot_reaped_zombie and not _drained_done:
verdict["resume_reason"] = f"status_{row.prev_status}"
elif not row.same_params:
verdict["resume_reason"] = "params_changed"