Импорт msk_raw умеет областной ДомКлик, а не падает на нём (#3512)
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m39s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
Some checks failed
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m39s
Deploy Trade-In / build-backend (push) Successful in 1m11s
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Has been cancelled
This commit is contained in:
parent
6ba04d845e
commit
5ee208087d
2 changed files with 222 additions and 29 deletions
|
|
@ -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 # аварийный
|
||||
|
|
@ -131,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__)
|
||||
|
|
@ -579,6 +590,53 @@ 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 _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"))
|
||||
|
|
@ -586,16 +644,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): is_oblast_domclick_payload,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -655,12 +713,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()
|
||||
|
|
|
|||
|
|
@ -480,11 +480,69 @@ 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_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"
|
||||
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 is mod.is_oblast_domclick_payload
|
||||
assert filt50(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3"))
|
||||
assert not filt50(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2"))
|
||||
|
||||
|
||||
# ── регион 50 (Московская область) ───────────────────────────────────────────
|
||||
|
|
@ -547,11 +605,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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue