fix(mera-public): публичная копия — сервис не называет себя оценщиком, площадки-источники не названы #3342
29 changed files with 430 additions and 126 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 различная "
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue