gendesign/tradein-mvp/backend/tests/test_msk_raw_import.py
bot-backend ce9c45c3c2
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m9s
feat(msk): импортёр знает Яндекс — город из адреса, ноль внешних вызовов
Сбор Яндекса по Москве уже идёт, а лить его было нечем: в SOURCE_VIEWS стояли
только cian и avito.

Отбор Москвы у Яндекса не требует ни префикса округа (как у Циана), ни
пред-геокода (как у Авито). Адрес приходит полным и нормализованным — «Россия,
Москва, Коробейников переулок, 1», регион читается вторым компонентом. Замер по
21 393 карточкам первого прохода: во втором компоненте ровно ДВА значения,
«Москва» 10 610 и «Московская область» 10 783, третьего не встречается. Новая
Москва отдельным значением не приходит — Троицк и Зеленоград Яндекс кладёт под
«Москва», что совпадает с кодом региона 77.

Координаты, адрес и ссылка заполнены у 100% карточек, поэтому geom появляется
сразу и ждать ночного `geocode_missing` не нужно. `--geocode` для yandex
отклоняется так же, как для cian: квота нужна только Авито.

`filter_by_okrug` заменён словарём CITY_FILTERS — источник либо сам говорит про
город, либо его в словаре нет и без пред-геокода писать его нельзя. Поведение
cian и avito байт в байт прежнее.

`uv run python -m pytest tests/test_msk_raw_import.py` — 42 passed, ruff чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh
2026-09-12 02:19:44 +03:00

