From ce9c45c3c26b4fbececed5f632d92356224fc287 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 02:19:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(msk):=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D1=91=D1=80=20=D0=B7=D0=BD=D0=B0=D0=B5=D1=82=20=D0=AF?= =?UTF-8?q?=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=20=E2=80=94=20=D0=B3=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B8=D0=B7=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=B0,=20=D0=BD=D0=BE=D0=BB=D1=8C=20=D0=B2=D0=BD=D0=B5=D1=88?= =?UTF-8?q?=D0=BD=D0=B8=D1=85=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сбор Яндекса по Москве уже идёт, а лить его было нечем: в 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 Claude-Session: https://claude.ai/code/session_01VQ8jqr4SFirX5tFLwdSrXh --- .../backend/app/tasks/msk_raw_import.py | 48 +++++++++++++-- .../backend/tests/test_msk_raw_import.py | 61 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py index 5382c0d6..0496e6a5 100644 --- a/tradein-mvp/backend/app/tasks/msk_raw_import.py +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -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: diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py index 4ed0a2d7..4042a7a0 100644 --- a/tradein-mvp/backend/tests/test_msk_raw_import.py +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -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 + )