feat(msk): импорт сырья по Москве в listings и region-aware геокодирование

Три куска, каждый нужен, чтобы поиск и оценка по Москве заработали end-to-end.

1. Импортёр msk_raw -> listings (app/tasks/msk_raw_import.py).

Переиспользует штатный save_listings из кита: писатель уже параметризован
регионом, свой не нужен. payload в msk_raw — сериализованный ScrapedLot
один в один, так что импорт сводится к сборке модели и вызову писателя.

Москва отбирается по префиксу административного округа в адресе, а не по
bbox. Причина: адрес Циан не содержит города, а границы региона 77
захватывают ближний пояс области. Замер по проду: с округом 35 552, все
внутри bbox 77; без округа внутри bbox 17 576 — это область. Отдельно
отсекаются 212 карточек с адресом «Екатеринбург (Cian)», артефакт парсера.

listing_segment ПЕРЕСЧИТЫВАЕТСЯ перед записью, а не копируется из payload.
Кит ставит novostroyki по одному наличию offer.newbuilding.id. Замер по
всем 60 464 карточкам: is_from_developer=true у НУЛЯ, false у 29 000,
отсутствует у 31 464. Застройщик не продаёт ни одной карточки корпуса.
В проде есть гвард (estimator.py): в аналоги идут строки только с
listing_segment IS NULL или 'vtorichka' — копирование метки как есть
выбросило бы 29 000 строк из подбора.

Авито импортируется только с явным --allow-unfiltered: в его адресе нет ни
города, ни округа, координат нет ни у одной из 50 335 карточек, отличить
область от Москвы нечем.

Прогон на проде: прочитано 60 464, записано 35 552, все с геометрией,
35 182 привязаны к дому, создано 10 960 домов. Повторный проход строки не
дублирует — idempotency на dedup_hash, проверено.

2. Подсказки адреса стали региональными (geocoder.suggest, api/v1/geocode).

Раньше suggest вообще не принимал регион: DaData звалась с жёстким
region='Свердловская', Nominatim — с viewbox 66-го и bounded=1. Московский
адрес давал ПУСТОЙ список молча, без ошибки; в коде это уже было описано
как известный баг. Механику по регионам переиспользовали из geocode(),
вторую не писали. Кадастровый тир для не-66 не зовётся: он на ЕКБ-данных.

3. Оценка перестала геокодировать Москву свердловским скоупом (estimator).

geocode() звалась без региона, то есть с дефолтом 66, и московский адрес
возвращал бы пустую оценку с причиной address_not_geocoded даже с рабочими
подсказками. Регион запроса определяется по координатам через реестр, затем
по city_hint, затем дефолт. Fast-path клиентских координат стал
региононезависимым: OBLAST66_BBOX и REGIONS[66].bbox_region совпадают
байт-в-байт, поэтому для 66 поведение прежнее, добавились координаты Москвы.

Регресс-нейтральность по Свердловской области — главный критерий всех трёх
кусков. Тесты: 1160 passed по затронутым областям.

Известные ограничения. Границы 77 захватывают ближний пояс области, Химки
резолвятся в Москву. У региона 77 нет ни одного тира обогащения, оценка
поедет на аналогах и сделках. Ценовая полоса по Москве одна на весь город.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
This commit is contained in:
bot-backend 2026-09-10 18:50:37 +03:00
parent 8fcef103c2
commit b1727ca39c
9 changed files with 1010 additions and 37 deletions

View file

