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:
parent
8fcef103c2
commit
b1727ca39c
9 changed files with 1010 additions and 37 deletions
|
|
@ -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®ion_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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
322
tradein-mvp/backend/app/tasks/msk_raw_import.py
Normal file
322
tradein-mvp/backend/app/tasks/msk_raw_import.py
Normal 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()
|
||||
132
tradein-mvp/backend/tests/test_3051_estimate_region_scope.py
Normal file
132
tradein-mvp/backend/tests/test_3051_estimate_region_scope.py
Normal 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
|
||||
181
tradein-mvp/backend/tests/test_3051_suggest_region_scope.py
Normal file
181
tradein-mvp/backend/tests/test_3051_suggest_region_scope.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
196
tradein-mvp/backend/tests/test_msk_raw_import.py
Normal file
196
tradein-mvp/backend/tests/test_msk_raw_import.py
Normal 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 # префиксный фильтр к Авито не применяется
|
||||
Loading…
Add table
Reference in a new issue