From e4c1e9dab3673e164f6cff91360c656345af723b Mon Sep 17 00:00:00 2001 From: bot-backend Date: Tue, 15 Sep 2026 09:58:38 +0300 Subject: [PATCH] =?UTF-8?q?fix(scraper-kit):=20=D1=81=D0=B2=D0=B8=D0=BF=20?= =?UTF-8?q?=D1=81=20=D1=87=D1=83=D0=B6=D0=B8=D0=BC=20region=5Fcode=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D0=B5=D1=82=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20=D1=82=D0=B8=D1=85=D0=BE=D0=B3=D0=BE=20=D1=81?= =?UTF-8?q?=D0=B1=D0=BE=D1=80=D0=B0=20=D0=95=D0=9A=D0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расписание с region_code 77/50 (#3515 научил планировщик читать регион из строки) задаёт регион ШТАМПА, но не гео-скоуп прогона. У avito/cian/yandex скоуп — anchors города из CITY_ANCHORS, а этот реестр покрывает только Свердловскую область: строка вида avito_city_sweep_moscow с {"region_code": 77} откатилась бы на EKB_ANCHORS, собрала Екатеринбург и записала его как Москву — со штатными счётчиками и без аномалий в логах. У domclick скоуп зашит ещё жёстче: providers/domclick/serp.py строит BFF-URL с _EKB_ADDRESS_GUID/_EKB_AREA_ID и греет контекст на ekaterinburg.domclick.ru, а city_id из default_params fetch_city игнорирует (vestigial) — там ЕКБ собрался бы при любом region_code. _require_region_scope: регион != 66 без явных anchors → ValueError до первого запроса. Вызовы в run_avito/cian/yandex_city_sweep; у domclick отдельный гард на region_code (anchors у него нет вовсе), стоящий в pipeline — поэтому он общий и для kit-native job'а, и для продуктового дубля с куками Sber ID. Явные anchors для чужого региона гард пропускает: запрещён не регион, а молчаливый ЕКБ-fallback. Москва и МО попали в прод-корпус разовым ручным коллектором (scripts/local-avito-msk/collect.py -> msk_raw -> app.tasks.msk_raw_import), а не свипами; регулярный сбор по ним требует сперва завести города в CITY_ANCHORS/CITY_LOCATIONS, поэтому строк расписаний эта ветка не заводит. --- .../tests/test_msk_region_scope_guard.py | 81 +++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 47 +++++++++++ 2 files changed, 128 insertions(+) create mode 100644 tradein-mvp/backend/tests/test_msk_region_scope_guard.py diff --git a/tradein-mvp/backend/tests/test_msk_region_scope_guard.py b/tradein-mvp/backend/tests/test_msk_region_scope_guard.py new file mode 100644 index 00000000..b65522cb --- /dev/null +++ b/tradein-mvp/backend/tests/test_msk_region_scope_guard.py @@ -0,0 +1,81 @@ +"""Гард гео-скоупа свипов: `region_code` 77/50 не должен молча собирать Екатеринбург. + +Контекст: планировщик читает регион из строки расписания (#3515, +`scheduler._resolve_region_code`), но гео-скоуп свипа задаётся НЕ им — у avito/cian/ +yandex это anchors города (`CITY_ANCHORS`/`CITY_LOCATIONS`, сегодня только +Свердловская обл.), у domclick — зашитый в BFF-URL GUID Екатеринбурга. Заведи мы +строку `*_city_sweep_moscow` с `{"region_code": 77}` — прогон собрал бы ЕКБ и +проштамповал его московским регионом, не показав ни одной аномалии в счётчиках. +Гард превращает эту тихую порчу в падение до первого запроса. +""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from typing import Any + +import pytest +from scraper_kit.orchestration.pipeline import ( + _require_region_scope, + run_avito_city_sweep, + run_cian_city_sweep, + run_domclick_city_sweep, + run_yandex_city_sweep, +) + +_EKB_ANCHOR = [(56.8400, 60.6050, "Центр")] + + +def test_default_region_without_anchors_passes() -> None: + """ЕКБ-прогоны (anchors=None, регион по умолчанию 66) не трогаем.""" + _require_region_scope("avito_city_sweep", 66, None) + + +def test_default_region_with_city_anchors_passes() -> None: + """Свердловские oblast-строки (#179/#262) продолжают работать как прежде.""" + _require_region_scope("cian_city_sweep", 66, [(57.910, 59.980, "Н.Тагил центр")]) + + +@pytest.mark.parametrize("region_code", [50, 77]) +def test_foreign_region_without_anchors_raises(region_code: int) -> None: + """Москва/МО без своих anchors — тот самый случай, что молча давал ЕКБ.""" + with pytest.raises(ValueError, match=f"region_code={region_code}"): + _require_region_scope("avito_city_sweep", region_code, None) + + +@pytest.mark.parametrize("region_code", [50, 77]) +def test_foreign_region_with_explicit_anchors_passes(region_code: int) -> None: + """Явные anchors — явный гео-скоуп: запрещаем не регион, а молчаливый ЕКБ-fallback.""" + _require_region_scope("avito_city_sweep", region_code, _EKB_ANCHOR) + + +def _required_kwargs(fn: Callable[..., Any], **overrides: Any) -> dict[str, Any]: + """None во все обязательные keyword-параметры: гард обязан сработать раньше их.""" + kwargs: dict[str, Any] = { + name: None + for name, prm in inspect.signature(fn).parameters.items() + if prm.kind is inspect.Parameter.KEYWORD_ONLY and prm.default is inspect.Parameter.empty + } + kwargs.update(overrides) + return kwargs + + +@pytest.mark.parametrize( + "sweep", + [run_avito_city_sweep, run_cian_city_sweep, run_yandex_city_sweep], +) +async def test_anchor_sweeps_reject_foreign_region(sweep: Callable[..., Any]) -> None: + """Гард стоит до первого обращения к БД/сети — поэтому db=None здесь безопасен.""" + with pytest.raises(ValueError, match="region_code=77"): + await sweep(None, **_required_kwargs(sweep, run_id=1, region_code=77, anchors=None)) + + +@pytest.mark.parametrize("region_code", [50, 77]) +async def test_domclick_sweep_rejects_foreign_region(region_code: int) -> None: + """У домклика anchors нет вовсе — гео-скоуп зашит в GUID, поэтому режем регион.""" + with pytest.raises(ValueError, match=f"region_code={region_code}"): + await run_domclick_city_sweep( + None, # type: ignore[arg-type] + **_required_kwargs(run_domclick_city_sweep, run_id=1, region_code=region_code), + ) 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 dcee421d..245c952a 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 @@ -1124,6 +1124,36 @@ class CitySweepCounters: return {f.name: getattr(self, f.name) for f in fields(self)} +def _require_region_scope( + source_kind: str, + region_code: int, + anchors: list[tuple[float, float, str]] | None, +) -> None: + """Регион вне 66 обязан приходить СО СВОИМИ anchors — иначе это тихий сбор ЕКБ. + + `anchors=None` у всех трёх anchor-свипов означает "ЕКБ по умолчанию" + (EKB_ANCHORS у avito/cian, центральная точка combos-режима у yandex). Планировщик + берёт anchors из `CITY_ANCHORS[default_params["city"]]`, а этот реестр сегодня + покрывает только Свердловскую область, поэтому строка расписания вида + `avito_city_sweep_moscow` с `{"region_code": 77}` собрала бы ЕКАТЕРИНБУРГ и + проштамповала его московским регионом — со штатными счётчиками и без единой + аномалии в логах. Падаем до первого запроса. + + Москва (77) и МО (50) попали в прод-корпус разовым ручным коллектором + (`scripts/local-avito-msk/collect.py` → схема `msk_raw` → `app.tasks.msk_raw_import`), + а не свипами. Регулярный сбор по ним требует сперва завести города в + CITY_ANCHORS/CITY_LOCATIONS (anchors + per-provider идентификаторы), и только + потом строки расписаний. + """ + if region_code == DEFAULT_REGION_CODE or anchors: + return + raise ValueError( + f"{source_kind}: region_code={region_code} без anchors города — развёртка " + "откатилась бы на ЕКБ и проштамповала его чужим регионом. Заведи город в " + "CITY_ANCHORS/CITY_LOCATIONS (и передай anchors) либо убери region_code." + ) + + async def run_avito_city_sweep( db: Session, *, @@ -1164,6 +1194,7 @@ async def run_avito_city_sweep( # AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города, # а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ. # None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже). + _require_region_scope("avito_city_sweep", region_code, anchors) _anchors = anchors if anchors is not None else EKB_ANCHORS # ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ────────────────────── @@ -2258,6 +2289,7 @@ async def run_yandex_city_sweep( Возвращает YandexCitySweepCounters. mark_done вызывается ВСЕГДА (кроме cancel/ban/fatal). """ + _require_region_scope("yandex_city_sweep", region_code, anchors) # EKB center — единственный anchor для citywide combos sweep. # anchors-параметр принимается для backward-compat тестов. if anchors is not None: @@ -2904,6 +2936,7 @@ async def run_cian_city_sweep( Cooperative cancel: runs.is_cancelled(db, run_id) перед каждым anchor. mark_done вызывается ВСЕГДА (finally outer). """ + _require_region_scope("cian_city_sweep", region_code, anchors) _anchors = anchors if anchors is not None else EKB_ANCHORS # ── Checkpoint/resume (#3074): единица обхода — ЯКОРЬ ────────────────────── @@ -4522,6 +4555,20 @@ async def run_domclick_city_sweep( Возвращает DomClickCitySweepCounters. """ + # Гео-скоуп этого свипа ЗАШИТ в ЕКБ: providers/domclick/serp.py строит BFF-URL с + # _EKB_ADDRESS_GUID/_EKB_AREA_ID и прогревает контекст на _SWEEP_ORIGIN + # (ekaterinburg.domclick.ru), а city_id — vestigial, fetch_city его игнорирует. + # Строка расписания с region_code 77/50 поэтому собрала бы ЕКБ и проштамповала его + # чужим регионом — молча, со штатными счётчиками. Падаем до первого запроса; гард + # снимается вместе с параметризацией GUID/aids/origin по региону (GUID Москвы и МО + # известны — scripts/local-avito-msk/collect.py MSK_DOMCLICK_GUID/MO_DOMCLICK_GUID). + if region_code != DEFAULT_REGION_CODE: + raise ValueError( + f"domclick_city_sweep: region_code={region_code} не поддержан — гео-скоуп " + "BFF-запроса зашит в ЕКБ (address GUID / aids / origin прогрева), прогон " + "собрал бы ЕКБ под чужим регионом" + ) + _resolved_delay = request_delay_sec if request_delay_sec is not None else 6.0 counters = DomClickCitySweepCounters() -- 2.45.3