diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 4e7da7c5..a14626e5 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -167,10 +167,27 @@ async def clean_address(address: str) -> DadataAddressResult | None: logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)") return None if status in (401, 403): - logger.error( - "dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.", - status, - ) + body_preview = (response.text or "")[:200] + # 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден, + # но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ + # поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback + # (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена. + if status == 403 and ( + "disabled" in body_preview.lower() or "feature" in body_preview.lower() + ): + logger.error( + "dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте " + "(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или " + "полагайся на suggest-fallback (enrich_address). Ответ: %r", + body_preview, + ) + else: + logger.error( + "dadata: HTTP %d — auth/secret rejected. " + "Проверь DADATA_API_TOKEN/SECRET. Ответ: %r", + status, + body_preview, + ) return None if status >= 500: logger.warning("dadata: HTTP %d — transient server error", status) @@ -246,6 +263,12 @@ class DadataSuggestion: street: str | None city: str | None kind: str + # #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN + # выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id); + # kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной. + house_fias_id: str | None = None + kladr_id: str | None = None + qc_geo: int | None = None def _classify_kind(fias_level: Any) -> str: @@ -295,6 +318,9 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None: street=data.get("street"), city=data.get("city"), kind=_classify_kind(data.get("fias_level")), + house_fias_id=data.get("house_fias_id"), + kladr_id=data.get("kladr_id"), + qc_geo=_coerce_int(data.get("qc_geo")), ) @@ -401,3 +427,55 @@ async def suggest_addresses( logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out)) return out + + +# ── Enrichment orchestrator (clean → suggest fallback) ─────────────────────── + + +async def enrich_address(address: str) -> DadataAddressResult | None: + """Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST. + + Порядок: + 1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id + + qc_geo + metro. Если вернул результат — используем его. + 2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) — + берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo, + но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются + None. Честная деградация: недостающие поля не выдумываем. + + Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга + CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id — + ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт + бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66). + """ + cleaned = await clean_address(address) + if cleaned is not None: + return cleaned + + suggestions = await suggest_addresses(address, limit=5, city="") + for s in suggestions: + fias = s.house_fias_id or s.fias_id + if s.kind == "house" and fias: + # house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию. + qc_geo = s.qc_geo if s.qc_geo is not None else 0 + logger.info( + "dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s " + "(canonical/cadnum недоступны без CLEAN)", + address[:60], + fias, + ) + return DadataAddressResult( + canonical_address=None, + house_cadnum=None, + house_fias_id=fias, + lat=s.lat, + lon=s.lon, + qc_geo=qc_geo, + qc_house=None, + kladr_id=s.kladr_id, + okato=None, + oktmo=None, + metro=[], + raw={"_source": "suggest_fallback", "value": s.value}, + ) + return None diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index b5e3bc00..2f0ce95e 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -64,7 +64,11 @@ from app.schemas.trade_in import ( TradeInEstimateInput, ) from app.services.dadata import DadataAddressResult -from app.services.dadata import clean_address as dadata_clean_address + +# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга +# CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас +# оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn). +from app.services.dadata import enrich_address as dadata_clean_address from app.services.geocoder import ( SVERDLOVSK_OBLAST_CITIES, GeocodeResult, diff --git a/tradein-mvp/backend/data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql b/tradein-mvp/backend/data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql new file mode 100644 index 00000000..465ae4f7 --- /dev/null +++ b/tradein-mvp/backend/data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql @@ -0,0 +1,199 @@ +-- 179_scrape_schedules_seed_oblast_city_sweeps.sql +-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout: +-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области. +-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит. +-- +-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false. +-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies, +-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его. +-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors, +-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и +-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в +-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы +-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз: +-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil'; +-- +-- WHY отдельный source на город (а не один source + params.city со списком городов): +-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с +-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source +-- либо конфликтует, либо перезаписывает первую строку — по факту только один город +-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source = +-- '_' (например 'cian_city_sweep_kamensk_uralskiy'), а +-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же +-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п., +-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает +-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS. +-- +-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": ""}. +-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции — +-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на +-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед +-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский). +-- +-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь +-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) — +-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные +-- sweep'ы разных источников делили бы тот же прокси). +-- +-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)). +-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален +-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов, +-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_ != city_sweep). + +BEGIN; + +INSERT INTO scrape_schedules ( + source, + enabled, + window_start_hour, + window_end_hour, + next_run_at, + default_params +) +VALUES +-- ── avito_city_sweep_ (база: radius_m 1500, detail_top_n 20, enrich_houses true, +-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ────────── +( + 'avito_city_sweep_nizhniy_tagil', + false, + 0, + 1, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb +), +( + 'avito_city_sweep_kamensk_uralskiy', + false, + 1, + 2, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb +), +( + 'avito_city_sweep_pervouralsk', + false, + 5, + 6, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb +), +( + 'avito_city_sweep_verkhnyaya_pyshma', + false, + 7, + 8, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb +), +( + 'avito_city_sweep_serov', + false, + 8, + 9, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb +), +-- ── cian_city_sweep_ (база: radius_m 1500, detail_top_n 10, enrich_houses true, +-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ───────── +( + 'cian_city_sweep_nizhniy_tagil', + false, + 9, + 10, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb +), +( + 'cian_city_sweep_kamensk_uralskiy', + false, + 10, + 11, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb +), +( + 'cian_city_sweep_pervouralsk', + false, + 11, + 12, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb +), +( + 'cian_city_sweep_verkhnyaya_pyshma', + false, + 12, + 13, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb +), +( + 'cian_city_sweep_serov', + false, + 13, + 14, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb +), +-- ── yandex_city_sweep_ (база: radius_m 25000, pages_per_anchor 3, +-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok — +-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не +-- query-ограничение, лишний охват не увеличивает load) ──────────────────────── +( + 'yandex_city_sweep_nizhniy_tagil', + false, + 14, + 15, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb +), +( + 'yandex_city_sweep_kamensk_uralskiy', + false, + 15, + 16, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb +), +( + 'yandex_city_sweep_pervouralsk', + false, + 17, + 18, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb +), +( + 'yandex_city_sweep_verkhnyaya_pyshma', + false, + 18, + 19, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb +), +( + 'yandex_city_sweep_serov', + false, + 19, + 20, + ((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC', + '{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::jsonb +) +ON CONFLICT (source) DO NOTHING; + +COMMENT ON TABLE scrape_schedules IS + 'In-app scheduler config (заменяет cron-script setup). ' + 'Sources: avito_city_sweep, yandex_city_sweep, cian_city_sweep (EKB, enabled), ' + 'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, ' + 'avito_city_sweep_/cian_city_sweep_/yandex_city_sweep_ ' + '(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/' + 'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), ' + 'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, ' + 'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, ' + 'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, ' + 'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, ' + 'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, ' + 'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, ' + 'deactivate_stale_avito/yandex/cian.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/services/test_dadata.py b/tradein-mvp/backend/tests/services/test_dadata.py index 5cb7489e..4b9a7464 100644 --- a/tradein-mvp/backend/tests/services/test_dadata.py +++ b/tradein-mvp/backend/tests/services/test_dadata.py @@ -720,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None: body = captured["body"] assert isinstance(body, dict) assert body["count"] == 20 # clamped to max + + +# ───────────────────────────────────────────────────────────────────────────── +# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback) +# ───────────────────────────────────────────────────────────────────────────── + + +import logging as _logging # noqa: E402 + +# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен). +CLEAN_FEATURE_DISABLED_BODY = { + "timestamp": "2026-07-12T16:37:54.585+00:00", + "status": 403, + "error": "Forbidden", + "message": ( + "Feature 'CLEAN' disabled for token 'xxx'. " + "See https://dadata.userecho.com/topics/7784 for help." + ), + "path": "/api/v1/clean/address", +} + +# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у). +SUGGEST_HOUSE_PAYLOAD = { + "suggestions": [ + { + "value": "г Екатеринбург, ул Малышева, д 4", + "unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4", + "data": { + "geo_lat": "56.831463", + "geo_lon": "60.580943", + "fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade", + "house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade", + "kladr_id": "6600000100006480017", + "qc_geo": "0", + "fias_level": "8", + "house": "4", + "street": "Малышева", + "city": "Екатеринбург", + }, + } + ] +} + + +def _mock_enrich_transport( + clean_status: int, clean_body: object, suggest_body: object +) -> httpx.MockTransport: + """Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient.""" + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "cleaner.dadata.ru" in url: + return httpx.Response(clean_status, json=clean_body) + if "suggestions.dadata.ru" in url: + return httpx.Response(200, json=suggest_body) + return httpx.Response(404, json={"error": "unexpected url"}) + + return httpx.MockTransport(handler) + + +async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None: + """403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен).""" + from app.services import dadata + + transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY) + with _patch_settings(), _patch_async_client(transport): + with caplog.at_level(_logging.ERROR, logger="app.services.dadata"): + result = await dadata.clean_address("Екатеринбург, Малышева 4") + + assert result is None + text = caplog.text + assert "Стандартизация" in text or "выключена" in text + # Не должны обвинять токен при feature-disabled. + assert "auth/secret rejected" not in text + + +async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None: + """401 (или 403 без 'disabled') → сообщение про креды.""" + from app.services import dadata + + transport = _mock_transport_returning(401, {"message": "Unauthorized"}) + with _patch_settings(), _patch_async_client(transport): + with caplog.at_level(_logging.ERROR, logger="app.services.dadata"): + result = await dadata.clean_address("Екатеринбург, Малышева 4") + + assert result is None + assert "auth/secret rejected" in caplog.text + + +async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None: + """_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data.""" + from app.services import dadata + + transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD) + with _patch_settings(), _patch_async_client(transport): + results = await dadata.suggest_addresses("Малышева 4") + + assert len(results) == 1 + assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade" + assert results[0].kladr_id == "6600000100006480017" + assert results[0].qc_geo == 0 + + +async def test_enrich_address_uses_clean_when_available() -> None: + """CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет.""" + from app.services import dadata + + transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []}) + with _patch_settings(), _patch_async_client(transport): + result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125") + + assert result is not None + assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125" + assert result.house_cadnum == "66:41:0704045:350" + assert result.raw.get("_source") != "suggest_fallback" + + +async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None: + """CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None.""" + from app.services import dadata + + transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD) + with _patch_settings(), _patch_async_client(transport): + result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4") + + assert result is not None + assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade" + assert result.kladr_id == "6600000100006480017" + assert result.qc_geo == 0 + assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6 + # Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем. + assert result.canonical_address is None + assert result.house_cadnum is None + assert result.raw.get("_source") == "suggest_fallback" + + +async def test_enrich_address_ignores_non_house_suggestions() -> None: + """Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None.""" + from app.services import dadata + + street_only = { + "suggestions": [ + { + "value": "г Екатеринбург, ул Малышева", + "unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева", + "data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"}, + } + ] + } + transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only) + with _patch_settings(), _patch_async_client(transport): + result = await dadata.enrich_address("Екатеринбург, ул. Малышева") + + assert result is None + + +async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None: + """CLEAN недоступен + suggest пустой → None (graceful).""" + from app.services import dadata + + transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []}) + with _patch_settings(), _patch_async_client(transport): + result = await dadata.enrich_address("Малышева 4") + + assert result is None diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index 38429830..a4499362 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -28,11 +28,15 @@ from unittest.mock import AsyncMock, MagicMock, patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from scraper_kit.orchestration import scheduler as kit_sched +from scraper_kit.orchestration.pipeline import CITY_ANCHORS from scraper_kit.orchestration.scheduler import ( Handler, SchedulerContext, _claim_run, _dispatch, + _job_avito_city_sweep, + _job_cian_city_sweep, + _job_yandex_city_sweep, build_registry, reap_zombies, reschedule_after_minutes, @@ -74,14 +78,20 @@ _PRODUCT_SOURCES: set[str] = { "proxy_healthcheck", } -# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно +# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно. +# "*_city_sweep_*" — wildcard-семья oblast B1 rollout (region 66, dormant, #179): +# per-city source (avito_city_sweep_nizhniy_tagil и т.п.) резолвится на тот же +# handler, что EKB source, через resolve_handler-префикс (см. _default_kit_handlers). _KIT_NATIVE_SOURCES = { "avito_city_sweep", + "avito_city_sweep_*", "avito_full_load", "avito_full_load_exhaustive", "avito_newbuilding_sweep", "yandex_city_sweep", + "yandex_city_sweep_*", "cian_city_sweep", + "cian_city_sweep_*", "cian_full_load", "domclick_city_sweep", } @@ -156,7 +166,10 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None: def test_kit_native_handler_set() -> None: - """Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно.""" + """Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast + + "*_city_sweep_*" wildcard, #179). + """ registry = build_registry() assert set(registry) == _KIT_NATIVE_SOURCES for src in _KIT_NATIVE_SOURCES: @@ -434,3 +447,87 @@ async def test_dispatch_post_claim_hook_runs() -> None: await asyncio.gather(*list(ctx._inflight_tasks)) post.assert_called_once() assert post.call_args[0][1] == 300 # run_id + + +# ── 6. B1 oblast rollout — city param → city anchors, wildcard dispatch ────────── + + +def _oblast_ctx() -> SchedulerContext: + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=MagicMock(), + ) + + +async def test_job_avito_city_sweep_with_city_param_uses_city_anchors() -> None: + """params={"city": "nizhniy_tagil"} → Н.Тагил anchors переданы в run_avito_city_sweep.""" + with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run: + await _job_avito_city_sweep(MagicMock(), 1, {"city": "nizhniy_tagil"}, _oblast_ctx()) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs["anchors"] == CITY_ANCHORS["nizhniy_tagil"] + assert kwargs["anchors"] == [(57.910, 59.980, "Н.Тагил центр")] + + +async def test_job_cian_city_sweep_with_city_param_uses_city_anchors() -> None: + """params={"city": "kamensk_uralskiy"} → anchors города переданы в run_cian_city_sweep.""" + with patch.object(kit_sched, "run_cian_city_sweep", AsyncMock()) as mock_run: + await _job_cian_city_sweep(MagicMock(), 1, {"city": "kamensk_uralskiy"}, _oblast_ctx()) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs["anchors"] == CITY_ANCHORS["kamensk_uralskiy"] + + +async def test_job_yandex_city_sweep_with_city_param_uses_city_anchors() -> None: + """params={"city": "serov"} → anchors города переданы в run_yandex_city_sweep.""" + with patch.object(kit_sched, "run_yandex_city_sweep", AsyncMock()) as mock_run: + await _job_yandex_city_sweep(MagicMock(), 1, {"city": "serov"}, _oblast_ctx()) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs["anchors"] == CITY_ANCHORS["serov"] + + +async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> None: + """Без city (EKB-schedule, back-compat) — anchors=None передан (run_* сам берёт EKB_ANCHORS).""" + with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run: + await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx()) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs["anchors"] is None + + +async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None: + """Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None, + + НЕ падает с KeyError. + """ + with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run: + await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx()) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs["anchors"] is None + + +def test_wildcard_resolves_oblast_city_sweep_sources() -> None: + """resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*".""" + registry = build_registry() + assert ( + resolve_handler("avito_city_sweep_nizhniy_tagil", registry) + is registry["avito_city_sweep_*"] + ) + assert resolve_handler("cian_city_sweep_serov", registry) is registry["cian_city_sweep_*"] + assert ( + resolve_handler("yandex_city_sweep_pervouralsk", registry) + is registry["yandex_city_sweep_*"] + ) + # EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry. + assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"] + assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py index ff5d661b..06e714a5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/geo.py @@ -14,6 +14,12 @@ from __future__ import annotations EKB_BBOX_TIGHT = (56.65, 56.95, 60.40, 60.85) EKB_BBOX_WIDE = (56.6, 57.1, 60.3, 60.9) +# Свердловская область целиком (region 66) — щедрый bbox под oblast-wide city-sweep +# (Н.Тагил/Каменск-Уральский/Первоуральск/В.Пышма/Серов, B1 rollout). Зеркалит +# `app.services.geocoder.OBLAST66_BBOX` дословно (та же (lat_min, lat_max, lon_min, +# lon_max)) — держим EKB_BBOX_TIGHT/WIDE отдельно для EKB-only fast-path (не заменяем). +OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6) + def is_within_ekb_bbox( lat: float, lon: float, bbox: tuple[float, float, float, float] = EKB_BBOX_TIGHT @@ -28,6 +34,18 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool: Используется для валидации координат из avito detail-страниц перед записью в БД. Шире geocoder-tight, поэтому не режет легитимное приграничье, но отсекает не-ЕКБ - (Питер/Тюмень/Уфа). + (Питер/Тюмень/Уфа). EKB-only fast-path — для oblast-wide sweep см. + `is_within_oblast66_bbox`. """ return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE) + + +def is_within_oblast66_bbox(lat: float, lon: float) -> bool: + """Ingest-guard: True если координаты внутри bbox всей Свердловской области (region 66). + + Oblast-wide аналог `is_within_ekb_bbox_wide` (B1 rollout — city-sweep за пределами ЕКБ: + Н.Тагил/Каменск-Уральский/Первоуральск/В.Пышма/Серов). Мирроит + `app.services.geocoder.is_within_oblast66_bbox` — щедрый bbox, всё ещё отсекает + кросс-региональные дубли (Тюмень/Курган и т.п. вне OBLAST66_BBOX). + """ + return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index b02ae072..0287a631 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -283,6 +283,33 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [ (56.8650, 60.6200, "Пионерский"), ] +# Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66). +# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules. +# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек +# на большой город), эти города компактнее — одного anchor'а достаточно. +# Domclick (BFF, city_id-based, НЕ anchors-based) сюда не входит — отдельный B2 rollout. +CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = { + "nizhniy_tagil": [(57.910, 59.980, "Н.Тагил центр")], + "kamensk_uralskiy": [(56.414, 61.918, "Каменск центр")], + "pervouralsk": [(56.908, 59.943, "Первоуральск центр")], + "verkhnyaya_pyshma": [(56.976, 60.578, "В.Пышма центр")], + "serov": [(59.604, 60.578, "Серов центр")], +} + + +def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] | None: + """Anchors для oblast-города по slug (CITY_ANCHORS). Неизвестный slug/None → None. + + None — сигнал caller'у (scheduler.py _job_*_city_sweep) падать обратно на EKB_ANCHORS + (run_avito_city_sweep/run_cian_city_sweep) либо на единственный центральный anchor ЕКБ + (run_yandex_city_sweep combos-mode) — см. `anchors if anchors is not None else + EKB_ANCHORS` в соответствующих run_*_city_sweep. + """ + if city_slug is None: + return None + return CITY_ANCHORS.get(city_slug) + + _CHROME_HEADERS = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 5f9b1953..463bd8b8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -38,6 +38,7 @@ from sqlalchemy import text from scraper_kit.orchestration import runs as _kit_runs from scraper_kit.orchestration.pipeline import ( + get_city_anchors, run_avito_city_sweep, run_avito_full_load, run_avito_newbuilding_sweep, @@ -440,6 +441,11 @@ def resolve_handler(source: str, registry: Mapping[str, Handler]) -> Handler | N async def _job_avito_city_sweep( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext ) -> None: + # #B1 oblast rollout: default_params["city"] (slug, e.g. "nizhniy_tagil") → anchors + # города вместо EKB_ANCHORS. Отсутствует/неизвестен → get_city_anchors вернёт None → + # run_avito_city_sweep сам падает на EKB_ANCHORS (прежнее поведение без city). + city = params.get("city") + anchors = get_city_anchors(city) if city else None await run_avito_city_sweep( db, run_id=run_id, @@ -448,6 +454,7 @@ async def _job_avito_city_sweep( enrichment=ctx.enrichment, shutdown_requested=ctx.shutdown_requested, proxy_provider=ctx.proxy_provider, + anchors=anchors, pages_per_anchor=int(params.get("pages_per_anchor", 3)), detail_top_n=int(params.get("detail_top_n", 20)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), @@ -513,6 +520,12 @@ async def _job_avito_full_load_exhaustive( async def _job_yandex_city_sweep( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext ) -> None: + # #B1 oblast rollout — см. _job_avito_city_sweep. Для yandex city None-anchors + # означает "единственный центральный ЕКБ anchor combos-mode" (run_yandex_city_sweep + # само подставляет (56.8400, 60.6050, "Центр (combos)") при anchors=None); заданный + # city заменяет его на anchors города (та же итерация anchors×combos, 1 anchor). + city = params.get("city") + anchors = get_city_anchors(city) if city else None await run_yandex_city_sweep( db, run_id=run_id, @@ -521,6 +534,7 @@ async def _job_yandex_city_sweep( enrichment=ctx.enrichment, shutdown_requested=ctx.shutdown_requested, proxy_provider=ctx.proxy_provider, + anchors=anchors, pages_per_anchor=int(params.get("pages_per_anchor", 2)), request_delay_sec=float(params.get("request_delay_sec", 9.0)), radius_m=int(params.get("radius_m", 1500)), @@ -532,6 +546,9 @@ async def _job_yandex_city_sweep( async def _job_cian_city_sweep( db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext ) -> None: + # #B1 oblast rollout — см. _job_avito_city_sweep. + city = params.get("city") + anchors = get_city_anchors(city) if city else None await run_cian_city_sweep( db, run_id=run_id, @@ -539,6 +556,7 @@ async def _job_cian_city_sweep( matcher=ctx.matcher, shutdown_requested=ctx.shutdown_requested, proxy_provider=ctx.proxy_provider, + anchors=anchors, pages_per_anchor=int(params.get("pages_per_anchor", 3)), request_delay_sec=float(params.get("request_delay_sec", 5.0)), radius_m=int(params.get("radius_m", 1500)), @@ -584,16 +602,29 @@ async def _job_domclick_city_sweep( def _default_kit_handlers() -> dict[str, Handler]: - """Встроенные kit-native sweep-обработчики (тело в scraper_kit.orchestration.pipeline).""" + """Встроенные kit-native sweep-обработчики (тело в scraper_kit.orchestration.pipeline). + + "*_city_sweep_*" — wildcard-семья per-city schedule'ов вне ЕКБ (B1 oblast rollout, + Свердловская область region 66). `scrape_schedules.source` UNIQUE не даёт нескольких + строк с одним и тем же source ("avito_city_sweep") на разные города — поэтому каждый + oblast-город получает СВОЙ source ("avito_city_sweep_nizhniy_tagil" и т.п., + data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql), а `resolve_handler` + резолвит их через wildcard-префикс (тот же механизм, что "deactivate_stale_*") на + ТОТ ЖЕ handler, что и ЕКБ-source — job читает default_params["city"] и подставляет + anchors города (см. _job_avito_city_sweep/_job_cian_city_sweep/_job_yandex_city_sweep). + """ return { "avito_city_sweep": Handler(_job_avito_city_sweep, "avito_city_sweep"), + "avito_city_sweep_*": Handler(_job_avito_city_sweep, "avito_city_sweep_*"), "avito_newbuilding_sweep": Handler(_job_avito_newbuilding_sweep, "avito_newbuilding_sweep"), "avito_full_load": Handler(_job_avito_full_load, "avito_full_load"), "avito_full_load_exhaustive": Handler( _job_avito_full_load_exhaustive, "avito_full_load_exhaustive" ), "yandex_city_sweep": Handler(_job_yandex_city_sweep, "yandex_city_sweep"), + "yandex_city_sweep_*": Handler(_job_yandex_city_sweep, "yandex_city_sweep_*"), "cian_city_sweep": Handler(_job_cian_city_sweep, "cian_city_sweep"), + "cian_city_sweep_*": Handler(_job_cian_city_sweep, "cian_city_sweep_*"), "cian_full_load": Handler(_job_cian_full_load, "cian_full_load"), "domclick_city_sweep": Handler(_job_domclick_city_sweep, "domclick_city_sweep"), } diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/price_brackets.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/price_brackets.py index f3c075fc..0ae31234 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/price_brackets.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/price_brackets.py @@ -24,6 +24,18 @@ _EKB_SECONDARY: list[tuple[int, int | None]] = [ CITY_PRICE_SEED_BRACKETS: dict[str, list[tuple[int, int | None]]] = { "ekaterinburg": _EKB_SECONDARY, + # B1 oblast rollout (Свердловская область, region 66) — Н.Тагил/Каменск-Уральский/ + # Первоуральск/В.Пышма/Серов вторичка дешевле ЕКБ (меньше 17-25М+ сегмента), но + # отдельного per-city анализа прод-распределения пока не делали (сами schedule'ы + # ship disabled — данных для брекетов ещё нет). Временно реюзаем EKB-брекеты как + # безопасный upper-bound-охват (get_price_seed_brackets и так фоллбэчит на + # ekaterinburg для незнакомых городов — эти entries делают выбор ЯВНЫМ, не implicit + # fallback, чтобы легче было найти и заменить, когда появится реальная статистика). + "nizhniy_tagil": _EKB_SECONDARY, + "kamensk_uralskiy": _EKB_SECONDARY, + "pervouralsk": _EKB_SECONDARY, + "verkhnyaya_pyshma": _EKB_SECONDARY, + "serov": _EKB_SECONDARY, } diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py index 5d08b524..a311699a 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/__init__.py @@ -10,6 +10,7 @@ Развязка от `app.*`: - `app.core.config.settings` → инжектируемый `ScraperConfig` (scraper_kit.contracts) - `app.services.scraper_settings.get_scraper_delay` → инжектируемый `delay_provider` - - `app.services.geocoder.is_within_ekb_bbox_wide` → `scraper_kit.geo` + - `app.services.geocoder.is_within_oblast66_bbox` → `scraper_kit.geo` (было is_within_ekb_bbox_wide + до B1 oblast rollout — детекция координат теперь oblast-wide, не только ЕКБ) - `app.services.matching.houses.match_or_create_house` → инжектируемый `HouseMatcher` """ diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py index 9a901319..915c5502 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py @@ -39,7 +39,7 @@ from scraper_kit.avito_exceptions import ( AvitoListingGoneError, AvitoRateLimitedError, ) -from scraper_kit.geo import is_within_ekb_bbox_wide +from scraper_kit.geo import is_within_oblast66_bbox from scraper_kit.providers._base import build_document_session from scraper_kit.providers.avito.serp import _clean_address, _is_firewall_page from scraper_kit.providers.avito.shared import RUS_MONTHS @@ -562,14 +562,17 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment: lon = _try_float(map_el.attributes.get("data-map-lon")) avito_location_id = _try_int(map_el.attributes.get("data-location-id")) - # bbox-guard (#1871): detail-страница иногда отдаёт координаты вне ЕКБ - # (Питер/Тюмень/Уфа — кросс-региональные дубли объявлений). Эти координаты - # попадают в UPDATE COALESCE без bbox-валидации (в отличие от geocoder.py, - # где bbox-фильтр везде). Сбрасываем в None → листинг уходит в geocode-cron - # путь, который сам отфильтрует по адресу. SERP-ingest координат не даёт вовсе. - if lat is not None and lon is not None and not is_within_ekb_bbox_wide(lat, lon): + # bbox-guard (#1871, расширен до oblast66 — B1 rollout): detail-страница иногда + # отдаёт координаты вне Свердловской области (Питер/Тюмень/Уфа — кросс-региональные + # дубли объявлений). Эти координаты попадают в UPDATE COALESCE без bbox-валидации + # (в отличие от geocoder.py, где bbox-фильтр везде). Сбрасываем в None → листинг + # уходит в geocode-cron путь, который сам отфильтрует по адресу. SERP-ingest + # координат не даёт вовсе. Guard расширен с EKB-only (is_within_ekb_bbox_wide) до + # oblast-wide (is_within_oblast66_bbox), чтобы не резать легитимные Н.Тагил/Каменск/ + # Первоуральск/В.Пышма/Серов координаты (city-sweep за пределами ЕКБ). + if lat is not None and lon is not None and not is_within_oblast66_bbox(lat, lon): logger.warning( - "avito_detail #1871: dropped non-EKB coords item=%s lat=%s lon=%s", + "avito_detail #1871: dropped non-oblast66 coords item=%s lat=%s lon=%s", item_id, lat, lon,