diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 06763e6f..a3915707 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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), diff --git a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py index 881f3728..d36fb741 100644 --- a/tradein-mvp/backend/app/tasks/landing_showcase_deals.py +++ b/tradein-mvp/backend/app/tasks/landing_showcase_deals.py @@ -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 различная " diff --git a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py index 6a4ce8be..65c56b3c 100644 --- a/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py +++ b/tradein-mvp/backend/tests/test_2677_house_anchor_repair_basis.py @@ -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: diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts new file mode 100644 index 00000000..60c12c89 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts @@ -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(/(? { + 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([]); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx index 1d700573..69132cb7 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/CostOfErrorV3.tsx @@ -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(" · "), }); } diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx index ea0a9a75..2aebb587 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/SourcesV3.tsx @@ -51,9 +51,12 @@ export function SourcesV3() {

{group.title}

diff --git a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx index 2642138b..ae38d5b1 100644 --- a/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/_components/v3/TwoPathsV3.tsx @@ -63,7 +63,7 @@ export function TwoPathsV3() { Отчёта достаточно, чтобы справиться самому. Но если нет времени или не хочется вести показы и торг — передайте продажу нашим специалистам: объявление, фото, показы, переговоры, сделка. Работаем - по цене из вашего отчёта, стоимость фиксированная и известна + по цене из вашего отчёта, стоимость услуг фиксированная и известна заранее. Ни к чему не обязывает — просто узнайте условия.

` в trade-in.css / `srcDot` в 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: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.", }, ]; diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css index 8c6feab8..7ad86759 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css +++ b/tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css @@ -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; diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 125466c3..834247d2 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -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() {