From 050a133839a0818f9f72a3c32c239038b03b6d21 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 17:25:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D1=81?= =?UTF-8?q?=D1=8B=D1=80=D1=8C=D1=8F=20msk=5Fraw=20=D1=83=D0=BC=D0=B5=D0=B5?= =?UTF-8?q?=D1=82=20=D0=9C=D0=BE=D1=81=D0=BA=D0=BE=D0=B2=D1=81=D0=BA=D1=83?= =?UTF-8?q?=D1=8E=20=D0=BE=D0=B1=D0=BB=D0=B0=D1=81=D1=82=D1=8C,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=9C?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B2=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Целевой регион стал параметром (--region, 77 по умолчанию). Поведение Москвы осталось байт-в-байт: её карточки Циана по-прежнему опознаются префиксом округа, Яндекса — вторым компонентом адреса, ДомКлика — первым. Область у Циана в адресе не видна вовсе, поэтому берётся по поддомену source_url. Замер по прод-сырью 12.09.2026: поддомен www даёт 38 030 карточек, из них 36 569 с префиксом округа; все прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, khimki, mytishchi, podolsk, odintsovo и ещё полсотни) — ноль карточек с округом. Два сигнала нигде не противоречат друг другу, поэтому Москва остаётся на округе, а область читается из URL. Нераспознанный хост даёт None и трактуется как «не область» — ложной области он не создаёт. У Яндекса область — второй компонент адреса, значений ровно два: Москва 13 287 и Московская область 10 865. У Авито регион приходит из пред-геокода, и полка msk_raw.avito_geocode уже содержит 9 840 строк с регионом 50, так что прогон по области не стоит ни одного внешнего вызова. У ДомКлика областного корпуса нет вовсе — сырьё собрано с GUID-ом Москвы, поэтому --region 50 падает явной ошибкой, а не отдаёт тихий ноль. В listings.city для области пишется None: единого города у неё нет (canonical_city в реестре пуст), а save_listings COALESCE'ит city и уже известное значение не затирает. Подбор аналогов радиусный, ценовая полоса ДКП ключуется на deals.city, так что потери нет. Счётчики переименованы под параметр: skipped_not_moscow стал skipped_not_target_region, skipped_oblast — skipped_geo_other_region. --- .../backend/app/tasks/msk_raw_import.py | 228 +++++++++++++----- .../backend/tests/test_msk_raw_import.py | 122 +++++++++- 2 files changed, 285 insertions(+), 65 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py index 18a8df25..a7ec9113 100644 --- a/tradein-mvp/backend/app/tasks/msk_raw_import.py +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -1,22 +1,34 @@ -"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`. +"""Импорт сырья `msk_raw.*_latest` в `listings` — Москва (77) и область (50). Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка несёт `payload` — сериализованный `ScrapedLot` один в один (те же 54 ключа, что и поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем -`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`. +`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`. -Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато -начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и -опознаётся. Замер по проду (60 464 карточки): с округом — 35 551, ВСЕ внутри -bbox региона 77; без округа внутри bbox — 17 576 (это Московская область, регион -50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox — -7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» — артефакт -парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва». +Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для +Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и +опознаётся — байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом +— 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox — 17 576 (это +Московская область, регион 50); без округа вне bbox — 7 337. Отдельно 212 +карточек с адресом вида «Екатеринбург (Cian)» — артефакт парсера, считаются +своим счётчиком, чтобы не растворяться в «не целевой регион». + +Область (регион 50) у Циана в адресе НЕ видна вовсе — берём по ПОДДОМЕНУ +`source_url` (`https://.cian.ru/...`): `sub != "www"` ⇒ область. Замер по +`msk_raw.cian_latest` 12.09.2026: `www` — 38 030 карточек, из них 36 569 с +префиксом округа (это Москва); все прочие поддомены (krasnogorsk 2065, +balashikha 1828, vidnoye 1802, lyubertsy 1498, zvenigorod 1420, khimki 1306, +mytishchi 1288, podolsk 756, odintsovo 738, …) — 0 карточек с префиксом округа, +итого не-www ≈ 24 784. Поддомен и префикс округа нигде не противоречат друг +другу, поэтому Москва остаётся на префиксе округа (не трогаем), а область — +на поддомене. Поддомен не распознался (нет source_url / хост не `*.cian.ru`) → +карточка НЕ область (консервативно, счётчик «не целевой регион»). Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес — голая улица с домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335). -Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр. +Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр — +для ЛЮБОГО целевого региона. Два сигнала, и оба нужны — ни один по отдельности не годится. @@ -42,16 +54,16 @@ bbox региона 77; без округа внутри bbox — 17 576 (это qc_geo=0 у 97% найденных. Что куда едет: - * регион 77 → в `listings`, С координатами (geom есть сразу, radius-подбор - аналогов работает без ожидания `geocode_missing`); - * регион 50 → НЕ пишется, ждёт появления региона 50 в реестре; лежит не в - воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) — когда - регион появится, прогон по этой полке уже не потребует внешних вызовов; + * регион == `--region` → в `listings`, С координатами (geom есть сразу, + radius-подбор аналогов работает без ожидания `geocode_missing`); + * адрес разрешился, но регион ответа не совпал с `--region` → не пишется, + лежит не в воздухе, а строкой в `msk_raw.avito_geocode` — прогон с другим + `--region` подхватит её из кэша без единого внешнего вызова; * адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») → свой счётчик, карточка не пишется. `--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет -Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает. +целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает. Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`, @@ -71,7 +83,7 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону одинаковы. -Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и +Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается вторым компонентом. Замер по 21 393 карточкам первого прохода — ровно два значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса @@ -85,14 +97,19 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це второй город. Замер по 5 024 карточкам первого прохода: единственное значение первого компонента — «Москва». Новая Москва приходит как «Москва, пос. Птичное, …», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты -заполнены у 100% карточек. +заполнены у 100% карточек. Для региона 50 у ДомКлика КОРПУСА НЕТ ВООБЩЕ — запуск +`--source domclick --region 50` падает `SystemExit` с явным текстом, а не тихо +отдаёт ноль карточек. Запуск: python -m app.tasks.msk_raw_import --dry-run python -m app.tasks.msk_raw_import --limit 500 python -m app.tasks.msk_raw_import --source yandex + 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 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 # аварийный """ @@ -102,6 +119,7 @@ import argparse import asyncio import logging import re +from collections.abc import Callable from dataclasses import dataclass from urllib.parse import urlsplit @@ -119,9 +137,11 @@ logger = logging.getLogger(__name__) MOSCOW_REGION_CODE = 77 MOSCOW_CITY = "Москва" -# Регион 50 (Московская область) в реестре `app.services.regions` ещё не заведён — -# карточки области не пишутся, а откладываются (см. докстринг модуля). +# Московская область в реестре `app.services.regions` заведена, но своего +# единого города у неё нет (`canonical_city is None`) — в listings.city для +# неё пишем None (см. `import_msk_raw`, `save_listings` его COALESCE'ит). OBLAST_REGION_CODE = 50 +SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE) DEFAULT_BATCH_SIZE = 500 # Слаг города в `source_url` Авито: `https://www.avito.ru//kvartiry/...`. @@ -427,14 +447,16 @@ class ImportCounters: read: int = 0 skipped_artifact: int = 0 - skipped_not_moscow: int = 0 + # Карточка сама говорит про другой регион (префикс округа / поддомен + # Циана / компонент адреса не совпал с целевым `--region`). + skipped_not_target_region: int = 0 skipped_invalid: int = 0 - # Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область — - # адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не - # разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск - # этого счётчика читается как «проверь квоту», а не «в Москве стало меньше - # домов». - skipped_oblast: int = 0 + # Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой + # регион — адрес разрешён, дом реальный, просто регион в ответе DaData не + # совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился + # бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а + # не «в целевом регионе стало меньше домов». + skipped_geo_other_region: int = 0 skipped_ungeocoded: int = 0 selected: int = 0 inserted: int = 0 @@ -461,8 +483,8 @@ class ImportCounters: self.read == self.selected + self.skipped_artifact - + self.skipped_not_moscow - + self.skipped_oblast + + self.skipped_not_target_region + + self.skipped_geo_other_region + self.skipped_ungeocoded + self.skipped_invalid ) @@ -480,6 +502,37 @@ def is_moscow_address(address: str | None) -> bool: return _MOSCOW_OKRUG_RE.match(address.strip()) is not None +def cian_subdomain(payload: dict) -> str | None: + """Поддомен `source_url` Циана: `https://.cian.ru/...` → ``. + + Адрес карточки регион 50 не выдаёт вовсе (см. докстринг модуля), поэтому + область читается из URL. Поддомен и префикс округа не противоречат друг + другу ни в одной карточке (замер по `msk_raw.cian_latest`, 12.09.2026): + `www` — 38 030 карточек (36 569 с префиксом округа, это Москва); все + прочие поддомены (krasnogorsk, balashikha, vidnoye, lyubertsy, zvenigorod, + khimki, mytishchi, podolsk, odintsovo, …) — 0 карточек с префиксом округа. + Хост не `*.cian.ru` или `source_url` отсутствует → None (консервативно: + региону не сопоставляем). + """ + url = payload.get("source_url") + if not isinstance(url, str) or not url: + return None + # `hostname`, а не `netloc`: он уже без порта и userinfo и в нижнем + # регистре — иначе гипотетический `www.cian.ru:443` промахнулся бы мимо + # суффикса и уехал в «не целевой регион». + host = urlsplit(url).hostname or "" + if not host.endswith(".cian.ru"): + return None + sub = host[: -len(".cian.ru")] + return sub or None + + +def is_cian_oblast_payload(payload: dict) -> bool: + """Регион 50 у Циана: любой поддомен, кроме `www` (см. `cian_subdomain`).""" + sub = cian_subdomain(payload) + return sub is not None and sub != "www" + + def is_moscow_yandex_address(address: str | None) -> bool: """У Яндекса регион — второй компонент полного адреса. @@ -497,6 +550,17 @@ def is_moscow_yandex_address(address: str | None) -> bool: return len(parts) > 1 and parts[1] == "Москва" +def is_oblast_yandex_address(address: str | None) -> bool: + """Регион 50 у Яндекса: второй компонент адреса — «Московская область». + + Симметрично `is_moscow_yandex_address`: во втором компоненте встречаются + ровно два значения (см. докстринг модуля), третьего нет — среди карточек + Яндекса «не Москва» и означает «область». + """ + parts = [part.strip() for part in (address or "").split(",")] + return len(parts) > 1 and parts[1] == "Московская область" + + def is_moscow_domclick_address(address: str | None) -> bool: """У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …». @@ -515,12 +579,23 @@ def is_moscow_domclick_address(address: str | None) -> bool: return bool(parts) and parts[0] == "Москва" -# Источники, у которых город виден в самой карточке. Ключ отсутствует — -# источник про город молчит, и без пред-геокода писать его нельзя (avito). -CITY_FILTERS = { - "cian": is_moscow_address, - "yandex": is_moscow_yandex_address, - "domclick": is_moscow_domclick_address, +def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]: + """Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра).""" + return lambda payload: fn(payload.get("address")) + + +# Реестр (source, целевой регион) → фильтр по ВСЕМУ payload'у, не только +# адресу: у Циана признак региона 50 лежит в `source_url`, адрес про него +# молчит. Ключа нет — либо источник вообще не умеет отличать регион без +# пред-геокода (avito, для ЛЮБОГО региона), либо корпус физически не содержит +# нужных карточек (domclick + регион 50 — своя явная проверка в +# `import_msk_raw`, а не тихий ноль здесь). +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), } @@ -569,6 +644,7 @@ def import_msk_raw( db: Session, *, source: str = "cian", + region: int = MOSCOW_REGION_CODE, batch_size: int = DEFAULT_BATCH_SIZE, limit: int | None = None, dry_run: bool = False, @@ -577,32 +653,44 @@ def import_msk_raw( geocode_limit: int = DEFAULT_GEOCODE_LIMIT, ) -> ImportCounters: """Переливает сырьё `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() matcher = RealMatcherAdapter() budget = GeocodeBudget(remaining=max(0, geocode_limit)) - # Источник, который сам говорит про город: у Циана это префикс округа, у - # Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему - # нужен пред-геокод, поэтому его в словаре нет. - city_filter = CITY_FILTERS.get(source) + # Источник, который сам говорит про целевой регион: у Циана это префикс + # округа/поддомен, у Яндекса — второй компонент адреса, у ДомКлика — + # первый. Авито не говорит ничего ни для какого региона, ему нужен + # пред-геокод, поэтому в реестре его нет вовсе. + city_filter = REGION_FILTERS.get((source, region)) if geocode and city_filter is not None: - # Город опознаётся даром и без ошибок — тратить на него внешнюю квоту + # Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту # незачем. raise SystemExit(f"source={source}: --geocode нужен только для avito") if city_filter is None and not geocode: - # Без пред-геокода у Авито по-прежнему нечем отделить Москву от области: - # ни города в адресе, ни координат. Пишем только по явному разрешению. + # Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от + # прочих: ни города в адресе, ни координат. Пишем только по явному + # разрешению. if not (dry_run or allow_unfiltered): raise SystemExit( - f"source={source}: адрес не содержит признака города, Москву от " - "области не отличить. Нужен --geocode (штатный путь), " + f"source={source}: адрес не содержит признака города, регион " + f"{region} от прочих не отличить. Нужен --geocode (штатный путь), " "--allow-unfiltered (аварийный) или --dry-run." ) logger.warning( - "source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; " - "строки лягут без geom и вперемешку с областью", + "source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет " + "вовсе; строки лягут без geom и вперемешку с прочими регионами", source, + region, ) if geocode and not dry_run: db.execute(text(_GEO_CACHE_DDL)) @@ -618,16 +706,16 @@ def import_msk_raw( if is_artifact_address(address): counters.skipped_artifact += 1 continue - if city_filter is not None and not city_filter(address): - counters.skipped_not_moscow += 1 + if city_filter is not None and not city_filter(payload): + counters.skipped_not_target_region += 1 continue if geocode: point = points.get(geo_cache_key(address or "", avito_city_slug(payload))) if point is None: counters.skipped_ungeocoded += 1 continue - if point.region_code != MOSCOW_REGION_CODE: - counters.skipped_oblast += 1 + if point.region_code != region: + counters.skipped_geo_other_region += 1 continue # Координаты кладём в КОПИЮ payload'а: исходную строку сырья не # трогаем, пересбор корпуса от этого не зависит. geom появляется @@ -649,19 +737,26 @@ def import_msk_raw( if dry_run or not lots: continue + # У региона 50 своего единого города нет (`canonical_city is None` в + # реестре regions) — пишем city=None, `save_listings` его COALESCE'ит + # и существующее значение не затирает. Подбор аналогов не страдает: + # он радиусный (ST_DWithin), а не по городу; ценовая полоса ДКП + # ключуется на `deals.city`, а не на `listings.city`. + city = MOSCOW_CITY if region == MOSCOW_REGION_CODE else None inserted, updated = save_listings( db, lots, matcher=matcher, - region_code=MOSCOW_REGION_CODE, - city=MOSCOW_CITY, + region_code=region, + city=city, ) counters.inserted += inserted counters.updated += updated db.commit() # батч зафиксирован — обрыв не отматывает всю работу logger.info( - "msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)", + "msk_raw %s region=%d: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)", source, + region, counters.read, counters.selected, counters.written, @@ -679,11 +774,12 @@ def import_msk_raw( geocode_limit, ) logger.info( - "msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d " - "(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d " - "область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | " + "msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d " + "(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d " + "геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | " "геокод-вызовов=%d | сходится=%s", source, + region, " (dry-run)" if dry_run else "", counters.read, counters.selected, @@ -691,8 +787,8 @@ def import_msk_raw( counters.inserted, counters.updated, counters.writer_skipped if not dry_run else 0, - counters.skipped_not_moscow, - counters.skipped_oblast, + counters.skipped_not_target_region, + counters.skipped_geo_other_region, counters.skipped_ungeocoded, counters.skipped_artifact, counters.skipped_invalid, @@ -707,21 +803,30 @@ def main() -> None: level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) - parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)") + parser = argparse.ArgumentParser( + description="Импорт сырья msk_raw в listings (регион задаётся --region, по умолчанию 77)" + ) parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian") + parser.add_argument( + "--region", + type=int, + choices=SUPPORTED_REGIONS, + default=MOSCOW_REGION_CODE, + help=f"целевой регион: {MOSCOW_REGION_CODE} — Москва, {OBLAST_REGION_CODE} — область", + ) parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE) parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек") parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики") parser.add_argument( "--allow-unfiltered", action="store_true", - help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom", + help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom", ) parser.add_argument( "--geocode", action="store_true", help="штатный путь для avito: пред-геокод адреса (слаг + DaData), " - "в listings уходит только регион 77, область откладывается", + "в listings уходит только целевой регион (--region), прочее пропускается", ) parser.add_argument( "--geocode-limit", @@ -737,6 +842,7 @@ def main() -> None: import_msk_raw( db, source=args.source, + region=args.region, batch_size=args.batch_size, limit=args.limit, dry_run=args.dry_run, diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py index b9e7ad58..85a98ed0 100644 --- a/tradein-mvp/backend/tests/test_msk_raw_import.py +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -156,7 +156,7 @@ def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> No assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"] assert counters.read == 5 assert counters.selected == 2 - assert counters.skipped_not_moscow == 1 + assert counters.skipped_not_target_region == 1 assert counters.skipped_artifact == 1 assert counters.skipped_invalid == 1 assert counters.check(), "счётчики обязаны сходиться с прочитанным" @@ -331,7 +331,7 @@ def test_geocode_lets_avito_through_without_allow_unfiltered( _FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True ) assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] - assert counters.skipped_oblast == 1 + assert counters.skipped_geo_other_region == 1 assert counters.skipped_ungeocoded == 1 assert counters.check(), "счётчики обязаны сходиться с прочитанным" @@ -444,7 +444,7 @@ def test_yandex_oblast_rows_do_not_reach_the_writer(captured: list[dict[str, Any ] counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10) assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] - assert counters.skipped_not_moscow == 1 + assert counters.skipped_not_target_region == 1 def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None: @@ -482,4 +482,118 @@ def test_domclick_non_moscow_is_rejected() -> None: def test_domclick_view_is_wired_into_source_views() -> None: assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" - assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address + filt = mod.REGION_FILTERS[("domclick", 77)] + assert filt(_payload(source="domclick", address="Москва, улица Нижние Мнёвники, 16 к2")) + assert not filt(_payload(source="domclick", address="Московская область, Химки, 9 Мая, 21к3")) + + +# ── регион 50 (Московская область) ─────────────────────────────────────────── + + +def test_cian_subdomain_selects_oblast_regardless_of_okrug( + captured: list[dict[str, Any]], +) -> None: + """khimki.cian.ru → область при --region 50, даже если адрес без округа.""" + rows = [ + { + "id": 1, + "payload": _payload( + source_id="khimki-1", + source_url="https://khimki.cian.ru/sale/flat/1/", + address="городской округ Химки, Олимпийский проспект, 29", + ), + } + ] + counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10) + assert counters_50.selected == 1 + + counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10) + assert counters_77.selected == 0 + + +def test_cian_www_with_okrug_stays_moscow_only(captured: list[dict[str, Any]]) -> None: + """www.cian.ru + префикс округа — Москва (77), не область (50).""" + rows = [ + { + "id": 1, + "payload": _payload( + source_id="www-1", + source_url="https://www.cian.ru/sale/flat/330200428/", + address="ЦАО, р-н Арбат, улица Арбат, 10", + ), + } + ] + counters_77 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=77, batch_size=10) + assert counters_77.selected == 1 + + counters_50 = mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10) + assert counters_50.selected == 0 + + +def test_yandex_oblast_region_50_is_selected(captured: list[dict[str, Any]]) -> None: + rows = [ + { + "id": 1, + "payload": _yandex( + source_id="oblast-1", + address="Россия, Московская область, Химки, Ивановская улица, 5", + ), + } + ] + counters_50 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=50, batch_size=10) + assert counters_50.selected == 1 + + counters_77 = mod.import_msk_raw(_FakeSession(rows), source="yandex", region=77, batch_size=10) + 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_writer_gets_city_none_for_oblast(captured: list[dict[str, Any]]) -> None: + rows = [ + { + "id": 1, + "payload": _payload( + source_id="khimki-1", + source_url="https://khimki.cian.ru/sale/flat/1/", + address="городской округ Химки, Олимпийский проспект, 29", + ), + } + ] + mod.import_msk_raw(_FakeSession(rows), source="cian", region=50, batch_size=10) + assert captured[0]["region_code"] == 50 + assert captured[0]["city"] is None + + +def test_moscow_region_77_numbers_unchanged(captured: list[dict[str, Any]]) -> None: + """Анти-регресс: те же фикстуры, тот же region=77 → те же прежние числа.""" + counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=10, region=77) + assert [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"] + assert counters.read == 5 + assert counters.selected == 2 + assert counters.skipped_not_target_region == 1 + assert counters.skipped_artifact == 1 + assert counters.skipped_invalid == 1 + assert captured[0]["region_code"] == 77 + assert captured[0]["city"] == "Москва" + + +def test_cian_subdomain_ignores_port_and_case() -> None: + """Поддомен читается из `hostname`, а не из `netloc`: порт и регистр хоста + не должны превращать московскую карточку в «не целевой регион», а областную + — в московскую. Хост не `*.cian.ru` (включая голый `cian.ru` и похожий + `fake-cian.ru`) остаётся None — консервативно, ложной области не даёт.""" + assert mod.cian_subdomain({"source_url": "https://WWW.CIAN.RU:443/sale/flat/1/"}) == "www" + assert ( + mod.cian_subdomain({"source_url": "https://khimki.cian.ru:8443/sale/flat/1/"}) == "khimki" + ) + assert mod.is_cian_oblast_payload({"source_url": "https://khimki.cian.ru:8443/x"}) is True + assert mod.is_cian_oblast_payload({"source_url": "https://WWW.CIAN.RU:443/x"}) is False + for bad in ("https://cian.ru/sale/flat/1/", "https://fake-cian.ru/x", "", None, 42): + assert mod.cian_subdomain({"source_url": bad}) is None + assert mod.cian_subdomain({}) is None -- 2.45.3