From f6938452d092268094993978ccafa03a591fe68c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 12 Sep 2026 15:16:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(msk-import):=20=D0=94=D0=BE=D0=BC=D0=9A?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=20=D0=BA=D0=B0=D0=BA=20=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D0=B2=D1=91=D1=80=D1=82=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=81=D1=8B=D1=80=D1=8C=D1=8F=20=D0=B2=20?= =?UTF-8?q?listings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сбор ДомКлика по Москве пошёл (PR #3496), но переливать его в listings было нечем: в SOURCE_VIEWS стояли только Циан, Авито и Яндекс. Вью msk_raw.domclick_latest в проде уже существует, не хватало двух записей в словарях и распознавания города. ## Город читается первым компонентом адреса У Циана города в адресе нет вовсе и Москву выдаёт префикс округа; у Яндекса регион стоит вторым компонентом после страны. У ДомКлика адрес начинается прямо с города: «Москва, улица Нижние Мнёвники, 16 к2». Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно одно значение — «Москва», областных нет ни одной. Это ожидаемо, сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне сборщика. Поэтому фильтр здесь — вторая линия, а не единственная. Сменится GUID в сборщике или приедет в ту же таблицу второй город — импорт не потащит его в Москву молча, а отложит в skipped_not_moscow. Новая Москва регион не ломает: «Москва, пос. Птичное, …», «Москва, x. Ильичевка, …» — посёлок стоит ВТОРЫМ компонентом, первым всегда город, что и соответствует коду 77. Сравнение идёт по компоненту целиком, а не по префиксу строки: иначе «Московская область» и «Московский» прошли бы как Москва. Оба случая закреплены тестом. ## Тесты Распознавание Москвы, включая две формы Новой Москвы; отбраковка области, Екатеринбурга, похожего на префикс «Московский», пустой строки и None; проводка источника в оба словаря. 45 passed, ruff чистый. Claude-Session: https://claude.ai/code/session_01NQb6WeJtagZwZnUsSjDizs --- .../backend/app/tasks/msk_raw_import.py | 31 +++++++++++++++++++ .../backend/tests/test_msk_raw_import.py | 22 +++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tradein-mvp/backend/app/tasks/msk_raw_import.py b/tradein-mvp/backend/app/tasks/msk_raw_import.py index 0496e6a5..18a8df25 100644 --- a/tradein-mvp/backend/app/tasks/msk_raw_import.py +++ b/tradein-mvp/backend/app/tasks/msk_raw_import.py @@ -77,10 +77,21 @@ source_id) считает сам кит (`ScrapedLot.compute_dedup_hash`), це значения, «Москва» 10 610 и «Московская область» 10 783. Координаты у Яндекса заполнены у 100% карточек, поэтому ни геокод, ни `geocode_missing` ему не нужны. +Отбор Москвы (source=domclick) тоже стоит ноль вызовов, но по другой причине: +сырьё собрано запросом с GUID-ом Москвы и отфильтровано по bbox ещё на стороне +сборщика, поэтому областных карточек в нём нет по построению. Регион читается +ПЕРВЫМ компонентом адреса («Москва, улица Нижние Мнёвники, 16 к2»), и фильтр +оставлен второй линией — на случай, если в ту же таблицу когда-нибудь приедет +второй город. Замер по 5 024 карточкам первого прохода: единственное значение +первого компонента — «Москва». Новая Москва приходит как «Москва, пос. Птичное, +…», то есть посёлок стоит вторым компонентом и регион 77 не ломает. Координаты +заполнены у 100% карточек. + Запуск: 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 domclick python -m app.tasks.msk_raw_import --source avito --geocode --geocode-limit 9000 python -m app.tasks.msk_raw_import --source avito --allow-unfiltered # аварийный """ @@ -160,6 +171,7 @@ SOURCE_VIEWS = { "cian": "msk_raw.cian_latest", "avito": "msk_raw.avito_latest", "yandex": "msk_raw.yandex_latest", + "domclick": "msk_raw.domclick_latest", } _PAGE_SQL = """ @@ -485,11 +497,30 @@ def is_moscow_yandex_address(address: str | None) -> bool: return len(parts) > 1 and parts[1] == "Москва" +def is_moscow_domclick_address(address: str | None) -> bool: + """У ДомКлика регион — ПЕРВЫЙ компонент адреса: «Москва, улица …». + + Источник собран запросом с GUID-ом Москвы в параметре address и дополнительно + отфильтрован по bbox на стороне сборщика, так что областных карточек в сырье + и не должно быть. Фильтр здесь — вторая линия: сменится GUID в сборщике или + появится второй город в той же таблице — импорт не потащит его в Москву + молча. Замер по 5 024 карточкам первого прохода: первый компонент имеет ровно + одно значение, «Москва», областных нет ни одной. + + Новая Москва отдельным значением НЕ приходит: «Москва, x. Ильичевка, …», + «Москва, пос. Птичное, …» — посёлок стоит ВТОРЫМ компонентом, первый всегда + город, что совпадает с кодом региона 77. + """ + parts = [part.strip() for part in (address or "").split(",")] + return bool(parts) and parts[0] == "Москва" + + # Источники, у которых город виден в самой карточке. Ключ отсутствует — # источник про город молчит, и без пред-геокода писать его нельзя (avito). CITY_FILTERS = { "cian": is_moscow_address, "yandex": is_moscow_yandex_address, + "domclick": is_moscow_domclick_address, } diff --git a/tradein-mvp/backend/tests/test_msk_raw_import.py b/tradein-mvp/backend/tests/test_msk_raw_import.py index 4042a7a0..b9e7ad58 100644 --- a/tradein-mvp/backend/tests/test_msk_raw_import.py +++ b/tradein-mvp/backend/tests/test_msk_raw_import.py @@ -461,3 +461,25 @@ def test_geocode_is_refused_for_yandex(captured: list[dict[str, Any]]) -> None: mod.import_msk_raw( _FakeSession([{"id": 1, "payload": _yandex()}]), source="yandex", geocode=True ) + + +def test_domclick_moscow_is_recognised_without_geocode() -> None: + """Регион ДомКлика — первый компонент адреса; Новая Москва тоже Москва.""" + assert mod.is_moscow_domclick_address("Москва, улица Нижние Мнёвники, 16 к2") + assert mod.is_moscow_domclick_address("Москва, пос. Птичное, Лесная улица, 82") + assert mod.is_moscow_domclick_address("Москва, x. Ильичевка, хутор Ильичёвка, 1") + + +def test_domclick_non_moscow_is_rejected() -> None: + """Вторая линия обороны: GUID в сборщике сменится — импорт не смолчит.""" + assert not mod.is_moscow_domclick_address("Московская область, Химки, 9 Мая, 21к3") + assert not mod.is_moscow_domclick_address("Екатеринбург, улица Ясная, 2") + # «Москва» должна быть отдельным компонентом, а не префиксом другого слова. + assert not mod.is_moscow_domclick_address("Московский, 1-я улица, 5") + assert not mod.is_moscow_domclick_address("") + assert not mod.is_moscow_domclick_address(None) + + +def test_domclick_view_is_wired_into_source_views() -> None: + assert mod.SOURCE_VIEWS["domclick"] == "msk_raw.domclick_latest" + assert mod.CITY_FILTERS["domclick"] is mod.is_moscow_domclick_address -- 2.45.3