From 5541aa9d376621278819599288bc255c1d2cb661 Mon Sep 17 00:00:00 2001
From: bot-backend
Date: Thu, 3 Sep 2026 09:50:41 +0300
Subject: [PATCH] =?UTF-8?q?fix(mera-public):=20=D0=BF=D1=83=D0=B1=D0=BB?=
=?UTF-8?q?=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BE=D0=BF=D0=B8=D1=8F?=
=?UTF-8?q?=20=E2=80=94=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=20=D0=BD?=
=?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?=
=?UTF-8?q?=D1=81=D0=B5=D0=B1=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD=D1=89=D0=B8?=
=?UTF-8?q?=D0=BA=D0=BE=D0=BC,=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0=D0=B4?=
=?UTF-8?q?=D0=BA=D0=B8-=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?=
=?UTF-8?q?=D0=B8=20=D0=BD=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD?=
=?UTF-8?q?=D1=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Три юр-правки по документу владельца продукта «Сайт_МЕРА_v2» (31.08.2026), сделаны
локально 31.08, но не были закоммичены — на meraocenka.ru всё оставалось по-старому.
1. «Оценщик» про собственный алгоритм. Витрина сделок (`landing_showcase_deals.py`,
REJECTION_RULE/NOTE) и сноска статьи «Как оценить квартиру» теперь говорят
«расчёт МЕРЫ»: самоназвание обесценивало дисклеймер «не официальный отчёт оценщика».
Комментарии и докстринги не тронуты — посетитель их не видит.
2. «Путь 2»: «стоимость услуг фиксированная и известна заранее» — читалось как
фиксированная цена квартиры.
3. Названия площадок убраны из видимой копии лендинга и веб-отчёта. Канон —
`publicLabel` + `sourcePublicLabel()` в `source-registry.ts`: одна площадка = один
номер «Источник N» и один цвет точки (цвет остаётся опознавателем между блоками),
Росреестр под своим именем, неизвестный id → «Другой источник» (раньше fallback
отдавал сырой id). Три параллельные реализации `sourceLabel` сведены к одной;
`sourceLabel()` с реальными именами живёт для админки. Backend: якорь
confidence_explanation «по оценке Avito IMV» → «по оценочной модели площадки».
Подсказки геокодера «Yandex / Nominatim» и «по Яндексу» сняты из клиентских форм.
Гейт `public-copy-no-platform-names.test.ts` сканирует mera-public/** и
components/trade-in/** без комментариев, по Unicode-границе слова; список запретов
регистрозависимый намеренно (строчные id `"avito"` законны), поэтому капс-варианты
и словоформы перечислены явно — «6202 ОБЪЯВЛЕНИЯ ДОМКЛИК» в статье именно так
проходил первую версию гейта.
Не закрыто здесь: клиентский PDF (#3341) и ссылки на объявления на доменах площадок.
Текст витрины хранится в БД (`landing_showcase_runs.rejection_rule`,
`landing_showcase_deals.note`), планировщика у пересчёта нет — после деплоя нужен
ручной пересчёт или UPDATE трёх подстрок на проде.
---
tradein-mvp/backend/app/services/estimator.py | 10 +-
.../app/tasks/landing_showcase_deals.py | 6 +-
.../test_2677_house_anchor_repair_basis.py | 2 +-
.../public-copy-no-platform-names.test.ts | 173 ++++++++++++++++++
.../_components/v3/CostOfErrorV3.tsx | 2 +-
.../mera-public/_components/v3/SourcesV3.tsx | 9 +-
.../mera-public/_components/v3/TwoPathsV3.tsx | 2 +-
.../mera-public/articles/articles-content.ts | 4 +-
.../frontend/src/app/mera-public/content.ts | 26 ++-
.../src/app/mera-public/landing-v3.module.css | 30 +++
tradein-mvp/frontend/src/app/page.tsx | 5 +-
.../components/trade-in/CianValuationCard.tsx | 7 +-
.../src/components/trade-in/DealsCard.tsx | 6 +-
.../src/components/trade-in/EstimateForm.tsx | 2 +-
.../src/components/trade-in/HeroSummary.tsx | 18 +-
.../src/components/trade-in/IMVBenchmark.tsx | 25 +--
.../src/components/trade-in/ListingsCard.tsx | 6 +-
.../src/components/trade-in/MapPicker.tsx | 4 +-
.../components/trade-in/PriceHistoryChart.tsx | 25 ++-
.../components/trade-in/SourcesProgress.tsx | 2 +-
.../components/trade-in/StreetDealsCard.tsx | 5 +-
.../src/components/trade-in/offer-rates.ts | 4 +-
.../src/components/trade-in/saleShareUtils.ts | 21 +--
.../src/components/trade-in/trade-in.css | 27 ++-
.../components/trade-in/v2/AnalyticsView.tsx | 26 +--
.../components/trade-in/v2/LocationDrawer.tsx | 7 +-
.../components/trade-in/v2/ParamsPanel.tsx | 2 +-
.../src/components/trade-in/v2/mappers.ts | 33 ++--
.../frontend/src/lib/source-registry.ts | 67 +++++--
29 files changed, 430 insertions(+), 126 deletions(-)
create mode 100644 tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-platform-names.test.ts
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}
{group.items.map((item) => (
-
-
- {item}
+
+
+ {item.label}
))}
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() {
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
не хочется вести показы и торг — передайте продажу нашим
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
- по цене из вашего отчёта, стоимость фиксированная и известна
+ по цене из вашего отчёта, стоимость услуг фиксированная и известна
заранее. Ни к чему не обязывает — просто узнайте условия.