Импорт сырья msk_raw умеет Московскую область, а не только Москву (#3505)
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled

This commit is contained in:
lekss361 2026-09-12 14:49:06 +00:00
parent c265e1f769
commit a8ba2d5d3c
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`: каждая строка Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`. `ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=region)`.
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато Отбор региона (source=cian). Адрес карточки Циана города НЕ содержит, зато для
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и Москвы начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри опознаётся байт-в-байт как раньше. Замер по проду (60 464 карточки): с округом
bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион 35 551, ВСЕ внутри bbox региона 77; без округа внутри bbox 17 576 (это
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox Московская область, регион 50); без округа вне bbox 7 337. Отдельно 212
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт карточек с адресом вида «Екатеринбург (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) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с Отбор Москвы (source=avito) по адресу НЕВОЗМОЖЕН: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335). карточки (замер: lat/lon/cadastral_number/geo_precision пусты у всех 50 335).
Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр. Поэтому для Авито работает ПРЕД-ГЕОКОД (`--geocode`), а не префиксный фильтр
для ЛЮБОГО целевого региона.
Два сигнала, и оба нужны ни один по отдельности не годится. Два сигнала, и оба нужны ни один по отдельности не годится.
@ -42,16 +54,16 @@ bbox региона 77; без округа внутри bbox — 17 576 (это
qc_geo=0 у 97% найденных. qc_geo=0 у 97% найденных.
Что куда едет: Что куда едет:
* регион 77 в `listings`, С координатами (geom есть сразу, radius-подбор * регион == `--region` в `listings`, С координатами (geom есть сразу,
аналогов работает без ожидания `geocode_missing`); radius-подбор аналогов работает без ожидания `geocode_missing`);
* регион 50 НЕ пишется, ждёт появления региона 50 в реестре; лежит не в * адрес разрешился, но регион ответа не совпал с `--region` не пишется,
воздухе, а строкой в `msk_raw.avito_geocode` (region_code=50) когда лежит не в воздухе, а строкой в `msk_raw.avito_geocode` прогон с другим
регион появится, прогон по этой полке уже не потребует внешних вызовов; `--region` подхватит её из кэша без единого внешнего вызова;
* адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33») * адрес не разрешился (ЖК без улицы, «Мкр-н имени В.Н. Махалина, 33»)
свой счётчик, карточка не пишется. свой счётчик, карточка не пишется.
`--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет `--allow-unfiltered` (без `--geocode`) остаётся прежним аварийным режимом: пишет
Москву вперемешку с областью и БЕЗ geom. Молча он по-прежнему не срабатывает. целевой регион вперемешку с прочими и БЕЗ geom. Молча он по-прежнему не срабатывает.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`, есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
@ -71,7 +83,7 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы. одинаковы.
Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и Отбор региона (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
@ -85,14 +97,19 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
второй город. Замер по 5 024 карточкам первого прохода: единственное значение второй город. Замер по 5 024 карточкам первого прохода: единственное значение
первого компонента «Москва». Новая Москва приходит как «Москва, пос. Птичное, первого компонента «Москва». Новая Москва приходит как «Москва, пос. Птичное,
», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты », то есть посёлок стоит вторым компонентом и регион 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 --dry-run
python -m app.tasks.msk_raw_import --limit 500 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
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
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000 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 # аварийный python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
""" """
@ -102,6 +119,7 @@ import argparse
import asyncio import asyncio
import logging import logging
import re import re
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import urlsplit from urllib.parse import urlsplit
@ -119,9 +137,11 @@ logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77 MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва" 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 OBLAST_REGION_CODE = 50
SUPPORTED_REGIONS = (MOSCOW_REGION_CODE, OBLAST_REGION_CODE)
DEFAULT_BATCH_SIZE = 500 DEFAULT_BATCH_SIZE = 500
# Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`. # Слаг города в `source_url` Авито: `https://www.avito.ru/<slug>/kvartiry/...`.
@ -427,14 +447,16 @@ class ImportCounters:
read: int = 0 read: int = 0
skipped_artifact: int = 0 skipped_artifact: int = 0
skipped_not_moscow: int = 0 # Карточка сама говорит про другой регион (префикс округа / поддомен
# Циана / компонент адреса не совпал с целевым `--region`).
skipped_not_target_region: int = 0
skipped_invalid: int = 0 skipped_invalid: int = 0
# Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Область — # Пред-геокод Авито: два РАЗНЫХ исхода, и смешивать их нельзя. Другой
# адрес разрешён, дом реальный, просто регион 50 (ждёт реестра). Не # регион — адрес разрешён, дом реальный, просто регион в ответе DaData не
# разрешён — DaData дома не нашла ИЛИ кончился бюджет вызовов; всплеск # совпал с целевым. Не разрешён — DaData дома не нашла ИЛИ кончился
# этого счётчика читается как «проверь квоту», а не «в Москве стало меньше # бюджет вызовов; всплеск этого счётчика читается как «проверь квоту», а
# домов». # не «в целевом регионе стало меньше домов».
skipped_oblast: int = 0 skipped_geo_other_region: int = 0
skipped_ungeocoded: int = 0 skipped_ungeocoded: int = 0
selected: int = 0 selected: int = 0
inserted: int = 0 inserted: int = 0
@ -461,8 +483,8 @@ class ImportCounters:
self.read self.read
== self.selected == self.selected
+ self.skipped_artifact + self.skipped_artifact
+ self.skipped_not_moscow + self.skipped_not_target_region
+ self.skipped_oblast + self.skipped_geo_other_region
+ self.skipped_ungeocoded + self.skipped_ungeocoded
+ self.skipped_invalid + 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 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: 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] == "Москва" 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: 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] == "Москва" return bool(parts) and parts[0] == "Москва"
# Источники, у которых город виден в самой карточке. Ключ отсутствует — def _by_address(fn: Callable[[str | None], bool]) -> Callable[[dict], bool]:
# источник про город молчит, и без пред-геокода писать его нельзя (avito). """Адаптер: фильтр по адресу → фильтр по всему payload'у (для реестра)."""
CITY_FILTERS = { return lambda payload: fn(payload.get("address"))
"cian": is_moscow_address,
"yandex": is_moscow_yandex_address,
"domclick": is_moscow_domclick_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, db: Session,
*, *,
source: str = "cian", source: str = "cian",
region: int = MOSCOW_REGION_CODE,
batch_size: int = DEFAULT_BATCH_SIZE, batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None, limit: int | None = None,
dry_run: bool = False, dry_run: bool = False,
@ -577,32 +653,44 @@ def import_msk_raw(
geocode_limit: int = DEFAULT_GEOCODE_LIMIT, geocode_limit: int = DEFAULT_GEOCODE_LIMIT,
) -> ImportCounters: ) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче.""" """Переливает сырьё `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] view = SOURCE_VIEWS[source]
counters = ImportCounters() counters = ImportCounters()
matcher = RealMatcherAdapter() matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit)) 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: if geocode and city_filter is not None:
# Город опознаётся даром и без ошибок — тратить на него внешнюю квоту # Регион опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем. # незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito") raise SystemExit(f"source={source}: --geocode нужен только для avito")
if city_filter is None and not geocode: if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области: # Без пред-геокода у Авито по-прежнему нечем отделить целевой регион от
# ни города в адресе, ни координат. Пишем только по явному разрешению. # прочих: ни города в адресе, ни координат. Пишем только по явному
# разрешению.
if not (dry_run or allow_unfiltered): if not (dry_run or allow_unfiltered):
raise SystemExit( raise SystemExit(
f"source={source}: адрес не содержит признака города, Москву от " f"source={source}: адрес не содержит признака города, регион "
"области не отличить. Нужен --geocode (штатный путь), " f"{region} от прочих не отличить. Нужен --geocode (штатный путь), "
"--allow-unfiltered (аварийный) или --dry-run." "--allow-unfiltered (аварийный) или --dry-run."
) )
logger.warning( logger.warning(
"source=%s: пред-геокод ВЫКЛЮЧЕН — фильтра по городу нет вовсе; " "source=%s region=%d: пред-геокод ВЫКЛЮЧЕН — фильтра по региону нет "
"строки лягут без geom и вперемешку с областью", "вовсе; строки лягут без geom и вперемешку с прочими регионами",
source, source,
region,
) )
if geocode and not dry_run: if geocode and not dry_run:
db.execute(text(_GEO_CACHE_DDL)) db.execute(text(_GEO_CACHE_DDL))
@ -618,16 +706,16 @@ def import_msk_raw(
if is_artifact_address(address): if is_artifact_address(address):
counters.skipped_artifact += 1 counters.skipped_artifact += 1
continue continue
if city_filter is not None and not city_filter(address): if city_filter is not None and not city_filter(payload):
counters.skipped_not_moscow += 1 counters.skipped_not_target_region += 1
continue continue
if geocode: if geocode:
point = points.get(geo_cache_key(address or "", avito_city_slug(payload))) point = points.get(geo_cache_key(address or "", avito_city_slug(payload)))
if point is None: if point is None:
counters.skipped_ungeocoded += 1 counters.skipped_ungeocoded += 1
continue continue
if point.region_code != MOSCOW_REGION_CODE: if point.region_code != region:
counters.skipped_oblast += 1 counters.skipped_geo_other_region += 1
continue continue
# Координаты кладём в КОПИЮ payload'а: исходную строку сырья не # Координаты кладём в КОПИЮ payload'а: исходную строку сырья не
# трогаем, пересбор корпуса от этого не зависит. geom появляется # трогаем, пересбор корпуса от этого не зависит. geom появляется
@ -649,19 +737,26 @@ def import_msk_raw(
if dry_run or not lots: if dry_run or not lots:
continue 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( inserted, updated = save_listings(
db, db,
lots, lots,
matcher=matcher, matcher=matcher,
region_code=MOSCOW_REGION_CODE, region_code=region,
city=MOSCOW_CITY, city=city,
) )
counters.inserted += inserted counters.inserted += inserted
counters.updated += updated counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info( 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, source,
region,
counters.read, counters.read,
counters.selected, counters.selected,
counters.written, counters.written,
@ -679,11 +774,12 @@ def import_msk_raw(
geocode_limit, geocode_limit,
) )
logger.info( logger.info(
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d " "msk_raw %s region=%d ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d " "(new=%d upd=%d, писатель пропустил=%d) | пропущено: не целевой регион=%d "
"область(50)=%d не разрешён=%d артефакт=%d невалидный payload=%d | " "геокод-другой-регион=%d не разрешён=%d артефакт=%d невалидный payload=%d | "
"геокод-вызовов=%d | сходится=%s", "геокод-вызовов=%d | сходится=%s",
source, source,
region,
" (dry-run)" if dry_run else "", " (dry-run)" if dry_run else "",
counters.read, counters.read,
counters.selected, counters.selected,
@ -691,8 +787,8 @@ def import_msk_raw(
counters.inserted, counters.inserted,
counters.updated, counters.updated,
counters.writer_skipped if not dry_run else 0, counters.writer_skipped if not dry_run else 0,
counters.skipped_not_moscow, counters.skipped_not_target_region,
counters.skipped_oblast, counters.skipped_geo_other_region,
counters.skipped_ungeocoded, counters.skipped_ungeocoded,
counters.skipped_artifact, counters.skipped_artifact,
counters.skipped_invalid, counters.skipped_invalid,
@ -707,21 +803,30 @@ def main() -> None:
level=logging.INFO, level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s", 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("--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("--batch-size", type=int, default=DEFAULT_BATCH_SIZE)
parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек") parser.add_argument("--limit", type=int, default=None, help="обработать не больше N карточек")
parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики") parser.add_argument("--dry-run", action="store_true", help="ничего не пишет, только счётчики")
parser.add_argument( parser.add_argument(
"--allow-unfiltered", "--allow-unfiltered",
action="store_true", action="store_true",
help="АВАРИЙНЫЙ режим: писать avito без фильтра по городу и без geom", help="АВАРИЙНЫЙ режим: писать avito без фильтра по региону и без geom",
) )
parser.add_argument( parser.add_argument(
"--geocode", "--geocode",
action="store_true", action="store_true",
help="штатный путь для avito: пред-геокод адреса (слаг + DaData), " help="штатный путь для avito: пред-геокод адреса (слаг + DaData), "
"в listings уходит только регион 77, область откладывается", "в listings уходит только целевой регион (--region), прочее пропускается",
) )
parser.add_argument( parser.add_argument(
"--geocode-limit", "--geocode-limit",
@ -737,6 +842,7 @@ def main() -> None:
import_msk_raw( import_msk_raw(
db, db,
source=args.source, source=args.source,
region=args.region,
batch_size=args.batch_size, batch_size=args.batch_size,
limit=args.limit, limit=args.limit,
dry_run=args.dry_run, 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 [lot.source_id for lot in captured[0]["lots"]] == ["1", "4"]
assert counters.read == 5 assert counters.read == 5
assert counters.selected == 2 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_artifact == 1
assert counters.skipped_invalid == 1 assert counters.skipped_invalid == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным" 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 _FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
) )
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] 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.skipped_ungeocoded == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным" 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) counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"] 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: 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: def test_domclick_view_is_wired_into_source_views() -> None:
assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" 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