diff --git a/tradein-mvp/backend/app/services/geocoder.py b/tradein-mvp/backend/app/services/geocoder.py index 04dff251..f7760ce8 100644 --- a/tradein-mvp/backend/app/services/geocoder.py +++ b/tradein-mvp/backend/app/services/geocoder.py @@ -810,6 +810,33 @@ async def _nominatim_lookup(address: str, city_hint: str | None = None) -> Geoco # ── Suggest (автокомплит) ─────────────────────────────────────────────────── +# Части адреса приходят из реестров УЖЕ склеенными — и склеенными грязно: +# ЕГРН `readable_address` печатает маркер без значения и оставляет лишний +# пробел («г Екатеринбург, ул Фролова , д. 29, корп.»). Чиним склейку, а не +# конкретный случай: один проход по частям — схлопнуть пробелы, выбросить +# пустые и маркер-без-номера (корп./стр./лит./кв. и пр.), склеить обратно. +_RE_ADDR_WS = re.compile(r"\s+") +_RE_ADDR_BARE_MARKER = re.compile( + r"^(?:д|дом|корп|корпус|к|стр|строение|соор|сооружение|лит|литера|литер" + r"|кв|квартира|оф|офис|пом|помещение|вл|владение|уч|участок)\.?$", + re.IGNORECASE, +) + + +def tidy_address(s: str) -> str: + """«г Екатеринбург, ул Фролова , д. 29, корп.» → «г Екатеринбург, ул Фролова, д. 29». + + Часть без значения ВЫПАДАЕТ, а не печатается пустым маркером. + """ + parts = [] + for raw in s.split(","): + part = _RE_ADDR_WS.sub(" ", raw).strip() + if not part or _RE_ADDR_BARE_MARKER.match(part): + continue + parts.append(part) + return ", ".join(parts) + + @dataclass(frozen=True, slots=True) class GeocodeSuggestion: label: str # формат "Малышева 30, Октябрьский район" @@ -822,6 +849,12 @@ class GeocodeSuggestion: # street/locality-кандидатов остаётся None (нет стабильного house-fias). fias_id: str | None = None + def __post_init__(self) -> None: + # Единственная точка, через которую проходят ВСЕ тиры (cadastral/ + # geoportal/houses/DaData/Nominatim) — чистим здесь, а не у каждого. + object.__setattr__(self, "label", tidy_address(self.label)) + object.__setattr__(self, "full_address", tidy_address(self.full_address)) + # ── DaData suggest wrapper ────────────────────────────────────────────────── # Маппинг DaData kind → GeocodeSuggestion.kind (внутри geocoder используется diff --git a/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py b/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py new file mode 100644 index 00000000..33484230 --- /dev/null +++ b/tradein-mvp/backend/tests/test_geocode_suggest_address_join.py @@ -0,0 +1,70 @@ +"""Склейка адреса в подсказках: пустые части ВЫПАДАЮТ, а не печатаются. + +Прод-дефект: «г Екатеринбург, ул Фролова , д. 29, корп.» — двойной пробел +перед запятой и висячее «корп.» без номера (ЕГРН `readable_address` отдаёт +маркер даже когда значения нет, и все тиры /suggest пропускали строку насквозь). +""" + +import pytest + +from app.services.geocoder import GeocodeSuggestion, tidy_address + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + # Прод-случай целиком. + ( + "г Екатеринбург, ул Фролова , д. 29, корп.", + "г Екатеринбург, ул Фролова, д. 29", + ), + # Непустой корпус ОБЯЗАН остаться. + ( + "г Екатеринбург, ул Фролова, д. 29, корп. 2", + "г Екатеринбург, ул Фролова, д. 29, корп. 2", + ), + # Соседние типы частей — та же болезнь, тот же механизм. + ("г Екатеринбург, ул Мира, д. 1, стр.", "г Екатеринбург, ул Мира, д. 1"), + ("г Екатеринбург, ул Мира, д. 1, лит.", "г Екатеринбург, ул Мира, д. 1"), + ("г Екатеринбург, ул Мира, д. 1, кв.", "г Екатеринбург, ул Мира, д. 1"), + ("г Екатеринбург, ул Мира, д. 1, стр. 5", "г Екатеринбург, ул Мира, д. 1, стр. 5"), + ("г Екатеринбург, ул Мира, д. 1, лит. А", "г Екатеринбург, ул Мира, д. 1, лит. А"), + # Пустая часть посередине и подряд идущие запятые. + ("г Екатеринбург, , ул Мира, корп. , д. 1", "г Екатеринбург, ул Мира, д. 1"), + # Маркер без точки и без номера. + ("г Екатеринбург, ул Мира, д 1, корпус", "г Екатеринбург, ул Мира, д 1"), + # Чистая строка не меняется. + ("г Екатеринбург, ул Малышева, д 30", "г Екатеринбург, ул Малышева, д 30"), + ], +) +def test_tidy_address(raw: str, expected: str) -> None: + assert tidy_address(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "г Екатеринбург, ул Фролова , д. 29, корп.", + "г Екатеринбург, , ул Мира, корп. , д. 1", + "г Екатеринбург, ул Мира, д. 1, стр. , лит.", + ], +) +def test_no_double_spaces_or_dangling_parts(raw: str) -> None: + out = tidy_address(raw) + assert " " not in out + assert " ," not in out + assert not out.endswith(",") + assert out.split(", ")[-1] not in {"корп.", "стр.", "лит.", "кв.", "д."} + + +def test_suggestion_cleans_both_label_and_full_address() -> None: + """Чистка стоит в единой точке — её проходят ВСЕ тиры /suggest.""" + s = GeocodeSuggestion( + label="ул Фролова , д. 29, корп.", + full_address="г Екатеринбург, ул Фролова , д. 29, корп.", + lat=56.8, + lon=60.6, + kind="house", + ) + assert s.label == "ул Фролова, д. 29" + assert s.full_address == "г Екатеринбург, ул Фролова, д. 29" diff --git a/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts new file mode 100644 index 00000000..6b7d71e7 --- /dev/null +++ b/tradein-mvp/frontend/src/app/mera-public/__tests__/public-copy-no-infra.test.ts @@ -0,0 +1,90 @@ +/** + * Инфраструктурные имена не должны попадать в файлы пользовательской копии. + * + * 30.08.2026 «Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026)» + * доехала до экрана: `CITY_COVERAGE_SOURCE` собирается в `coverage-copy.ts` и + * рендерится в `EstimateFlow.tsx:323`. На первом экране лендинга её не видно — + * она появляется в форме проверки, после выбора города. Поэтому grep по + * отрендеренной главной давал ноль, и дефект выглядел несуществующим. + * + * Отсюда форма гейта: он смотрит в ИСХОДНИК, а не в вывод. Какой именно путь + * рендера сегодня показывает строку, знать не нужно — завтра он будет другим, + * а хостнейм прода посетителю не сообщает ничего ни на одном из них. + * + * Скан идёт по всему тексту файла, включая комментарии: комментарий рядом с + * копией — заготовка следующей строки копии, и «на боевой базе» в нём ничем + * не хуже имени машины. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, ".."); + +/** + * Имена машин, контейнеров и внутренних хостов. Список ЯВНЫЙ, а не эвристика: + * эвристика на «что-то похожее на хостнейм» ловила бы доменные имена в текстах + * про источники данных (domclick.ru, avito.ru), которые тут законны. + * + * `gendsgn.ru` в список НЕ входит намеренно: это публичный домен B2B, на + * который лендинг ссылается кнопкой «Для бизнеса» (`B2B_URL` в content.ts). + * Запрет на него сломал бы рабочую ссылку — граница проходит по «внутреннее + * имя машины» против «адрес, который посетитель и так увидит в браузере». + */ +const BANNED = [ + "poincare", + "beget", + "tradein-postgres", + "tradein-backend", + "tradein-scraper", + "gendesign-postgres", + "gendesign-backend", + "glitchtip", + "forgejo", +]; + +/** Файлы копии: то, из чего собирается видимый посетителю текст. */ +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; +} + +describe("публичная копия не содержит инфраструктурных имён", () => { + const files = copySources(ROOT); + + it("файлы копии вообще найдены — иначе гейт зелёный по построению", () => { + // Без этой проверки сломанный обход каталога дал бы пустой список и + // «нарушений нет» на любом содержимом. + expect(files.length).toBeGreaterThan(20); + expect(files.some((f) => f.endsWith("landing-facts.ts"))).toBe(true); + expect(files.some((f) => f.endsWith("coverage-copy.ts"))).toBe(true); + }); + + it.each(BANNED)("нигде не встречается «%s»", (needle) => { + const hits: string[] = []; + for (const file of files) { + const text = readFileSync(file, "utf8"); + text.split("\n").forEach((line, i) => { + if (line.toLowerCase().includes(needle)) { + hits.push(`${file.slice(ROOT.length + 1)}:${i + 1}`); + } + }); + } + expect( + hits, + `«${needle}» — инфраструктурное имя в файле пользовательской копии. ` + + `Посетителю оно не сообщает ничего: «на боевой базе» несёт весь смысл. ` + + `Строка может не рендериться сегодня и начать рендериться завтра — ` + + `так и вышло с CITY_COVERAGE_SOURCE в EstimateFlow.tsx.`, + ).toEqual([]); + }); +}); diff --git a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts index 7f0bfba8..e9b44953 100644 --- a/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/articles/articles-content.ts @@ -4,7 +4,7 @@ * ЧИСЛА ИЗ МАКЕТА СНЯТЫ (29.08.2026). Макет утверждал «по нашим данным, * средний разрыв между первой ценой в объявлении и ценой сделки — 4,1%, а у * провисевших дольше трёх месяцев доходит до 9%», «расхождение с кадастровой - * до 40%» и плитки −4,3% / ×2,4 / 83%. Прод-замер (poincare, 29.08.2026) + * до 40%» и плитки −4,3% / ×2,4 / 83%. Прод-замер (29.08.2026) * не подтвердил ни одно: * * | макет | что показал замер | diff --git a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts index 176dbe4c..e1ee7300 100644 --- a/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts +++ b/tradein-mvp/frontend/src/app/mera-public/landing-facts.ts @@ -44,7 +44,7 @@ export interface MeasuredValue { /** Общий источник трёх величин ниже — один и тот же прогон сверки. */ const BACKTEST_SOURCE = - "Ручная сверка на проде (poincare, 29.08.2026): прогноз МЕРЫ против цены ДКП " + + "Ручная сверка на боевой базе (29.08.2026): прогноз МЕРЫ против цены ДКП " + "Росреестра по Екатеринбургу, сделки II квартала 2026 года"; /** @@ -204,7 +204,7 @@ export interface CityCoverageMeasure { } export const CITY_COVERAGE_SOURCE = - "Симуляция пробы покрытия на боевой базе (poincare, 30.08.2026): случайные адреса " + + "Симуляция пробы покрытия на боевой базе (30.08.2026): случайные адреса " + "активных объявлений, для каждого — когорта самой ручки /coverage (радиус 1 км, то же " + "число комнат, площадь ±15 %, свежесть 14 дней, дедуп по источнику и адресу), " + "собственный адрес из когорты исключён";