Merge branch 'main' into fix/mera-public-contrast
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m20s

This commit is contained in:
bot-backend 2026-08-30 10:13:25 +00:00
commit be59ca4452
5 changed files with 196 additions and 3 deletions

View file

@ -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 используется

View file

@ -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"

View file

@ -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([]);
});
});

View file

@ -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)
* не подтвердил ни одно:
*
* | макет | что показал замер |

View file

@ -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 дней, дедуп по источнику и адресу), " +
"собственный адрес из когорты исключён";