fix(tradein/pdf): клиентский PDF не называет площадки-источники — «Источник N», как в веб-отчёте (#3341)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 5m7s

Веб-отчёт и лендинг с #3342 не показывают названия площадок (канон publicLabel в
frontend/src/lib/source-registry.ts), а клиентский PDF по тому же /estimate/{id}
печатал Avito / Циан / «Домклик · Сбер» / Я.Недвижимость / Этажи в пилюлях
источников, «подтверждают Росреестр, ДомКлик…» в советах и «на Циан, Авито,
Я.Недвижимости» в тарифах. Один клиент — два документа с разной нормой, и юр-риск,
ради которого всё делалось, в PDF оставался открытым.

- `_SOURCE_DISPLAY_NAMES` → публичные лейблы 1:1 с реестром фронта: avito → «Источник 1»,
  cian → 2, yandex → 3, domklik → 4, etazhi → 5, rosreestr → «Росреестр»; fallback для
  незнакомого id — «Другой источник», а не `source.title()` (сырой id — та же утечка).
- Алиасы (avito_imv, cian_valuation, yandex_valuation, domclick, etagi) канонизируются
  ДО выбора цвета и лейбла (`_canonical_source`), а списки источников на страницах
  объявлений и сделок дедуплицируются до среза [:5] (`_public_sources`): иначе
  `sources_used` = listing ∪ valuation давал «Источник 1, Источник 1, Источник 2,
  Источник 2, Источник 4» с серой точкой у алиасов и вытеснял yandex.
- Цвета пилюль не тронуты: цвет — опознаватель источника, как на вебе.
- Тексты: «Росреестр, сделки площадок и продажи агентств», «на основных площадках
  объявлений» (двойник offer-rates.ts).
- Гейт `tests/test_pdf_public_source_labels.py`: видимый текст страниц (без тегов и
  атрибутов, href на домены площадок законны) не содержит названий площадок и сырых
  id, по одному кейсу на имя; дедуп алиасов; ветка совета с процентом.

Не тронуто: ссылки на объявления (avito.ru/domclick.ru) — отдельное решение;
`_QUALITY_SOURCE_SLOTS` — только счётчик, имена не рендерит.
This commit is contained in:
bot-backend 2026-09-03 11:58:45 +03:00
parent 34e84ed0ed
commit f2f9ac941b
3 changed files with 351 additions and 27 deletions

View file

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

View file

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

View file

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