Импорт msk_raw умеет областной ДомКлик, а не падает на нём
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s

Запрет на --source domclick --region 50 был верен ровно до тех пор, пока областного
корпуса не существовало. Теперь он собран отдельным прогоном с областным GUID, поэтому
запрет снят и заменён настоящим фильтром.

Регион читается первым компонентом адреса. Замер живьём на API ДомКлика 12.09.2026:
у московских карточек первый компонент «Москва» — так у всех 22 836 строк прод-корпуса;
у областных «Московская область» — так у всех 140 карточек выборки с семи разных
смещений выдачи. Разделение полное и симметричное тому, что уже сделано для Яндекса.

Фильтр здесь не вторая линия обороны, а единственный способ развести регионы: обе
партии лежат в одной таблице msk_raw.domclick_cards, колонки региона в ней нет, а вью
domclick_latest отдаёт их вперемешку. Поэтому в тестах появился случай со смешанной
страницей сырья: из двух карточек при region=50 отбирается ровно одна, вторая уходит в
счётчик не целевого региона.

Прежний тест ожидал SystemExit и заменён поведенческими. Московский путь не тронут.
This commit is contained in:
bot-backend 2026-09-12 19:38:21 +03:00
parent 8863781589
commit a7470f0c3d
2 changed files with 148 additions and 29 deletions

View file

@ -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()

View file

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