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
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:
commit
aaf8119408
51 changed files with 1944 additions and 219 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -110,10 +110,10 @@ logger = logging.getLogger(__name__)
|
|||
# диапазон ₽/м² применён выше по потоку, в `_load_sample`; дублировать его тут
|
||||
# значило бы завести проверку, которая в проде не срабатывает никогда.
|
||||
REJECTION_RULE = (
|
||||
"Строка не попадает на витрину, только если данных нет: оценщик не дал "
|
||||
"Строка не попадает на витрину, только если данных нет: расчёт МЕРЫ не дал "
|
||||
"ожидаемой цены продажи (мало аналогов), неизвестен квартал сделки или "
|
||||
"площадь. Величина отклонения на отбор и отбраковку не влияет — иначе "
|
||||
"витрина показывала бы лучший хвост, а не работу оценщика. Санитарный "
|
||||
"витрина показывала бы лучший хвост, а не работу расчёта. Санитарный "
|
||||
"диапазон цены сделки (30 000–600 000 ₽/м² для Екатеринбурга) применён "
|
||||
"к выборке до расчёта, по цене самой сделки."
|
||||
)
|
||||
|
|
@ -123,7 +123,7 @@ NOTE = (
|
|||
"это не point-in-time проверка, дрейф рынка за период входит в отклонение целиком. "
|
||||
"Факт — цена ДКП из договора (поле price_rub Росреестра, не пересчёт из ₽/м²): "
|
||||
"она бывает занижена сторонами, и тогда "
|
||||
"строка выглядит как промах оценщика, хотя врёт документ. "
|
||||
"строка выглядит как промах расчёта, хотя врёт документ. "
|
||||
"Схема на карточке — улица сделки, а не её дом: в адресе Росреестра номер дома "
|
||||
"есть у 2.7% строк, поэтому дом не показан и показан быть не может."
|
||||
"Точка на карте — центроид улицы, а не дом: в выборке витрины 991 различная "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
225
tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
Normal file
225
tradein-mvp/backend/tests/test_3319_citysweep_checkpoint.py
Normal 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"
|
||||
179
tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
Normal file
179
tradein-mvp/backend/tests/test_3332_domclick_counter_identity.py
Normal 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 не должна начислять исход дважды"
|
||||
)
|
||||
267
tradein-mvp/backend/tests/test_pdf_public_source_labels.py
Normal file
267
tradein-mvp/backend/tests/test_pdf_public_source_labels.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 employee↔pilot покрыты tests/test_role_single_source.py.
|
||||
"""
|
||||
monkeypatch.setattr(auth_mod, "_registry_role", lambda username: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test app — использует РЕАЛЬНЫЙ rbac_guard (app/core/rbac.py), а не копию.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
188
tradein-mvp/backend/tests/test_role_single_source.py
Normal file
188
tradein-mvp/backend/tests/test_role_single_source.py
Normal 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
|
||||
|
|
@ -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("неизвестный город не выдумывает величину", () => {
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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(" · "),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function TwoPathsV3() {
|
|||
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
|
||||
не хочется вести показы и торг — передайте продажу нашим
|
||||
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
|
||||
по цене из вашего отчёта, стоимость фиксированная и известна
|
||||
по цене из вашего отчёта, стоимость услуг фиксированная и известна
|
||||
заранее. Ни к чему не обязывает — просто узнайте условия.
|
||||
</p>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разброс расхождений ПОКАЗАННЫХ строк.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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}.`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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" }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? "—"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: "бесплатно",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -1693,7 +1693,7 @@ export default function ParamsPanel({
|
|||
}}
|
||||
>
|
||||
<label htmlFor="pp-address" style={hintLabel}>
|
||||
АДРЕС (YANDEX / NOMINATIM)
|
||||
АДРЕС (УЛИЦА, ДОМ)
|
||||
</label>
|
||||
{/* Город (#2576) — компактный дропдаун рядом с лейблом адреса, не
|
||||
отдельная строка: не отнимает вертикальное место у и так плотной
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
["лот", "лота", "лотов"],
|
||||
)})`;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue