From 02a1ec08f358dfafd3eab227fe656ba27a259673 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 19:54:33 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=94=D0=BE=D0=BC=D0=9A=D0=BB=D0=B8=D0=BA=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?=D0=B5=D1=89=D1=91=20=D0=B8=20=D0=BF=D0=BE=20=D0=BA=D0=BE=D0=BE?= =?UTF-8?q?=D1=80=D0=B4=D0=B8=D0=BD=D0=B0=D1=82=D0=B5,=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D1=84=D0=B8=D0=BA=D1=81=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Замер на собранном корпусе: у 2 960 из 2 961 карточки областного батча первый компонент адреса — «Московская область», а у одной «Можайский муниципальный округ, д. Семёновское, 1» (55.5116/35.8293). Это настоящая область, строгий префикс выбросил бы её молча. Фильтр стал payload-уровневым: префикс → да; «Москва» первым компонентом → нет (раньше гео-ветки, иначе вложенный в областной bbox Москвы протащил бы столицу); иначе координата внутри bbox_region региона 50 из реестра. Московский батч тем же замером даёт «Москва» у всех 22 836 карточек — вторая опора его не трогает. --- .../backend/app/tasks/msk_raw_import.py | 33 +++++++++++++- .../backend/tests/test_msk_raw_import.py | 43 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py index 5213cc34..142ef919 100644 --- a/tradein-mvp/backend/app/tasks/msk_raw_import.py +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -141,6 +141,7 @@ from sqlalchemy.orm import Session from app.core.db import SessionLocal from app.services import dadata from app.services.geocoder import normalize_address +from app.services.regions import REGIONS, is_within_bbox from app.services.scraper_adapters import RealMatcherAdapter logger = logging.getLogger(__name__) @@ -606,6 +607,36 @@ def is_oblast_domclick_address(address: str | None) -> bool: return bool(parts) and parts[0] == "Московская область" +def _payload_point(payload: dict) -> tuple[float, float] | None: + """(lat, lon) из сырья ДомКлика, если сборщик их положил и они читаются.""" + try: + return float(payload["lat"]), float(payload["lon"]) + except (KeyError, TypeError, ValueError): + return None + + +def is_oblast_domclick_payload(payload: dict) -> bool: + """Область у ДомКлика: префикс адреса ИЛИ координата внутри bbox области. + + Одного префикса мало. Замер на собранном корпусе 12.09.2026 (2 961 карточка + областного батча): «Московская область» стоит первым компонентом у 2 960, а + у одной — «Можайский муниципальный округ, д. Семёновское, 1», 55.5116/35.8293. + Это настоящая область (Можайск), и строгий префикс выбросил бы её молча. + Московский батч тем же замером даёт «Москва» первым компонентом у ВСЕХ + 22 836 карточек, поэтому явный отказ Москве идёт раньше гео-ветки и bbox + Москвы (вложенный в областной) не может протащить столичную карточку в 50. + """ + address = payload.get("address") + if is_oblast_domclick_address(address): + return True + if is_moscow_domclick_address(address): + return False + point = _payload_point(payload) + if point is None: + return False + return is_within_bbox(point[0], point[1], REGIONS[OBLAST_REGION_CODE].bbox_region) + + def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]: """Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра).""" return lambda payload: fn(payload.get("address")) @@ -622,7 +653,7 @@ REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = { ("yandex", MOSCOW_REGION_CODE): _by_address(is_moscow_yandex_address), ("yandex", OBLAST_REGION_CODE): _by_address(is_oblast_yandex_address), ("domclick", MOSCOW_REGION_CODE): _by_address(is_moscow_domclick_address), - ("domclick", OBLAST_REGION_CODE): _by_address(is_oblast_domclick_address), + ("domclick", OBLAST_REGION_CODE): is_oblast_domclick_payload, } diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py index f5e71392..d2e983c1 100644 --- a/tradein-mvp/backend/tests/test_msk_raw_import.py +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -491,6 +491,48 @@ def test_domclick_oblast_is_recognised_without_geocode() -> None: assert not mod.is_oblast_domclick_address(None) +def test_domclick_oblast_without_prefix_is_saved_by_its_coordinates() -> None: + """Живой случай: областная карточка БЕЗ префикса «Московская область». + + В собранном корпусе 12.09.2026 такая ровно одна на 2 961 — Можайск, и + строгий префикс выбросил бы её молча. Координата внутри bbox области — + вторая опора фильтра, а не замена первой. + """ + mozhaysk = _payload( + source="domclick", + address="Можайский муниципальный округ, д. Семёновское, 1", + lat=55.511625, + lon=35.829308, + ) + assert mod.is_oblast_domclick_payload(mozhaysk) + + # Строки вместо чисел (JSON сырья бывает и таким) читаются так же. + assert mod.is_oblast_domclick_payload({**mozhaysk, "lat": "55.511625", "lon": "35.829308"}) + + +def test_domclick_oblast_geo_branch_cannot_swallow_moscow() -> None: + """bbox Москвы вложен в областной — отказ по адресу обязан идти РАНЬШЕ гео.""" + moscow = _payload( + source="domclick", + address="Москва, улица Нижние Мнёвники, 16 к2", + lat=55.7558, + lon=37.6173, + ) + assert not mod.is_oblast_domclick_payload(moscow) + + +def test_domclick_oblast_geo_branch_needs_a_usable_point() -> None: + """Нет префикса и нечего проверить координатой → карточка НЕ область.""" + bare = _payload(source="domclick", address="Можайский муниципальный округ, д. Семёновское, 1") + bare.pop("lat", None) + bare.pop("lon", None) + assert not mod.is_oblast_domclick_payload(bare) + assert not mod.is_oblast_domclick_payload({**bare, "lat": None, "lon": None}) + assert not mod.is_oblast_domclick_payload({**bare, "lat": "н/д", "lon": "н/д"}) + # Координата за пределами области (Екатеринбург) тоже не проходит. + assert not mod.is_oblast_domclick_payload({**bare, "lat": 56.8300, "lon": 60.6000}) + + def test_domclick_view_is_wired_into_source_views() -> None: assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" filt77 = mod.REGION_FILTERS[("domclick", 77)] @@ -498,6 +540,7 @@ def test_domclick_view_is_wired_into_source_views() -> None: assert not filt77(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3")) filt50 = mod.REGION_FILTERS[("domclick", 50)] + assert filt50 is mod.is_oblast_domclick_payload assert filt50(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3")) assert not filt50(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))