Импорт сырья 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
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:
parent
c265e1f769
commit
a8ba2d5d3c
2 changed files with 285 additions and 65 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue