feat(msk): импортёр знает Яндекс — город из адреса, ноль внешних вызовов
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

Сбор Яндекса по Москве уже идёт, а лить его было нечем: в 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
This commit is contained in:
bot-backend 2026-09-12 02:19:44 +03:00
parent deb6517bd5
commit ce9c45c3c2
2 changed files with 103 additions and 6 deletions

View file

@ -71,9 +71,16 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це
курсор идёт по `id` вью, так что порядок и полнота обхода от прогона к прогону
одинаковы.
Отбор Москвы (source=yandex) стоит ноль вызовов: адрес приходит полным и
нормализованным («Россия, Москва, Коробейников переулок, 1»), регион читается
вторым компонентом. Замер по 21 393 карточкам первого прохода ровно два
значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса
заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны.
Запуск:
python -m app.tasks.msk_raw_import --dry-run
python -m app.tasks.msk_raw_import --limit 500
python -m app.tasks.msk_raw_import --source yandex
python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000
python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный
"""
@ -152,6 +159,7 @@ _ARTIFACT_RE = re.compile(r"Екатеринбург", re.IGNORECASE)
SOURCE_VIEWS = {
"cian": "msk_raw.cian_latest",
"avito": "msk_raw.avito_latest",
"yandex": "msk_raw.yandex_latest",
}
_PAGE_SQL = """
@ -460,6 +468,31 @@ def is_moscow_address(address: str | None) -> bool:
return _MOSCOW_OKRUG_RE.match(address.strip()) is not None
def is_moscow_yandex_address(address: str | None) -> bool:
"""У Яндекса регион — второй компонент полного адреса.
Адрес приходит нормализованным и с городом: «Россия, Москва, Коробейников
переулок, 1». Замер по 21 393 карточкам первого прохода: во втором
компоненте ровно ДВА значения «Москва» 10 610 и «Московская область»
10 783, третьего не встречается. Поэтому ни префикса округа (как у Циана),
ни внешнего геокода (как у Авито) источнику не нужно: разделение 77 и 50
читается из самой карточки и стоит ноль вызовов.
Новая Москва отдельным значением НЕ приходит Троицк и Зеленоград Яндекс
кладёт под «Москва», что совпадает с кодом региона 77.
"""
parts = [part.strip() for part in (address or "").split(",")]
return len(parts) > 1 and parts[1] == "Москва"
# Источники, у которых город виден в самой карточке. Ключ отсутствует —
# источник про город молчит, и без пред-геокода писать его нельзя (avito).
CITY_FILTERS = {
"cian": is_moscow_address,
"yandex": is_moscow_yandex_address,
}
def recompute_listing_segment(payload: dict) -> str:
"""Заново считаем сегмент: 'novostroyki' только при продаже застройщиком.
@ -518,12 +551,15 @@ def import_msk_raw(
matcher = RealMatcherAdapter()
budget = GeocodeBudget(remaining=max(0, geocode_limit))
filter_by_okrug = source == "cian"
if geocode and filter_by_okrug:
# У Циана город опознаётся префиксом округа даром и без ошибок —
# тратить на него внешнюю квоту незачем.
# Источник, который сам говорит про город: у Циана это префикс округа, у
# Яндекса — второй компонент полного адреса. Авито не говорит ничего, ему
# нужен пред-геокод, поэтому его в словаре нет.
city_filter = CITY_FILTERS.get(source)
if geocode and city_filter is not None:
# Город опознаётся даром и без ошибок — тратить на него внешнюю квоту
# незачем.
raise SystemExit(f"source={source}: --geocode нужен только для avito")
if not filter_by_okrug and not geocode:
if city_filter is None and not geocode:
# Без пред-геокода у Авито по-прежнему нечем отделить Москву от области:
# ни города в адресе, ни координат. Пишем только по явному разрешению.
if not (dry_run or allow_unfiltered):
@ -551,7 +587,7 @@ def import_msk_raw(
if is_artifact_address(address):
counters.skipped_artifact += 1
continue
if filter_by_okrug and not is_moscow_address(address):
if city_filter is not None and not city_filter(address):
counters.skipped_not_moscow += 1
continue
if geocode:

View file

@ -400,3 +400,64 @@ def test_dry_run_survives_missing_geocode_cache(monkeypatch: pytest.MonkeyPatch)
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
)