463 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Тесты импорта московского сырья `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
def scalar(self) -> Any:
"""Проба существования кэш-таблицы: None = таблицы ещё нет."""
return self._rows[0] if self._rows else None
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] | None = None) -> _FakeResult:
if not params or "after" not in params:
return _FakeResult([]) # DDL кэша пред-геокода — строк не отдаёт
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 # префиксный фильтр к Авито не применяется
# ── пред-геокод Авито ────────────────────────────────────────────────────────
#
# Риски этой ветки, которые тут и караулятся:
# * регион возьмут из слага, а не из ответа — Новая Москва (Троицк/Щербинка/
# Коммунарка) уедет в область, хотя это регион 77;
# * область (регион 50) просочится в listings;
# * слаг выпадет из ключа кэша — адреса-тёзки из разных муниципалитетов
# («Центральная ул.,5») схлопнутся в один дом;
# * повторный прогон заново потратит квоту по уже разрешённым адресам.
def _avito(**over: Any) -> dict[str, Any]:
base = _payload(
source="avito",
source_url="https://www.avito.ru/moskva/kvartiry/2-k._kvartira_56_m_56_et._46317006",
address="Варшавское ш.,62к1",
)
base.update(over)
return base
class _Sugg:
"""Минимальный DadataSuggestion — нужны только поля, которые читает отбор."""
def __init__(self, kind: str, lat: float | None, lon: float | None, kladr: str | None) -> None:
self.kind, self.lat, self.lon, self.kladr_id = kind, lat, lon, kladr
self.unrestricted_value = "г Москва, ш Варшавское, д 62 к 1"
def test_slug_comes_from_source_url() -> None:
assert mod.avito_city_slug(_avito()) == "moskva"
himki = _avito(source_url="https://www.avito.ru/himki/kvartiry/1")
assert mod.avito_city_slug(himki) == "himki"
assert mod.avito_city_slug(_avito(source_url="")) is None
assert mod.avito_city_slug({}) is None
def test_cache_key_separates_same_address_in_different_towns() -> None:
"""1110 адресов из 21 570 живут под несколькими слагами — это разные дома."""
assert mod.geo_cache_key("Центральная ул.,5", "moskva") != mod.geo_cache_key(
"Центральная ул.,5", "himki"
)
# нормализация адреса общая с геокодером — регистр и пробелы ключ не множат
assert mod.geo_cache_key(" Варшавское Ш.,62к1 ", "moskva") == mod.geo_cache_key(
"варшавское ш.,62к1", "moskva"
)
def test_moscow_slug_narrows_constraint_others_keep_both_regions() -> None:
"""Не-московский слаг может оказаться Новой Москвой — оба региона в констрейнте."""
assert mod._dadata_regions_for("moskva") == [mod.DADATA_MOSCOW_REGION]
assert mod._dadata_regions_for("moskovskaya_oblast_troitsk") == [
mod.DADATA_MOSCOW_REGION,
mod.DADATA_OBLAST_REGION,
]
assert mod._dadata_regions_for(None) == [
mod.DADATA_MOSCOW_REGION,
mod.DADATA_OBLAST_REGION,
]
def test_region_is_read_from_the_answer_not_from_the_slug() -> None:
"""КЛАДР ответа 77… → регион 77, даже если слаг был подмосковным."""
point = mod._point_from_suggestions([_Sugg("house", 55.6, 37.6, "77000000000000000")])
assert point is not None and point.region_code == 77
oblast = mod._point_from_suggestions([_Sugg("house", 55.9, 37.4, "50000000000000000")])
assert oblast is not None and oblast.region_code == 50
def test_only_house_level_answers_become_points() -> None:
"""Улица/город дают точку в середине улицы — для radius-подбора это хуже, чем ничего."""
assert mod._point_from_suggestions([_Sugg("street", 55.6, 37.6, "77000000000000000")]) is None
assert mod._point_from_suggestions([_Sugg("house", None, None, "77000000000000000")]) is None
# чужой регион (констрейнт пробит) — тоже не берём
assert mod._point_from_suggestions([_Sugg("house", 56.8, 60.6, "66000000000000000")]) is None
def test_budget_stops_asking_instead_of_failing() -> None:
budget = mod.GeocodeBudget(remaining=2)
assert budget.take(5) == 2 and budget.exhausted and budget.spent == 2
assert budget.take(1) == 0 # квота кончилась — просто больше не спрашиваем
def _geo_rows() -> list[dict[str, Any]]:
return [
{"id": 1, "payload": _avito(source_id="1")}, # Москва
{
"id": 2,
"payload": _avito(
source_id="2",
source_url="https://www.avito.ru/himki/kvartiry/2",
address="Ивановская ул.,5",
),
}, # область
{
"id": 3,
"payload": _avito(source_id="3", address="Мкр-н имени В.Н. Махалина, 33"),
}, # не разрешился
]
@pytest.fixture()
def geocoded(monkeypatch: pytest.MonkeyPatch) -> list[list[dict[str, Any]]]:
"""Подменяет резолвер целиком — в сеть и в кэш-таблицу тест не ходит."""
seen: list[list[dict[str, Any]]] = []
def _fake_resolve(db: Any, rows: list[Any], budget: Any, **kwargs: Any) -> dict[str, Any]:
seen.append(list(rows))
return {
mod.geo_cache_key("Варшавское ш.,62к1", "moskva"): mod.GeoPoint(
55.6412, 37.6089, 77, "г Москва, ш Варшавское, д 62 к 1"
),
mod.geo_cache_key("Ивановская ул.,5", "himki"): mod.GeoPoint(
55.8970, 37.4297, 50, "Московская обл, г Химки, ул Ивановская, д 5"
),
}
monkeypatch.setattr(mod, "_resolve_points", _fake_resolve)
return seen
def test_geocode_lets_avito_through_without_allow_unfiltered(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
"""Пред-геокод — штатный путь: аварийный флаг больше не нужен."""
counters = mod.import_msk_raw(
_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True
)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_oblast == 1
assert counters.skipped_ungeocoded == 1
assert counters.check(), "счётчики обязаны сходиться с прочитанным"
def test_coordinates_reach_the_writer(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
"""Ради geom всё и затевалось — карточка не должна лечь без координат."""
mod.import_msk_raw(_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True)
lot = captured[0]["lots"][0]
assert (lot.lat, lot.lon) == (55.6412, 37.6089)
assert lot.geo_precision == "house"
assert captured[0]["region_code"] == 77
def test_oblast_is_never_written_as_region_77(
captured: list[dict[str, Any]], geocoded: list[list[dict[str, Any]]]
) -> None:
mod.import_msk_raw(_FakeSession(_geo_rows()), source="avito", batch_size=10, geocode=True)
written = [lot.source_id for call in captured for lot in call["lots"]]
assert "2" not in written, "химкинская карточка не должна уехать в регион 77"
def test_avito_without_geocode_demands_opt_in(captured: list[dict[str, Any]]) -> None:
rows = [{"id": 1, "payload": _avito()}]
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(rows), source="avito", batch_size=10)
def test_geocode_is_refused_for_cian(captured: list[dict[str, Any]]) -> None:
"""У Циана город виден по префиксу округа даром — квоту на него не тратим."""
with pytest.raises(SystemExit):
mod.import_msk_raw(_FakeSession(_rows()), source="cian", batch_size=10, geocode=True)
def test_dry_run_survives_missing_geocode_cache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Первый `--dry-run` идёт по проду, где кэш-таблицы ещё нет.
Таблицу создаёт только боевой прогон, поэтому SELECT по ней в сухом ронял
ровно ту репетицию, ради которой сухой прогон и существует (замер на проде
11.09: UndefinedTable msk_raw.avito_geocode). Резолвер обязан молча считать
кэш пустым и идти к DaData за остатком.
"""
class _NoCacheSession:
def __init__(self) -> None:
self.cache_reads = 0
def execute(self, sql: Any, params: dict[str, Any] | None = None) -> _FakeResult:
if "to_regclass" in str(sql):
return _FakeResult([]) # таблицы нет
self.cache_reads += 1
raise AssertionError("к отсутствующей кэш-таблице ходить нельзя")
point = mod.GeoPoint(55.6412, 37.6089, 77, "г Москва, ш Варшавское, д 62 к 1")
async def _fake_geocode(misses: list[Any]) -> dict[str, Any]:
return {misses[0][0]: point}
monkeypatch.setattr(mod, "_geocode_many", _fake_geocode)
db = _NoCacheSession()
points = mod._resolve_points(
db, [{"id": 1, "payload": _avito()}], mod.GeocodeBudget(10), dry_run=True
)
assert db.cache_reads == 0
assert list(points.values()) == [point]
# ── Яндекс: город из второго компонента адреса ───────────────────────────────
def _yandex(**over: Any) -> dict[str, Any]:
base = _payload(
source="yandex",
source_url="https://realty.yandex.ru/offer/1234567890123456789/",
source_id="1234567890123456789",
address="Россия, Москва, Коробейников переулок, 1",
lat=55.737312,
lon=37.601006,
)
base.update(over)
return base
def test_yandex_moscow_is_recognised_without_geocode() -> None:
assert mod.is_moscow_yandex_address("Россия, Москва, Коробейников переулок, 1")
assert mod.is_moscow_yandex_address("Россия, Москва, Зеленоград, корпус 1130")
def test_yandex_oblast_is_not_moscow() -> None:
"""Ровно те два значения, что встречаются в корпусе; область писать нельзя."""
oblast = "Россия, Московская область, Химки, Ивановская улица, 5"
assert not mod.is_moscow_yandex_address(oblast)
assert not mod.is_moscow_yandex_address("Москва, Коробейников переулок, 1") # без страны
assert not mod.is_moscow_yandex_address("")
assert not mod.is_moscow_yandex_address(None)
def test_yandex_oblast_rows_do_not_reach_the_writer(captured: list[dict[str, Any]]) -> None:
rows = [
{"id": 1, "payload": _yandex(source_id="1")},
{
"id": 2,
"payload": _yandex(
source_id="2", address="Россия, Московская область, Химки, Ивановская улица, 5"
),
},
]
counters = mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
assert [lot.source_id for lot in captured[0]["lots"]] == ["1"]
assert counters.skipped_not_moscow == 1
def test_yandex_keeps_its_own_coordinates(captured: list[dict[str, Any]]) -> None:
"""Координаты у Яндекса свои — геокод ему не нужен, geom появляется сразу."""
rows = [{"id": 1, "payload": _yandex()}]
mod.import_msk_raw(_FakeSession(rows), source="yandex", batch_size=10)
lot = captured[0]["lots"][0]
assert (lot.lat, lot.lon) == (55.737312, 37.601006)
def test_geocode_is_refused_for_yandex(captured: list[dict[str, Any]]) -> None:
"""Квота нужна только Авито: у Яндекса город и точка есть даром."""
with pytest.raises(SystemExit):
mod.import_msk_raw(
_FakeSession([{"id": 1, "payload": _yandex()}]), source="yandex", geocode=True
)