fix(mera-public): публичная копия — сервис не называет себя оценщиком, площадки-источники не названы #3342

Merged
lekss361 merged 1 commit from fix/mera-public-copy-legal-deplatforming into main 2026-09-03 06:57:12 +00:00
29 changed files with 430 additions and 126 deletions

View file

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

View file

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

View file

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

View file

@ -0,0 +1,173 @@
/**
* Реальные названия площадок-источников не должны попадать в видимый
* клиентский 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 Оценка",
"Я.Оценка",
"Циан Оценка",
];
/**
* Файлы копии в скоупе: лендинг «Мера» + компоненты отчёта trade-in.
* `src/app/scrapers/**` и `src/components/scrapers/**` (admin-only, отдельные
* top-level каталоги) вне обоих корней сканирования их сюда не заносит.
*/
function copySources(dir: string, acc: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (name === "__tests__" || name === "node_modules") continue;
const full = join(dir, name);
if (statSync(full).isDirectory()) {
copySources(full, acc);
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
acc.push(full);
}
}
return acc;
}
/** Вырезает `/* *\/`- и `//`-комментарии; `//` внутри `https://` не режет. */
function stripComments(text: string): string {
// Newlines внутри блочного комментария сохраняем (заменяем на "\n" по числу
// строк), иначе после вырезки съедутся номера строк в отчёте о находке.
const noBlock = text.replace(/\/\*[\s\S]*?\*\//g, (m) =>
"\n".repeat((m.match(/\n/g) ?? []).length),
);
return noBlock
.split("\n")
.map((line) => {
// Ищем `//`, которому НЕ предшествует `:` (т.е. не часть `http(s)://`).
const idx = line.search(/(?<!:)\/\//);
return idx === -1 ? line : line.slice(0, idx);
})
.join("\n");
}
/**
* true, если `needle` встречается в `line` как отдельное слово не часть
* более длинного TS-идентификатора (`CianValuationSummary`, `cianId`).
* `\b` тут не годится: он ASCII-only и на кириллице не сработает вообще
* (ни один кириллический символ не входит в `\w`), поэтому граница
* вручную через Unicode `\p{L}`/`\p{N}`.
*/
function hasWholeWord(line: string, needle: string): boolean {
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, "u");
return re.test(line);
}
describe("публичная копия не содержит реальных названий площадок", () => {
const files = [
...copySources(MERA_PUBLIC_ROOT),
...copySources(TRADE_IN_COMPONENTS_ROOT),
];
it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => {
expect(files.length).toBeGreaterThan(20);
expect(files.some((f) => f.endsWith("HeroSummary.tsx"))).toBe(true);
expect(files.some((f) => f.endsWith("IMVBenchmark.tsx"))).toBe(true);
});
it.each(BANNED)("нигде в видимой копии не встречается «%s»", (needle) => {
const hits: string[] = [];
for (const file of files) {
const raw = readFileSync(file, "utf8");
const text = stripComments(raw);
text.split("\n").forEach((line, i) => {
if (hasWholeWord(line, needle)) {
hits.push(`${file}:${i + 1}`);
}
});
}
expect(
hits,
`«${needle}» — реальное название площадки в видимой клиентской копии. ` +
`Используй sourcePublicLabel() из src/lib/source-registry.ts вместо ` +
`хардкода: посетитель отчёта не должен видеть, какие площадки реально ` +
`участвовали в оценке (юр-риск, #3264-scope). В комментариях это имя ` +
`законно — гейт сюда не смотрит.`,
).toEqual([]);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ import { useState } from "react";
import type { AggregatedEstimate, TradeInEstimateInput, HouseType, RepairState, ConfidenceLevel } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { useActiveBrandSlug, useBrand } from "@/lib/useBrand";
import { sourceDot, sourceLabel } from "@/lib/source-registry";
import { sourceDot, sourcePublicLabel } from "@/lib/source-registry";
import { dealsAsOfLabel } from "@/lib/rosreestr";
import { SHORT_ESTIMATE_DISCLAIMER } from "@/lib/legal-copy";
import { HeroTransparency } from "./HeroTransparency";
@ -309,9 +309,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
)}
<div className="photo-meta">
{heroPhoto
? `фото аналога${estimate.sources_used[0] ? ` · ${sourceLabel(estimate.sources_used[0])}` : ""}`
? `фото аналога${estimate.sources_used[0] ? ` · ${sourcePublicLabel(estimate.sources_used[0])}` : ""}`
: estimate.sources_used.length > 0
? `${sourceLabel(estimate.sources_used[0])} · ${analogsCaption}`
? `${sourcePublicLabel(estimate.sources_used[0])} · ${analogsCaption}`
: "Нет фото"}
</div>
</div>
@ -535,13 +535,13 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
<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">{sourcePublicLabel("avito_imv")} <abbr title={`Information Market Value — независимая оценка рыночной стоимости (${sourcePublicLabel("avito_imv")})`}>IMV</abbr></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={`Индекс Market Value (${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")} <abbr title={`Information Market Value — независимая оценка рыночной стоимости (${sourcePublicLabel("avito_imv")})`}>IMV</abbr>: {formatMln(imvPrice)}
</div>
)}
</div>
@ -689,7 +689,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{formatMln(dM)}
</div>
<div className="exp left">{estimate.actual_deals.length} сделок</div>
<div className="exp right">Росреестр · ДомКлик</div>
<div className="exp right">Росреестр · {sourcePublicLabel("domklik")}</div>
</div>
</div>
);
@ -771,7 +771,7 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals
{estimate.sources_used.length > 0 ? (
estimate.sources_used.map((src) => (
<span key={src} className="source-chip">
<span className={`src-dot ${sourceDot(src)}`} /> {sourceLabel(src)}
<span className={`src-dot ${sourceDot(src)}`} /> {sourcePublicLabel(src)}
</span>
))
) : (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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