@ -81,9 +81,20 @@ async def suggest_addresses(
),
),
] = None,
region_code: Annotated[
int,
Query(
description=(
"Регион покрытия (#3051). Дефолт 66 — Свердловская область, прежнее "
"поведение для существующих клиентов. 77 — Москва: без него DaData "
"и Nominatim получают свердловский hard-констрейнт и молча "
"возвращают ПУСТО на московском адресе."
),
),
] = 66,
) -> SuggestResponse:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path).
"""Автокомплит адресов в регионе `region_code` (дефолт 66 — Свердловская область;
ЕКБ основной трафик, остаётся быстрым fast-path).
Используется в EstimateForm для подсказок пока пользователь печатает.
Bounded viewbox генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
@ -93,8 +104,13 @@ async def suggest_addresses(
/api/v1/geocode/suggest?q=Малышева
/api/v1/geocode/suggest?q=Цвиллинга # → пусто, такой улицы в ЕКБ нет
/api/v1/geocode/suggest?q=Ленина+1&city_hint=Нижний+Тагил
/api/v1/geocode/suggest?q=Тверская+6&region_code=77 # Москва
"""
items = await suggest(q, db=db, limit=limit, city_hint=city_hint)
try:
items = await suggest(q, db=db, limit=limit, city_hint=city_hint, region_code=region_code)
except ValueError as exc:
# Регион вне реестра покрытия — 422, а не 500: это ошибка ввода клиента.
raise HTTPException(status_code=422, detail=str(exc)) from exc
return SuggestResponse(
items=[
SuggestItem(

View file

@ -77,7 +77,6 @@ from app.services.geocoder import (
SVERDLOVSK_OBLAST_CITIES,
GeocodeResult,
geocode,
is_within_oblast66_bbox,
)
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house
@ -4084,6 +4083,28 @@ def _price_from_inputs(
# ── Public ───────────────────────────────────────────────────────────────────
def _request_region_code(payload: TradeInEstimateInput) -> int:
"""Регион ЗАПРОСА на оценку (#3051) — чем ограничивать геокодер.
Приоритет: координаты payload (фронт уже разрезолвил адрес
автокомплитом/картой самый надёжный источник) `city_hint`, если это
узнаваемое имя города какого-то региона реестра дефолт 66.
Регресс-нейтральность 66: точка вне охвата, неизвестный/пустой city_hint и
любой свердловский city_hint одинаково дают 66 ровно прежнее поведение
(geocode() и так ходил с region_code=66). Отличие есть ТОЛЬКО для московских
данных, которые раньше молча отвергались провайдерами.
"""
if payload.lat is not None and payload.lon is not None:
region = regions_mod.region_for_point(payload.lat, payload.lon)
if region is not None:
return region.code
region = regions_mod.region_by_city(payload.city_hint)
if region is not None:
return region.code
return regions_mod.DEFAULT_REGION_CODE
async def estimate_quality(
payload: TradeInEstimateInput,
db: Session,
@ -4141,10 +4162,15 @@ async def estimate_quality(
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
# not a correctness gate — narrower fast-path just meant more oblast requests fell
# through to geocode() (still correct, just slower).
# #3051: проверка стала региононезависимой — координаты принимаются, если
# попадают в bbox_region ЛЮБОГО региона реестра (regions.region_for_point).
# Для обл.66 это тот же bbox (OBLAST66_BBOX == REGIONS[66].bbox_region), т.е.
# поведение байт-в-байт прежнее; добавились только координаты Москвы,
# которые раньше зря платили round-trip в geocode() со свердловской рамкой.
if (
payload.lat is not None
and payload.lon is not None
and is_within_oblast66_bbox(payload.lat, payload.lon)
and regions_mod.region_for_point(payload.lat, payload.lon) is not None
):
geo = GeocodeResult(
lat=payload.lat,
@ -4163,8 +4189,17 @@ async def estimate_quality(
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
# честно сигнализирует об этом ниже.
# #3051: региональный скоуп геокодера. Оба тира geocode() ограничены
# регионом ЖЁСТКО (DaData locations.region, Nominatim viewbox+bounded=1),
# поэтому без region_code московский адрес возвращал None → пустая оценка
# с reason='address_not_geocoded'. Для 66 значение прежнее (дефолт).
geo = await _with_budget(
geocode(payload.address, db, city_hint=payload.city_hint),
geocode(
payload.address,
db,
city_hint=payload.city_hint,
region_code=_request_region_code(payload),
),
settings.estimate_geocode_budget_s,
label="geocode",
)

View file

@ -757,6 +757,30 @@ def _region_viewbox(region: Region) -> str:
return f"{lon_min},{lat_max},{lon_max},{lat_min}"
def _viewbox_for_region(region_code: int) -> str:
"""Nominatim `viewbox` по коду региона — ЕДИНАЯ точка для всех тиров.
`region_code=66` литеральная `OBLAST66_VIEWBOX["viewbox"]`: значение
историческое, из bbox не выводится, поэтому byte-identical прежнему
поведению. Прочие регионы рамка из реестра (`_region_viewbox`).
"""
if region_code == 66:
return OBLAST66_VIEWBOX["viewbox"]
return _region_viewbox(_ALL_REGIONS[region_code])
def _region_default_city(region_code: int) -> str:
"""Главный город региона — текстовый суффикс запроса, когда город не назван.
`region_code=66` литеральный "Екатеринбург" (byte-identical dual-query
#2580/C2). Прочие — `canonical_city` реестра, иначе `city_token` с заглавной.
"""
if region_code == 66:
return "Екатеринбург"
region = _ALL_REGIONS[region_code]
return region.canonical_city or region.city_token.capitalize()
async def _nominatim_query(
client: httpx.AsyncClient, address: str, region_code: int = 66
) -> dict | None:
@ -775,7 +799,7 @@ async def _nominatim_query(
поведению (те же bbox-значения и та же viewbox-строка).
"""
region = _ALL_REGIONS[region_code]
viewbox = OBLAST66_VIEWBOX["viewbox"] if region_code == 66 else _region_viewbox(region)
viewbox = _viewbox_for_region(region_code)
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -933,8 +957,24 @@ class GeocodeSuggestion:
# 'locality' вместо 'city' — consistent с Nominatim-веткой).
_DADATA_KIND_MAP = {"house": "house", "street": "street", "city": "locality"}
# region_code → значение поля DaData `region` (БЕЗ типа: «Свердловская», а не
# «Свердловская область» — тип лежит отдельно в `region_type`). Реестр регионов
# хранит человекочитаемое имя С типом, для hard-констрейнта оно не годится,
# поэтому отдельная карта — по образцу `_REGION_STATE_MARKERS` для Nominatim.
_DADATA_REGION_NAMES: dict[int, str] = {66: SVERDLOVSK_OBLAST_REGION, 77: "Москва"}
async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
def _dadata_region_name(region_code: int) -> str:
"""Имя региона для hard-констрейнта DaData. Неизвестный код → ValueError."""
try:
return _DADATA_REGION_NAMES[region_code]
except KeyError as exc:
raise ValueError(f"dadata suggest: unknown region_code={region_code!r}") from exc
async def _dadata_suggest(
query: str, limit: int = 8, region_code: int = 66
) -> list[GeocodeSuggestion]:
"""Обёртка над `dadata.suggest_addresses` — конвертит в GeocodeSuggestion.
Дроп candidate'ов без координат (DaData возвращает их для широких categories
@ -945,9 +985,8 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
внутри `suggest_addresses`), а не один город ЕКБ иначе Нижний Тагил/
Серов/etc никогда не появились бы в подсказках.
"""
raw = await dadata.suggest_addresses(
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
)
region_name = _dadata_region_name(region_code)
raw = await dadata.suggest_addresses(query, limit=limit, city=None, region=region_name)
if not raw:
# Region-констрейнт — hard-filter: неверное значение схлопывает выдачу в
# 0 БЕЗ ошибки (так и жил баг «Свердловская область» → 0 подсказок).
@ -957,7 +996,7 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
"dadata suggest: 0 кандидатов для %r при region=%r"
"проверь, что констрейнт совпадает с полем DaData `region` (без типа)",
query[:60],
SVERDLOVSK_OBLAST_REGION,
region_name,
)
out: list[GeocodeSuggestion] = []
for s in raw:
@ -980,8 +1019,14 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
return out
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
async def _nominatim_query_multi(
client: httpx.AsyncClient, query: str, limit: int, region_code: int = 66
) -> list[dict]:
"""Один Nominatim search с рамкой региона `region_code`. Возвращает up to N items.
`region_code=66` (дефолт) byte-identical прежнему поведению: та же
viewbox-строка `OBLAST66_VIEWBOX` (см. `_viewbox_for_region`).
"""
await _nominatim_throttle()
response = await client.get(
"https://nominatim.openstreetmap.org/search",
@ -990,7 +1035,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
"format": "json",
"limit": str(limit),
"countrycodes": "ru",
"viewbox": OBLAST66_VIEWBOX["viewbox"],
"viewbox": _viewbox_for_region(region_code),
"bounded": "1",
"addressdetails": "1",
},
@ -1027,7 +1072,12 @@ def _dedupe_nominatim_items(*item_lists: list[dict]) -> list[dict]:
async def _nominatim_query_city_aware(
client: httpx.AsyncClient, query: str, city: str | None, city_specified: bool, limit: int
client: httpx.AsyncClient,
query: str,
city: str | None,
city_specified: bool,
limit: int,
region_code: int = 66,
) -> list[dict]:
"""Строит и выполняет Nominatim-запрос(ы) с учётом того, известен ли город.
@ -1050,18 +1100,25 @@ async def _nominatim_query_city_aware(
ЕКБ-кандидаты идут первыми (majority-случай, привычный порядок).
"""
if city:
return await _nominatim_query_multi(client, f"{query}, {city}", limit)
return await _nominatim_query_multi(
client, f"{query}, {city}", limit, region_code=region_code
)
if city_specified:
return await _nominatim_query_multi(client, query, limit)
ekb_data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
bare_data = await _nominatim_query_multi(client, query, limit)
return _dedupe_nominatim_items(ekb_data, bare_data)[:limit]
return await _nominatim_query_multi(client, query, limit, region_code=region_code)
# Город неизвестен — dual-query с суффиксом главного города региона
# (66 → "Екатеринбург", byte-identical; прочие — см. `_region_default_city`).
default_city = _region_default_city(region_code)
city_data = await _nominatim_query_multi(
client, f"{query}, {default_city}", limit, region_code=region_code
)
bare_data = await _nominatim_query_multi(client, query, limit, region_code=region_code)
return _dedupe_nominatim_items(city_data, bare_data)[:limit]
# reraise=True — см. комментарий у `_nominatim_lookup` (GlitchTip RetryError-шум).
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4), reraise=True)
async def _nominatim_suggest(
query: str, limit: int = 8, city_hint: str | None = None
query: str, limit: int = 8, city_hint: str | None = None, region_code: int = 66
) -> list[GeocodeSuggestion]:
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда оригинальный
запрос ничего не находит).
@ -1079,17 +1136,26 @@ async def _nominatim_suggest(
"Accept": "application/json",
"Accept-Language": "ru,en;q=0.8",
}
city, city_specified = _resolve_city_for_geocode(query, city_hint)
city, city_specified = _resolve_city_for_geocode(query, city_hint, region_code)
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
# Tier 1: оригинальный query
data = await _nominatim_query_city_aware(client, query, city, city_specified, limit)
data = await _nominatim_query_city_aware(
client, query, city, city_specified, limit, region_code=region_code
)
# Tier 2: typo-варианты если оригинал пустой
if not data:
for variant in _typo_variants(query, limit=3):
variant_city, variant_specified = _resolve_city_for_geocode(variant, city_hint)
variant_city, variant_specified = _resolve_city_for_geocode(
variant, city_hint, region_code
)
data = await _nominatim_query_city_aware(
client, variant, variant_city, variant_specified, limit
client,
variant,
variant_city,
variant_specified,
limit,
region_code=region_code,
)
if data:
logger.info("nominatim suggest typo-fixed: %s%s", query, variant)
@ -1765,7 +1831,11 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
async def suggest(
query: str, db: Session | None = None, limit: int = 8, city_hint: str | None = None
query: str,
db: Session | None = None,
limit: int = 8,
city_hint: str | None = None,
region_code: int = 66,
) -> list[GeocodeSuggestion]:
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
остаётся быстрым fast-path). Cadastral FDW DaData Nominatim [].
@ -1779,10 +1849,22 @@ async def suggest(
(#2593: Yandex Geocoder, который был primary external provider до DaData,
удалён). DaData region-constraint уже охватывает всю область (не только
ЕКБ) city_hint ей не нужен.
region_code: регион покрытия (дефолт 66, #3051) — какой регион уходит в
hard-констрейнты провайдеров: DaData `region` (`_dadata_region_name`) и
Nominatim `viewbox`+bounded (`_viewbox_for_region`). БЕЗ него московский
адрес молча схлопывался в пустой список: оба констрейнта ФИЛЬТРЫ, а не
boost, и «не тот регион» неотличимо от «адрес не найден». Локальные
ЕКБ-тиры (кадастр) для region_code != 66 пропускаются целиком данных
по другим регионам в FDW физически нет. Дефолт byte-identical
прежнему поведению по Свердловской области.
Без кэша (дешёво, провайдеры толерируют автокомплит-запросы).
"""
if not query or len(query.strip()) < 2:
return []
try:
_ALL_REGIONS[region_code]
except KeyError as exc:
raise ValueError(f"suggest: unknown region_code={region_code!r}") from exc
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
# EKB-only fail-closed гейт (#2582, было #11) — пропускаем, если query явно
@ -1792,7 +1874,11 @@ async def suggest(
# иначе хинт мёртвый параметр для этого тира, см. `_ekb_local_tiers_allowed`
# и `geocode()` ниже — тот же гейт). Внешние тиры (2/3 ниже) не гейтим —
# они уже oblast-aware.
if db is not None and _ekb_local_tiers_allowed(query, city_hint):
# #3051: `region_code != 66` закрывает кадастровый тир ДО `_ekb_local_tiers_allowed`
# — gendesign_cad_buildings содержит только ЕКБ, звать его для Москвы значит
# платить FDW-round-trip ради гарантированного нуля (тот же гейт в
# `_geocode_resolve`; сигнатуру `_ekb_local_tiers_allowed` умышленно не трогаем).
if db is not None and region_code == 66 and _ekb_local_tiers_allowed(query, city_hint):
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
parsed = _parse_street_house(query.strip())
@ -1810,7 +1896,7 @@ async def suggest(
# лучший fit для РФ адресов.
if settings.dadata_api_token:
try:
dadata_results = await _dadata_suggest(query, limit)
dadata_results = await _dadata_suggest(query, limit, region_code)
if dadata_results:
return dadata_results
except Exception:
@ -1818,7 +1904,7 @@ async def suggest(
# Tier 3: Nominatim (последний fallback — OSM, без ключа)
try:
return await _nominatim_suggest(query, limit, city_hint=city_hint)
return await _nominatim_suggest(query, limit, city_hint=city_hint, region_code=region_code)
except Exception:
logger.exception("nominatim suggest failed")
return []

View file

@ -0,0 +1,322 @@
"""Импорт московского сырья (`msk_raw.*_latest`) в `listings`.
Сырьё собрано отдельным коллектором и лежит в прод-схеме `msk_raw`: каждая строка
несёт `payload` сериализованный `ScrapedLot` один в один (те же 54 ключа, что и
поля модели, см. `scraper_kit/base.py`). Свой писатель поэтому не нужен: собираем
`ScrapedLot(**payload)` и отдаём в штатный `save_listings(..., region_code=77)`.
Отбор Москвы (source=cian). Адрес карточки Циана города НЕ содержит, зато
начинается с округа: «ЦАО, ...», «СВАО, ...». По этому префиксу Москва и
опознаётся. Замер по проду (60 464 карточки): с округом 35 551, ВСЕ внутри
bbox региона 77; без округа внутри bbox 17 576 (это Московская область, регион
50, которого в реестре ещё нет, в этот импорт не берём); без округа вне bbox
7 337. Отдельно 212 карточек с адресом вида «Екатеринбург (Cian)» артефакт
парсера, считаются своим счётчиком, чтобы не растворяться в «не Москва».
Отбор Москвы (source=avito) НЕВОЗМОЖЕН по адресу: у Авито адрес голая улица с
домом («Варшавское ш.,62к1»), ни города, ни округа, и координат нет НИ У ОДНОЙ
карточки. Поэтому:
* префиксный фильтр к Авито не применяется он отбросил бы 100% строк;
* запись Авито требует явного `--allow-unfiltered`: молча залить в регион 77
вперемешку Москву и область хуже, чем не залить ничего;
* строки Авито лягут БЕЗ geom (lat/lon пусты) они не попадут в radius-подбор
аналогов estimator'а, пока их не догеокодит `geocode_missing`.
Пересчёт `listing_segment` (пункт, ради которого нельзя копировать payload как
есть). Кит ставит 'novostroyki' по одному лишь наличию `offer.newbuilding.id`,
то есть по ссылке на ЖК, а не по продаже застройщиком. Замер по всем 60 464:
`raw_payload.is_from_developer` = true у НУЛЯ карточек, false у 29 000,
отсутствует у 31 464 застройщик в этом корпусе не продаёт ничего, это вся
вторичка. В estimator'е стоит гвард (`estimator.py:5992-5995`): в аналоги идут
только строки с `listing_segment IS NULL` или 'vtorichka'. Скопируй мы метку
кита 29 000 карточек выпали бы из подбора. Поэтому метка считается заново:
is_from_developer is True 'novostroyki', иначе 'vtorichka'.
Идемпотентность на стороне `save_listings`: он делает upsert
`ON CONFLICT (dedup_hash) DO UPDATE` плюс reconcile-UPDATE по
`(source, source_id)` на случай дрейфа хеша. `dedup_hash` = sha256(source +
source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), цена в ключ не
входит. Повторный прогон поэтому обновляет те же строки, а не плодит дубли;
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы.
Запуск:
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 avito --allow-unfiltered
"""
from __future__ import annotations
import argparse
import logging
import re
from dataclasses import dataclass
from pydantic import ValidationError
from scraper_kit.base import ScrapedLot, save_listings
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.scraper_adapters import RealMatcherAdapter
logger = logging.getLogger(__name__)
MOSCOW_REGION_CODE = 77
MOSCOW_CITY = "Москва"
DEFAULT_BATCH_SIZE = 500
# Префиксы административных округов Москвы — единственный признак города в адресе
# карточки Циана (сам город в адрес не попадает).
MOSCOW_OKRUGS = (
"ЦАО",
"САО",
"СВАО",
"ВАО",
"ЮВАО",
"ЮАО",
"ЮЗАО",
"ЗАО",
"СЗАО",
"ЗелАО",
"НАО",
"ТАО",
)
# Lookahead вместо \b: следом за округом идёт запятая/пробел, но НЕ буква — иначе
# «ЗАО» матчило бы начало гипотетического «ЗАОзёрная».
_MOSCOW_OKRUG_RE = re.compile(
r"^(?:" + "|".join(MOSCOW_OKRUGS) + r")(?![А-Яа-яЁёA-Za-z])",
)
# Артефакт парсера: адрес вида «Екатеринбург (Cian)» в московском корпусе.
_ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
# Вью-источники. Только whitelist: имя подставляется в SQL текстом, параметром
# идентификатор не передать.
SOURCE_VIEWS = {
"cian": "msk_raw.cian_latest",
"avito": "msk_raw.avito_latest",
}
_PAGE_SQL = """
SELECT id, payload
FROM {view}
WHERE id > :after
ORDER BY id
LIMIT :limit
"""
@dataclass
class ImportCounters:
"""Разбор прогона. Числа обязаны сходиться, см. `check()`."""
read: int = 0
skipped_artifact: int = 0
skipped_not_moscow: int = 0
skipped_invalid: int = 0
selected: int = 0
inserted: int = 0
updated: int = 0
@property
def written(self) -> int:
return self.inserted + self.updated
@property
def writer_skipped(self) -> int:
"""Отобрано, но писатель строку не тронул.
`save_listings` возвращает только (inserted, updated); неизменные строки,
уже виденные сегодня, он пропускает своим гейтом (#2992). Остаток честно
показываем отдельно, а не растворяем в «записано».
"""
return self.selected - self.written
def check(self) -> bool:
return (
self.read
== self.selected
+ self.skipped_artifact
+ self.skipped_not_moscow
+ self.skipped_invalid
)
def is_artifact_address(address: str | None) -> bool:
"""Адрес чужого города в московском корпусе (артефакт парсера)."""
return bool(address) and _ARTIFACT_RE.search(address) is not None
def is_moscow_address(address: str | None) -> bool:
"""Москва опознаётся префиксом административного округа."""
if not address:
return False
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def recompute_listing_segment(payload: dict) -> str:
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
Обоснование в докстринге модуля: метка кита означает лишь ссылку на ЖК.
"""
raw = payload.get("raw_payload") or {}
if not isinstance(raw, dict):
return "vtorichka"
return "novostroyki" if raw.get("is_from_developer") is True else "vtorichka"
def build_lot(payload: dict) -> ScrapedLot:
"""`payload` → `ScrapedLot` с пересчитанным сегментом.
Ключи, которых в модели нет, отбрасываем явно (по `model_fields`), а не
полагаемся на настройку extra у pydantic-модели.
"""
known = {k: v for k, v in payload.items() if k in ScrapedLot.model_fields}
known["listing_segment"] = recompute_listing_segment(payload)
return ScrapedLot(**known)
def _iter_pages(db: Session, view: str, *, batch_size: int, limit: int | None):
"""Keyset-пагинация по `id` — весь корпус в память не тянем."""
after = 0
taken = 0
sql = text(_PAGE_SQL.format(view=view))
while True:
page_size = batch_size
if limit is not None:
page_size = min(batch_size, limit - taken)
if page_size <= 0:
return
rows = db.execute(sql, {"after": after, "limit": page_size}).mappings().all()
if not rows:
return
after = rows[-1]["id"]
taken += len(rows)
yield rows
def import_msk_raw(
db: Session,
*,
source: str = "cian",
batch_size: int = DEFAULT_BATCH_SIZE,
limit: int | None = None,
dry_run: bool = False,
allow_unfiltered: bool = False,
) -> ImportCounters:
"""Переливает сырьё `msk_raw` в `listings`. Коммит — на каждом батче."""
view = SOURCE_VIEWS[source]
counters = ImportCounters()
matcher = RealMatcherAdapter()
filter_by_okrug = source == "cian"
if not filter_by_okrug:
# У Авито в адресе нет ни города, ни округа, и нет координат — отсечь
# область нечем. Пишем только по явному разрешению.
if not (dry_run or allow_unfiltered):
raise SystemExit(
f"source={source}: адрес не содержит признака города, Москву от "
"области не отличить. Нужен --allow-unfiltered (или --dry-run)."
)
logger.warning(
"source=%s: фильтр по округу НЕ применяется (в адресе нет города); "
"строки лягут без geom — координат нет ни у одной карточки",
source,
)
for rows in _iter_pages(db, view, batch_size=batch_size, limit=limit):
lots: list[ScrapedLot] = []
for row in rows:
counters.read += 1
payload = row["payload"] or {}
address = payload.get("address")
if is_artifact_address(address):
counters.skipped_artifact += 1
continue
if filter_by_okrug and not is_moscow_address(address):
counters.skipped_not_moscow += 1
continue
try:
lots.append(build_lot(payload))
except ValidationError as exc:
counters.skipped_invalid += 1
logger.warning("msk_raw id=%s не собрался в ScrapedLot: %s", row["id"], exc)
counters.selected += len(lots)
if dry_run or not lots:
continue
inserted, updated = save_listings(
db,
lots,
matcher=matcher,
region_code=MOSCOW_REGION_CODE,
city=MOSCOW_CITY,
)
counters.inserted += inserted
counters.updated += updated
db.commit() # батч зафиксирован — обрыв не отматывает всю работу
logger.info(
"msk_raw %s: прочитано=%d отобрано=%d записано=%d (new=%d upd=%d)",
source,
counters.read,
counters.selected,
counters.written,
counters.inserted,
counters.updated,
)
logger.info(
"msk_raw %s ИТОГ%s: прочитано=%d отобрано=%d записано=%d "
"(new=%d upd=%d, писатель пропустил=%d) | пропущено: не Москва=%d "
"артефакт=%d невалидный payload=%d | сходится=%s",
source,
" (dry-run)" if dry_run else "",
counters.read,
counters.selected,
counters.written,
counters.inserted,
counters.updated,
counters.writer_skipped if not dry_run else 0,
counters.skipped_not_moscow,
counters.skipped_artifact,
counters.skipped_invalid,
counters.check(),
)
return counters
def main() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(description="Импорт сырья msk_raw в listings (регион 77)")
parser.add_argument("--source", choices=sorted(SOURCE_VIEWS), default="cian")
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)",
)
args = parser.parse_args()
db = SessionLocal()
try:
import_msk_raw(
db,
source=args.source,
batch_size=args.batch_size,
limit=args.limit,
dry_run=args.dry_run,
allow_unfiltered=args.allow_unfiltered,
)
finally:
db.close()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,132 @@
"""#3051: скоуп региона в estimate — fast-path и вызов geocode().
ПОЧЕМУ ЭТО ТЕСТ. Оба тира geocode() ограничены регионом ЖЁСТКИМ фильтром
(DaData `locations.region`, Nominatim `viewbox`+`bounded=1`), а не бустом:
промах региона не даёт ошибки выдача схлопывается в пустую, и estimate
возвращает `_empty_estimate(reason='address_not_geocoded')`, неотличимую от
«такого адреса нет». Значит проверять надо не результат, а ЧТО именно уходит
в geocode(). Второй предмет регресс-нейтральность 66: без московских данных
поведение обязано быть прежним.
Сеть и БД не дёргаем: geocode и _empty_estimate мокаются, db MagicMock.
"""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
import pytest
from app.schemas.trade_in import TradeInEstimateInput
from app.services.estimator import _request_region_code, estimate_quality
pytestmark = pytest.mark.anyio
# Тверская 6 (Москва) и Малышева 30 (Екатеринбург) — точки внутри bbox_region
# соответствующих регионов реестра.
MSK = (55.7605, 37.6100)
EKB = (56.8380, 60.6000)
def _payload(**kw) -> TradeInEstimateInput:
base = {"address": "Тверская 6", "area_m2": 50.0, "rooms": 2}
base.update(kw)
return TradeInEstimateInput(**base)
# ── _request_region_code: приоритет координаты → city_hint → 66 ──────────────
def test_region_from_moscow_coords() -> None:
assert _request_region_code(_payload(lat=MSK[0], lon=MSK[1])) == 77
def test_region_from_ekb_coords() -> None:
assert _request_region_code(_payload(lat=EKB[0], lon=EKB[1])) == 66
def test_region_without_coords_defaults_to_66() -> None:
"""Нет координат и нет узнаваемого города — прежний дефолт 66."""
assert _request_region_code(_payload()) == 66
assert _request_region_code(_payload(city_hint="Урюпинск")) == 66
def test_region_from_city_hint() -> None:
assert _request_region_code(_payload(city_hint="Москва")) == 77
assert _request_region_code(_payload(city_hint="Нижний Тагил")) == 66
def test_region_coords_outside_any_region_default_66() -> None:
"""Точка вне охвата (Сочи) → 66, а НЕ None: NULL обнулил бы фильтр."""
assert _request_region_code(_payload(lat=43.6, lon=39.7)) == 66
# ── fast-path клиентских координат: региононезависимость ─────────────────────
async def _run_estimate(payload: TradeInEstimateInput):
"""estimate_quality до первой развилки: geocode → None → _empty_estimate.
Если fast-path принял клиентские координаты, функция идёт дальше, в счёт по
БД, и спотыкается о MagicMock-сессию это ожидаемо и подавляется: предмет
проверки здесь ровно один, БЫЛ ли вызван geocode() и с каким регионом.
"""
geocode_mock = AsyncMock(return_value=None)
empty_mock = MagicMock(return_value="EMPTY")
result = None
with (
patch("app.services.estimator.geocode", new=geocode_mock),
patch("app.services.estimator._empty_estimate", new=empty_mock),
):
try:
result = await estimate_quality(payload, MagicMock())
except Exception: # дальше по функции живая БД, см. докстринг
pass
return geocode_mock, empty_mock, result
async def test_fast_path_accepts_moscow_coords() -> None:
"""Московские координаты принимаются как клиентские — geocode не зовём."""
geocode_mock, _, _ = await _run_estimate(_payload(lat=MSK[0], lon=MSK[1]))
geocode_mock.assert_not_awaited()
async def test_fast_path_accepts_ekb_coords_unchanged() -> None:
"""Регресс 66: координаты области по-прежнему минуют geocode()."""
geocode_mock, _, _ = await _run_estimate(_payload(lat=EKB[0], lon=EKB[1]))
geocode_mock.assert_not_awaited()
async def test_fast_path_ignores_coords_outside_coverage() -> None:
"""Точка вне охвата (Сочи) — как и раньше, честный geocode()."""
geocode_mock, _, _ = await _run_estimate(_payload(lat=43.6, lon=39.7))
geocode_mock.assert_awaited_once()
# ── geocode() получает регион запроса ────────────────────────────────────────
async def test_geocode_gets_region_77_for_moscow() -> None:
geocode_mock, empty_mock, result = await _run_estimate(
_payload(address="Тверская 6", city_hint="Москва")
)
assert geocode_mock.await_args.kwargs["region_code"] == 77
assert result == "EMPTY"
assert empty_mock.call_args.kwargs["reason"] == "address_not_geocoded"
async def test_geocode_gets_region_66_for_ekb() -> None:
geocode_mock, _, _ = await _run_estimate(
_payload(address="Малышева 30", city_hint="Екатеринбург")
)
assert geocode_mock.await_args.kwargs["region_code"] == 66
async def test_geocode_default_region_66_without_hints() -> None:
"""Регресс-нейтральность: без координат и city_hint — прежний скоуп 66."""
geocode_mock, _, _ = await _run_estimate(_payload(address="Малышева 30"))
assert geocode_mock.await_args.kwargs["region_code"] == 66
assert geocode_mock.await_args.kwargs["city_hint"] is None

View file

@ -0,0 +1,181 @@
"""#3051: `suggest()` умеет регион — и по умолчанию остаётся свердловским.
ПОЧЕМУ ЭТО ВООБЩЕ ТЕСТ. Оба внешних тира подсказок ограничены регионом
ЖЁСТКИМ фильтром, а не бустом: DaData `locations.region` и Nominatim
`viewbox`+`bounded=1`. Промах региона не даёт ни ошибки, ни warning'а от
провайдера выдача схлопывается в ПУСТОЙ список, неотличимый от «такого
адреса нет». Ровно так московский адрес молча возвращал ноль подсказок при
свердловском констрейнте. Значит проверять надо не результат, а то, ЧТО
именно уходит провайдеру.
Второй, более важный предмет проверки регресс-нейтральность: вызов без
`region_code` обязан слать провайдерам те же самые значения, что и до правки.
Сеть не дёргаем: тиры мокаются по образцу `test_geocoder_city_hint`.
"""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
import pytest
from app.services.geocoder import (
OBLAST66_VIEWBOX,
SVERDLOVSK_OBLAST_REGION,
_dadata_suggest,
_nominatim_suggest,
_viewbox_for_region,
suggest,
)
from app.services.regions import REGIONS
pytestmark = pytest.mark.anyio
# ── DaData-тир: имя региона в hard-констрейнте ───────────────────────────────
async def test_dadata_suggest_default_region_unchanged() -> None:
"""Без region_code — прежняя константа «Свердловская» (БЕЗ типа)."""
mock = AsyncMock(return_value=[])
with patch("app.services.geocoder.dadata.suggest_addresses", new=mock):
assert await _dadata_suggest("Малышева 30", limit=5) == []
assert mock.await_args.kwargs["region"] == SVERDLOVSK_OBLAST_REGION
assert mock.await_args.kwargs["region"] == "Свердловская"
assert mock.await_args.kwargs["city"] is None
async def test_dadata_suggest_region_77_sends_moscow() -> None:
"""region_code=77 — в DaData уходит «Москва», а не свердловский констрейнт."""
mock = AsyncMock(return_value=[])
with patch("app.services.geocoder.dadata.suggest_addresses", new=mock):
assert await _dadata_suggest("Тверская 6", limit=5, region_code=77) == []
assert mock.await_args.kwargs["region"] == "Москва"
async def test_dadata_suggest_unknown_region_raises() -> None:
"""Регион вне реестра — явная ошибка, а не молчаливый пустой список."""
with patch("app.services.geocoder.dadata.suggest_addresses", new=AsyncMock(return_value=[])):
with pytest.raises(ValueError, match="unknown region_code"):
await _dadata_suggest("Ленина 1", limit=5, region_code=99)
# ── Nominatim-тир: рамка региона ─────────────────────────────────────────────
def test_viewbox_for_region_66_is_literal_constant() -> None:
"""Для 66 рамка — историческая константа, не пересчёт из bbox реестра."""
assert _viewbox_for_region(66) == OBLAST66_VIEWBOX["viewbox"]
def test_viewbox_for_region_77_covers_moscow() -> None:
"""Рамка 77 строится из bbox_region реестра: lon_min,lat_max,lon_max,lat_min."""
lat_min, lat_max, lon_min, lon_max = REGIONS[77].bbox_region
assert _viewbox_for_region(77) == f"{lon_min},{lat_max},{lon_max},{lat_min}"
assert _viewbox_for_region(77) != OBLAST66_VIEWBOX["viewbox"]
async def test_nominatim_suggest_default_viewbox_and_suffix_unchanged() -> None:
"""Дефолтный вызов: свердловская рамка + ЕКБ-суффикс dual-query (#2580 C2)."""
seen: list[tuple[str, str]] = []
async def fake_get(url, params=None, **_kw):
seen.append((params["q"], params["viewbox"]))
response = MagicMock()
response.json.return_value = []
response.raise_for_status.return_value = None
return response
with (
patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)),
patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()),
):
await _nominatim_suggest("Ленина, 1", limit=5)
queries = [q for q, _ in seen]
assert "Ленина, 1, Екатеринбург" in queries
assert "Ленина, 1" in queries
assert {vb for _, vb in seen} == {OBLAST66_VIEWBOX["viewbox"]}
async def test_nominatim_suggest_region_77_sends_moscow_frame() -> None:
"""region_code=77: московская рамка и московский суффикс, ЕКБ не упоминается."""
seen: list[tuple[str, str]] = []
async def fake_get(url, params=None, **_kw):
seen.append((params["q"], params["viewbox"]))
response = MagicMock()
response.json.return_value = []
response.raise_for_status.return_value = None
return response
with (
patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=fake_get)),
patch("app.services.geocoder._nominatim_throttle", new=AsyncMock()),
):
await _nominatim_suggest("Тверская, 6", limit=5, region_code=77)
queries = [q for q, _ in seen]
assert "Тверская, 6, Москва" in queries
assert not any("Екатеринбург" in q for q in queries)
assert {vb for _, vb in seen} == {_viewbox_for_region(77)}
# ── suggest(): прокидывание региона и гейт локальных ЕКБ-тиров ───────────────
async def test_suggest_passes_region_to_both_tiers() -> None:
"""region_code доезжает и до DaData, и до Nominatim-фолбэка."""
dadata_mock = AsyncMock(return_value=[])
nominatim_mock = AsyncMock(return_value=[])
with (
patch("app.services.geocoder._dadata_suggest", new=dadata_mock),
patch("app.services.geocoder._nominatim_suggest", new=nominatim_mock),
patch("app.services.geocoder.settings") as mock_settings,
):
mock_settings.dadata_api_token = "token"
await suggest("Тверская 6", db=None, limit=5, region_code=77)
assert dadata_mock.await_args.args[2] == 77
assert nominatim_mock.await_args.kwargs["region_code"] == 77
async def test_suggest_region_77_skips_cadastral_tier() -> None:
"""Кадастровый тир (ЕКБ-FDW) для 77 не зовётся вовсе — данных там нет."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_house_match") as house_mock,
patch("app.services.geocoder._cadastral_forward_sync") as forward_mock,
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])),
patch("app.services.geocoder.settings") as mock_settings,
):
mock_settings.dadata_api_token = None
await suggest("Тверская 6", db=db, limit=5, region_code=77)
house_mock.assert_not_called()
forward_mock.assert_not_called()
async def test_suggest_default_still_uses_cadastral_tier() -> None:
"""Регресс-контроль: дефолтный (66) вызов кадастровый тир по-прежнему зовёт."""
db = MagicMock()
with (
patch("app.services.geocoder._cadastral_house_match", return_value=None) as house_mock,
patch("app.services.geocoder._cadastral_forward_sync", return_value=[]) as forward_mock,
patch("app.services.geocoder._nominatim_suggest", new=AsyncMock(return_value=[])),
patch("app.services.geocoder.settings") as mock_settings,
):
mock_settings.dadata_api_token = None
await suggest("Малышева 30", db=db, limit=5)
assert house_mock.called or forward_mock.called
async def test_suggest_unknown_region_raises() -> None:
"""Неизвестный регион — ValueError до похода к провайдерам."""
with pytest.raises(ValueError, match="unknown region_code"):
await suggest("Ленина 1", db=None, limit=5, region_code=99)

View file

@ -90,11 +90,16 @@ def test_client_coords_in_ekb_skip_geocode() -> None:
def test_out_of_bbox_coords_ignored_geocode_called() -> None:
"""Moscow coords are outside the EKB bbox → ignored, geocode() IS called."""
"""Coords outside EVERY covered region → ignored, geocode() IS called.
#3051: Moscow (55.75/37.61) used to be the out-of-bbox sample here, but region
77 is now part of the coverage registry and its coords go through the same
client-coords fast path as oblast 66 ones. Sochi is outside both.
"""
from app.services.estimator import estimate_quality
db = MagicMock()
payload = _make_payload(lat=55.75, lon=37.61) # Moscow — out of EKB bbox
payload = _make_payload(lat=43.60, lon=39.73) # Sochi — outside all regions
geocode_mock = AsyncMock(return_value=_make_fake_geo())
async def _run() -> None:

View file

@ -129,7 +129,7 @@ async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None
"""
calls: list[str] = []
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
@ -150,7 +150,7 @@ async def test_nominatim_suggest_no_city_dual_query_both_variants_sent() -> None
async def test_nominatim_suggest_city_hint_suffix() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
@ -165,7 +165,7 @@ async def test_nominatim_suggest_city_hint_suffix() -> None:
async def test_nominatim_suggest_explicit_ekb_unchanged() -> None:
calls: list[str] = []
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
calls.append(query)
return []
@ -202,7 +202,7 @@ async def test_nominatim_suggest_pobedy20_ekb_result_not_lost() -> None:
"address": {"road": "улица Победы", "house_number": "20"},
}
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
if query.endswith(", Екатеринбург"):
return [ekb_item]
return [pyshma_item]
@ -237,7 +237,7 @@ async def test_nominatim_suggest_dedupe_across_dual_query() -> None:
"address": {"road": "ул. Малышева", "house_number": "30"},
}
async def fake_query_multi(client, query, limit):
async def fake_query_multi(client, query, limit, **_kw):
return [same_item]
with (

View file

@ -0,0 +1,196 @@
"""Тесты импорта московского сырья `msk_raw` в listings.
Изоляция как у соседей (tests/tasks/test_backfill_house_coords_from_listings.py):
DATABASE_URL проставляется до импорта модуля, в сеть и в прод-БД тест не ходит.
Сессия заглушка, отдающая заранее заготовленные payload'ы; писатель
(`save_listings`) подменяется monkeypatch'ем, так что проверяется ровно наш отбор
и пересчёт метки, а не поведение кита.
Главные риски задачи, которые тут и караулятся:
* в регион 77 уедет Московская область (адрес без округа);
* артефакт парсера («Екатеринбург (Cian)») растворится в «не Москва»;
* метка кита 'novostroyki' будет скопирована как есть и выкинет 29 000 карточек
из подбора аналогов (гвард estimator'а берёт только NULL/'vtorichka').
"""
from __future__ import annotations
import os
from typing import Any
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.tasks import msk_raw_import as mod
def _payload(**over: Any) -> dict[str, Any]:
"""Минимальный сериализованный ScrapedLot, как он лежит в msk_raw.payload."""
base: dict[str, Any] = {
"source": "cian",
"source_url": "https://www.cian.ru/sale/flat/330200428/",
"source_id": "330200428",
"address": "ЦАО, р-н Арбат, улица Арбат, 10",
"price_rub": 25_000_000,
"rooms": 2,
"area_m2": 55.0,
"listing_segment": "novostroyki", # метка кита — её и пересчитываем
"raw_payload": {"is_from_developer": False},
}
base.update(over)
return base
# ── отбор по адресу ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("okrug", mod.MOSCOW_OKRUGS)
def test_every_okrug_prefix_is_moscow(okrug: str) -> None:
assert mod.is_moscow_address(f"{okrug}, р-н Хамовники, улица Льва Толстого, 16")
def test_oblast_address_without_okrug_is_not_moscow() -> None:
"""Адрес без округа — Московская область (регион 50), его в импорт не берём."""
assert not mod.is_moscow_address("Химки, микрорайон Новогорск, Ивановская улица, 5")
assert not mod.is_moscow_address("городской округ Мытищи, Олимпийский проспект, 29")
def test_okrug_must_be_a_whole_token() -> None:
"""«ЗАО» в начале улицы — не округ; иначе область просочилась бы в регион 77."""
assert not mod.is_moscow_address("ЗАОзёрная улица, 25")
def test_ekaterinburg_artifact_is_its_own_reason() -> None:
assert mod.is_artifact_address("Екатеринбург (Cian)")
assert not mod.is_moscow_address("Екатеринбург (Cian)")
# ── пересчёт listing_segment ─────────────────────────────────────────────────
def test_segment_recomputed_to_vtorichka_when_developer_false() -> None:
lot = mod.build_lot(_payload())
assert lot.listing_segment == "vtorichka"
def test_segment_recomputed_to_vtorichka_when_flag_absent() -> None:
"""Отсутствие флага — не признак новостройки (у 31 464 карточек его нет вовсе)."""
lot = mod.build_lot(_payload(raw_payload={}))
assert lot.listing_segment == "vtorichka"
assert mod.build_lot(_payload(raw_payload=None)).listing_segment == "vtorichka"
def test_segment_is_novostroyki_only_when_developer_sells() -> None:
lot = mod.build_lot(_payload(raw_payload={"is_from_developer": True}))
assert lot.listing_segment == "novostroyki"
def test_unknown_payload_keys_are_dropped() -> None:
"""В payload может приехать ключ, которого в модели нет — это не должно ронять импорт."""
lot = mod.build_lot(_payload(some_future_field="x"))
assert lot.source_id == "330200428"
# ── прогон целиком ───────────────────────────────────────────────────────────
class _FakeResult:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self._rows = rows
def mappings(self) -> _FakeResult:
return self
def all(self) -> list[dict[str, Any]]:
return self._rows
class _FakeSession:
"""Отдаёт строки постранично по тому же keyset-условию, что и настоящая вью."""
def __init__(self, rows: list[dict[str, Any]]) -> None:
self.rows = rows
self.commits = 0
def execute(self, _sql: Any, params: dict[str, Any]) -> _FakeResult:
after, limit = params["after"], params["limit"]
return _FakeResult([r for r in self.rows if r["id"] > after][:limit])
def commit(self) -> None:
self.commits += 1
def _rows() -> list[dict[str, Any]]:
return [
{"id": 1, "payload": _payload(source_id="1")},
{"id": 2, "payload": _payload(source_id="2", address="Химки, Ивановская улица, 5")},
{"id": 3, "payload": _payload(source_id="3", address="Екатеринбург (Cian)")},
{"id": 4, "payload": _payload(source_id="4", address="СВАО, улица Милашенкова, 7")},
{"id": 5, "payload": _payload(source_id="5", price_rub=0)}, # невалидный: цена > 0
]
@pytest.fixture()
def captured(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
def _fake_save(db: Any, lots: list[Any], **kwargs: Any) -> tuple[int, int]:
calls.append({"lots": lots, **kwargs})
return len(lots), 0
monkeypatch.setattr(mod, "save_listings", _fake_save)
return calls
def test_only_moscow_rows_reach_the_writer(captured: list[dict[str, Any]]) -> None:
db = _FakeSession(_rows())
counters = mod.import_msk_raw(db, batch_size=10)
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_artifact == 1
assert counters.skipped_invalid == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_writer_gets_region_77_and_city(captured: list[dict[str, Any]]) -> None:
mod.import_msk_raw(_FakeSession(_rows()), batch_size=10)
assert captured[0]["region_code"] == 77
assert captured[0]["city"] == "Москва"
assert all(lot.listing_segment == "vtorichka" for lot in captured[0]["lots"])
def test_commit_per_batch(captured: list[dict[str, Any]]) -> None:
"""Обрыв на середине не должен терять уже перелитые батчи."""
db = _FakeSession(_rows())
mod.import_msk_raw(db, batch_size=1)
assert db.commits == 2 # ровно столько батчей содержали московские строки
def test_dry_run_writes_nothing(captured: list[dict[str, Any]]) -> None:
db = _FakeSession(_rows())
counters = mod.import_msk_raw(db, batch_size=10, dry_run=True)
assert captured == []
assert db.commits == 0
assert counters.selected == 2 and counters.written == 0
def test_limit_caps_rows_read(captured: list[dict[str, Any]]) -> None:
counters = mod.import_msk_raw(_FakeSession(_rows()), batch_size=2, limit=3)
assert counters.read == 3
def test_avito_refuses_to_write_without_explicit_opt_in(captured: list[dict[str, Any]]) -> None:
"""У Авито в адресе нет города, а координат нет ни у одной карточки — молча
залить область в регион 77 нельзя."""
rows = [{"id": 1, "payload": _payload(source="avito", address="Варшавское ш.,62к1")}]
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10)
counters = mod.import_msk_raw(
_FakeSession(rows), source="avito", batch_size=10, allow_unfiltered=True
)
assert counters.selected == 1 # префиксный фильтр к Авито не применяется