From a7470f0c3d30d7c66677b346647e71d98b0a04e9 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 19:38:21 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=98=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20msk?= =?UTF-8?q?=5Fraw=20=D1=83=D0=BC=D0=B5=D0=B5=D1=82=20=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BD=D0=BE=D0=B9=20=D0=94=D0=BE=D0=BC=D0=9A?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=20=D0=BD=D1=91=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Запрет на --source domclick --region 50 был верен ровно до тех пор, пока областного корпуса не существовало. Теперь он собран отдельным прогоном с областным GUID, поэтому запрет снят и заменён настоящим фильтром. Регион читается первым компонентом адреса. Замер живьём на API ДомКлика 12.09.2026: у московских карточек первый компонент «Москва» — так у всех 22 836 строк прод-корпуса; у областных «Московская область» — так у всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и симметричное тому, что уже сделано для Яндекса. Фильтр здесь не вторая линия обороны, а единственный способ развести регионы: обе партии лежат в одной таблице msk_raw.domclick_cards, колонки региона в ней нет, а вью domclick_latest отдаёт их вперемешку. Поэтому в тестах появился случай со смешанной страницей сырья: из двух карточек при region=50 отбирается ровно одна, вторая уходит в счётчик не целевого региона. Прежний тест ожидал SystemExit и заменён поведенческими. Московский путь не тронут. --- .../backend/app/tasks/msk_raw_import.py | 63 ++++++---- .../backend/tests/test_msk_raw_import.py | 114 ++++++++++++++++-- 2 files changed, 148 insertions(+), 29 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py index a7ec9113..5213cc34 100644 --- a/tradein-mvp/backend/app/tasks/msk_raw_import.py +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -89,17 +89,26 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны. -Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине: -сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне -сборщика, поэтому областных карточек в нём нет по построению. Регион читается -ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр -оставлен второй линией — на случай, если в ту же таблицу когда-нибудь приедет -второй город. Замер по 5 024 карточкам первого прохода: единственное значение -первого компонента — «Москва». Новая Москва приходит как «Москва, пос. Птичное, -…», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты -заполнены у 100% карточек. Для региона 50 у ДомКлика КОРПУСА НЕТ ВООБЩЕ — запуск -`--source domclick --region 50` падает `SystemExit` с явным текстом, а не тихо -отдаёт ноль карточек. +Отбор региона (source=domclick) стоит ноль вызовов, но таблица одна на ДВА +РАЗНЫХ прогона сборщика с разными GUID: московский (батч +`msk-serp-domclick-20260912`) и областной (отдельный запуск, батч +`mo-serp-domclick-20260912`) — оба пишут в один и тот же +`msk_raw.domclick_cards`. Колонки региона в таблице НЕТ, а вью +`msk_raw.domclick_latest` отдаёт обе партии вперемешку — курсор по `id` не +различает, из какого прогона строка. Поэтому фильтр по адресу здесь не +опциональная вторая линия, а единственный способ развести регионы. + +Регион читается ПЕРВЫМ компонентом адреса. Замер живьём на API ДомКлика +12.09.2026: московские карточки — «Москва, Генерала Дорохова проспект, 49» и +подобные, первый компонент «Москва» у всех 22 836 карточек прод-корпуса; +областные карточки — «Московская область, Химки, 7-й м-н, проспект +Мельникова, 33», «Московская область, Одинцовский городской округ, +Звенигород, 3-й м-н, 28» и подобные, первый компонент «Московская область» у +всех 140 карточек выборки с семи разных смещений выдачи. Разделение полное и +симметричное `is_moscow_yandex_address`/`is_oblast_yandex_address`. Новая +Москва приходит как «Москва, пос. Птичное, …» — посёлок стоит вторым +компонентом, первый по-прежнему «Москва», регион 77 не ломается. Координаты +заполнены у 100% карточек в обоих прогонах. Запуск: python -m app.tasks.msk_raw_import --dry-run @@ -108,6 +117,7 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це python -m app.tasks.msk_raw_import --source yandex --region 50 python -m app.tasks.msk_raw_import --source cian --region 50 python -m app.tasks.msk_raw_import --source domclick + python -m app.tasks.msk_raw_import --source domclick --region 50 python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000 python -m app.tasks.msk_raw_import --source avito --geocode --region 50 python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный @@ -579,6 +589,23 @@ def is_moscow_domclick_address(address: str | None) -> bool: return bool(parts) and parts[0] == "Москва" +def is_oblast_domclick_address(address: str | None) -> bool: + """Регион 50 у ДомКлика: первый компонент адреса — «Московская область». + + Симметрично `is_moscow_domclick_address`. Таблица `msk_raw.domclick_cards` + копит ДВА разных прогона сборщика (московский батч + `msk-serp-domclick-20260912`, областной `mo-serp-domclick-20260912`) без + своей колонки региона, а вью `msk_raw.domclick_latest` отдаёт обе партии + вперемешку — фильтр по адресу обязателен, не опционален. Замер живьём на + API ДомКлика 12.09.2026: «Московская область, Химки, 7-й м-н, проспект + Мельникова, 33», «Московская область, Одинцовский городской округ, + Звенигород, 3-й м-н, 28» и подобные — первый компонент «Московская + область» у всех 140 карточек выборки с семи разных смещений выдачи. + """ + parts = [part.strip() for part in (address or "").split(",")] + return bool(parts) and parts[0] == "Московская область" + + def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]: """Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра).""" return lambda payload: fn(payload.get("address")) @@ -586,16 +613,16 @@ def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]: # Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только # адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него -# молчит. Ключа нет — либо источник вообще не умеет отличать регион без -# пред-геокода (avito, для ЛЮБОГО региона), либо корпус физически не содержит -# нужных карточек (domclick + регион 50 — своя явная проверка в -# `import_msk_raw`, а не тихий ноль здесь). +# молчит. Ключа нет только для источника, который вообще не умеет отличать +# регион без пред-геокода (avito, для ЛЮБОГО региона) — для всех прочих пар +# фильтр обязан быть в реестре явно. REGION_FILTERS: dict[tuple[str, int], Callable[[dict], bool]] = { ("cian", MOSCOW_REGION_CODE): _by_address(is_moscow_address), ("cian", OBLAST_REGION_CODE): is_cian_oblast_payload, ("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), } @@ -655,12 +682,6 @@ def import_msk_raw( """Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче.""" if region not in SUPPORTED_REGIONS: raise SystemExit(f"region={region}: регион не поддержан, доступны {SUPPORTED_REGIONS}") - if source == "domclick" and region == OBLAST_REGION_CODE: - raise SystemExit( - "source=domclick region=50: корпус собран под Москву (GUID Москвы в " - "параметре сборщика), областных карточек в нём нет вовсе — нужен " - "отдельный сбор, этот импорт их не найдёт." - ) view = SOURCE_VIEWS[source] counters = ImportCounters() diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py index 85a98ed0..f5e71392 100644 --- a/tradein-mvp/backend/tests/test_msk_raw_import.py +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -480,11 +480,26 @@ def test_domclick_non_moscow_is_rejected() -> None: assert not mod.is_moscow_domclick_address(None) +def test_domclick_oblast_is_recognised_without_geocode() -> None: + """Регион 50 у ДомКлика — первый компонент адреса «Московская область».""" + assert mod.is_oblast_domclick_address("Московская область, Химки, 9 Мая, 21к3") + assert mod.is_oblast_domclick_address( + "Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28" + ) + assert not mod.is_oblast_domclick_address("Москва, улица Нижние Мнёвники, 16 к2") + assert not mod.is_oblast_domclick_address("") + assert not mod.is_oblast_domclick_address(None) + + def test_domclick_view_is_wired_into_source_views() -> None: assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" - filt = mod.REGION_FILTERS[("domclick", 77)] - assert filt(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2")) - assert not filt(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3")) + filt77 = mod.REGION_FILTERS[("domclick", 77)] + assert filt77(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2")) + assert not filt77(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3")) + + filt50 = mod.REGION_FILTERS[("domclick", 50)] + assert filt50(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3")) + assert not filt50(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2")) # ── регион 50 (Московская область) ─────────────────────────────────────────── @@ -547,11 +562,94 @@ def test_yandex_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> assert counters_77.selected == 0 -def test_domclick_region_50_refuses_to_run() -> None: - """Корпус ДомКлика собран под Москву — областных карточек в нём нет вовсе.""" - rows = [{"id": 1, "payload": _payload(source="domclick", address="Москва, ул. Тестовая, 1")}] - with pytest.raises(SystemExit): - mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10) +def test_domclick_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None: + """Областной прогон сборщика (батч mo-serp-domclick-20260912) отбирается по region=50.""" + rows = [ + { + "id": 1, + "payload": _payload( + source="domclick", + source_id="oblast-1", + address="Московская область, Химки, проспект Мельникова, 33", + ), + } + ] + counters_50 = mod.import_msk_raw( + _FakeSession(rows), source="domclick", region=50, batch_size=10 + ) + assert counters_50.selected == 1 + + counters_77 = mod.import_msk_raw( + _FakeSession(rows), source="domclick", region=77, batch_size=10 + ) + assert counters_77.selected == 0 + + +def test_domclick_moscow_region_77_is_selected(captured: list[dict[str, Any]]) -> None: + """Московский прогон (батч msk-serp-domclick-20260912) отбирается по region=77.""" + rows = [ + { + "id": 1, + "payload": _payload( + source="domclick", + source_id="moscow-1", + address="Москва, Генерала Дорохова проспект, 49", + ), + } + ] + counters_77 = mod.import_msk_raw( + _FakeSession(rows), source="domclick", region=77, batch_size=10 + ) + assert counters_77.selected == 1 + + counters_50 = mod.import_msk_raw( + _FakeSession(rows), source="domclick", region=50, batch_size=10 + ) + assert counters_50.selected == 0 + + +def test_domclick_writer_gets_region_50_and_city_none(captured: list[dict[str, Any]]) -> None: + rows = [ + { + "id": 1, + "payload": _payload( + source="domclick", + source_id="oblast-2", + address="Московская область, Химки, проспект Мельникова, 33", + ), + } + ] + mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10) + assert captured[0]["region_code"] == 50 + assert captured[0]["city"] is None + + +def test_domclick_mixed_batches_are_told_apart_by_address(captured: list[dict[str, Any]]) -> None: + """Вью отдаёт московский и областной прогоны вперемешку — региональный фильтр + обязан развести их сам, курсор по `id` тут не помощник.""" + rows = [ + { + "id": 1, + "payload": _payload( + source="domclick", + source_id="oblast-3", + address="Московская область, Одинцовский городской округ, Звенигород, 3-й м-н, 28", + ), + }, + { + "id": 2, + "payload": _payload( + source="domclick", + source_id="moscow-2", + address="Москва, Генерала Дорохова проспект, 49", + ), + }, + ] + counters = mod.import_msk_raw(_FakeSession(rows), source="domclick", region=50, batch_size=10) + assert [lot.source_id for lot in captured[0]["lots"]] == ["oblast-3"] + assert counters.selected == 1 + assert counters.skipped_not_target_region == 1 + assert counters.check(), "счётчики обязаны сходиться с прочитанным" def test_writer_gets_city_none_for_oblast(captured: list[dict[str, Any]]) -> None: -- 2.45.3 From 02a1ec08f358dfafd3eab227fe656ba27a259673 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 19:54:33 +0300 Subject: [PATCH 2/2] =?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")) -- 2.45.3