Свип с чужим регионом падает вместо тихого сбора Екатеринбурга #3525

Merged
lekss361 merged 1 commit from feat/msk-mo-scrape-schedules into main 2026-09-15 07:09:06 +00:00
2 changed files with 128 additions and 0 deletions

View file

@ -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),
)

View file

@ -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()