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() {