Импорт сырья msk_raw умеет Московскую область, а не только Москву
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m40s

Целевой регион стал параметром (--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.
This commit is contained in:
bot-backend 2026-09-12 17:25:07 +03:00
parent 98a582e242
commit 050a133839
2 changed files with 285 additions and 65 deletions

View file

@ -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://<sub>.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/<slug>/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://<sub>.cian.ru/...` → `<sub>`.
Адрес карточки регион 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,

View file

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