"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей. Черновик msk-rollout: снимаем привязку avito/cian/yandex к Екатеринбургу. Тесты здесь чисто реестровые (никаких сетевых вызовов): проверяют, что * ЕКБ-путь не сдвинулся ни на байт (хост, слаг, rgid, region=), * Москва/область/объединённый скоуп дают ровно измеренные живьём значения, * сетка якорей Москвы лежит внутри продуктового ядра и покрывает его без дыр, * неизвестный слаг ПАДАЕТ, а не откатывается молча на Екатеринбург. """ from __future__ import annotations import math from types import SimpleNamespace import pytest from scraper_kit.orchestration.pipeline import ( CIAN_MOSCOW_HOST, CIAN_SVERDLOVSK_HOST, CITY_LOCATIONS, MOSCOW_ANCHOR_RADIUS_M, MOSCOW_ANCHORS, MOSCOW_CORE_BBOX_DEG, MOSCOW_OBLAST_ANCHOR_RADIUS_M, MOSCOW_OBLAST_CITY_ANCHORS, MOSCOW_OBLAST_CLUSTER_ANCHORS, MULTI_CITY_SLUGS, get_city_anchor_point, get_city_anchors, get_city_location, get_city_stamp_radius_km, resolve_city_name, ) from scraper_kit.providers.avito.serp import AvitoScraper from scraper_kit.providers.cian.serp import CianScraper from scraper_kit.providers.yandex.serp import YandexRealtyScraper def _cfg() -> SimpleNamespace: """Минимальный ScraperConfig-стаб: конструкторам scraper'ов хватает атрибутов.""" return SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http") def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: r = 6371.0 p1, p2 = math.radians(lat1), math.radians(lat2) dp = math.radians(lat2 - lat1) dl = math.radians(lon2 - lon1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * r * math.asin(math.sqrt(a)) # ── ЕКБ не сдвинулся ──────────────────────────────────────────────────────── def test_ekb_cian_host_and_region_byte_identical() -> None: """ЕКБ идёт по None-пути (get_city_location(None) → None) — хост класса и region= остаются прежними: ekb.cian.ru + region=4743.""" assert get_city_location(None) is None assert CianScraper.base_url == "https://ekb.cian.ru" == CIAN_SVERDLOVSK_HOST scraper = CianScraper(_cfg(), city_region_id=None, city_host=None) # type: ignore[arg-type] url = scraper._build_url() assert url.startswith("https://ekb.cian.ru/cat.php?") assert "region=4743" in url def test_ekb_avito_slug_and_card_filter_unchanged() -> None: """Без target_city_slug путь URL остаётся /ekaterinburg/, фильтр карточек включён.""" scraper = AvitoScraper(_cfg()) # type: ignore[arg-type] assert scraper._city_seg() == "ekaterinburg" assert scraper._filter_cards_by_city_slug is True def test_ekb_yandex_rgid_unchanged() -> None: scraper = YandexRealtyScraper(_cfg(), city_rgid=None) # type: ignore[arg-type] assert scraper._rgid == 559132 def test_sverdlovsk_entries_keep_default_host() -> None: """Все свердловские записи остаются на ekb.cian.ru — поле добавлено с дефолтом.""" for slug, loc in CITY_LOCATIONS.items(): if slug in {"moskva", "moskovskaya_oblast", "moskva_i_mo"}: continue assert loc.cian_host == CIAN_SVERDLOVSK_HOST, slug assert loc.avito_slug_is_region is False, slug # ── Москва / область / объединённый скоуп ─────────────────────────────────── @pytest.mark.parametrize( ("slug", "region_id", "rgid"), [ ("moskva", 1, 587795), ("moskovskaya_oblast", 4593, 587654), ("moskva_i_mo", -1, 741964), ], ) def test_moscow_scopes_ids(slug: str, region_id: int, rgid: int) -> None: """Замер 10-12.09.2026: region=1 Москва, 4593 область, -1 обе вместе.""" loc = get_city_location(slug) assert loc is not None assert loc.cian_region_id == region_id assert loc.yandex_rgid == rgid assert loc.cian_host == CIAN_MOSCOW_HOST == "https://www.cian.ru" # avito: ОДИН слаг на оба региона, отдельного слага у области нет. assert loc.avito_slug == "moskva_i_mo" assert loc.avito_slug_is_region is True scraper = CianScraper( # type: ignore[arg-type] _cfg(), city_region_id=loc.cian_region_id, city_host=loc.cian_host ) url = scraper._build_url() assert url.startswith("https://www.cian.ru/cat.php?") assert f"region={region_id}" in url def test_moscow_region_slug_disables_card_filter() -> None: """Слаг региона в ссылках карточек не встречается → per-card фильтр выключается, иначе отфильтровалась бы вся выдача (avito_serp_ekb_only=True в конфиге).""" loc = get_city_location("moskva") assert loc is not None scraper = AvitoScraper( # type: ignore[arg-type] _cfg(), target_city_slug=loc.avito_slug, filter_cards_by_city_slug=not loc.avito_slug_is_region, ) assert scraper._city_seg() == "moskva_i_mo" assert scraper._filter_cards_by_city_slug is False def test_multi_city_scopes_do_not_stamp_a_city() -> None: """У области и объединённого скоупа город в batch'е не один — штампа быть не должно.""" assert MULTI_CITY_SLUGS == frozenset({"moskovskaya_oblast", "moskva_i_mo"}) assert resolve_city_name("moskovskaya_oblast") is None assert resolve_city_name("moskva_i_mo") is None assert resolve_city_name("moskva") == "Москва" assert resolve_city_name(None) == "Екатеринбург" # ── сетка якорей Москвы ───────────────────────────────────────────────────── def test_moscow_grid_inside_core_and_centered() -> None: lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG assert len(MOSCOW_ANCHORS) == 25 for lat, lon, name in MOSCOW_ANCHORS: assert lat_min <= lat <= lat_max, name assert lon_min <= lon <= lon_max, name # anchors[0] — центр ядра: именно его берёт гео-гард штампа. assert get_city_anchor_point("moskva") == (55.75, 37.60) def test_moscow_grid_covers_core_without_holes() -> None: """Любая точка ядра не дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего якоря.""" lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG radius_km = MOSCOW_ANCHOR_RADIUS_M / 1000.0 worst = 0.0 lat_steps = round((lat_max - lat_min) / 0.01) lon_steps = round((lon_max - lon_min) / 0.01) for i in range(lat_steps + 1): lat = lat_min + i * 0.01 for j in range(lon_steps + 1): lon = lon_min + j * 0.01 nearest = min( _haversine_km(lat, lon, a_lat, a_lon) for a_lat, a_lon, _ in MOSCOW_ANCHORS ) worst = max(worst, nearest) assert worst <= radius_km, f"дыра в покрытии: худшая точка ядра в {worst:.2f} км от якоря" def test_moscow_oblast_anchors_are_satellite_cities() -> None: """У области — по якорю на город-спутник, координаты из реестра репозитория.""" anchors = get_city_anchors("moskovskaya_oblast") assert anchors is not None names = [name for _lat, _lon, name in anchors] assert "Балашиха" in names and "Химки" in names and "Подольск" in names assert len(names) == len(set(names)) combined = get_city_anchors("moskva_i_mo") assert combined is not None assert len(combined) == len(MOSCOW_ANCHORS) + len(anchors) def test_moscow_oblast_anchors_are_two_measured_layers() -> None: """Оба слоя на месте и не перепутаны: города-спутники + кластеры лот-массы.""" anchors = get_city_anchors("moskovskaya_oblast") assert anchors is not None assert anchors == [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS] # Слой 2 добирает то, чего не дают города: без него покрытие лот-массы 73.8%. assert len(MOSCOW_OBLAST_CLUSTER_ANCHORS) > 0 cities = {name for _lat, _lon, name in MOSCOW_OBLAST_CITY_ANCHORS} clusters = {name for _lat, _lon, name in MOSCOW_OBLAST_CLUSTER_ANCHORS} assert not (cities & clusters), "подписи слоёв пересеклись — якорь задублирован" def test_moscow_oblast_anchors_lie_inside_region_50_bbox() -> None: """Якорь вне области собрал бы чужой регион и проштамповал бы его как 50. Границы — bbox_region региона 50 из backend/app/services/regions.py (дубль: kit не имеет права импортировать app.*). """ lat_min, lat_max, lon_min, lon_max = 54.20, 56.96, 35.14, 40.21 for lat, lon, name in [*MOSCOW_OBLAST_CITY_ANCHORS, *MOSCOW_OBLAST_CLUSTER_ANCHORS]: assert lat_min <= lat <= lat_max, f"{name}: широта вне области" assert lon_min <= lon <= lon_max, f"{name}: долгота вне области" def test_moscow_oblast_radius_is_the_one_coverage_was_measured_at() -> None: """95.2% лот-массы посчитаны при 10 км; меньший radius_m в расписании даст дыры.""" assert MOSCOW_OBLAST_ANCHOR_RADIUS_M == 10000 assert MOSCOW_OBLAST_ANCHOR_RADIUS_M > MOSCOW_ANCHOR_RADIUS_M def test_moscow_stamp_radius_covers_core_diagonal() -> None: """15 км по умолчанию отрезали бы легитимную Москву — у ядра полудиагональ 29.1 км.""" _lat_min, lat_max, _lon_min, lon_max = MOSCOW_CORE_BBOX_DEG center = get_city_anchor_point("moskva") assert center is not None corner = _haversine_km(center[0], center[1], lat_max, lon_max) assert corner > 15.0 assert get_city_stamp_radius_km("moskva") >= corner # область/объединённый скоуп штампа не ставят вовсе (city=None → гард инертен). assert resolve_city_name("moskovskaya_oblast") is None # ── неизвестный слаг падает ───────────────────────────────────────────────── @pytest.mark.parametrize("fn", [get_city_anchors, get_city_location, resolve_city_name]) def test_unknown_slug_raises_instead_of_ekb_fallback(fn) -> None: # type: ignore[no-untyped-def] with pytest.raises(ValueError, match="nonexistent_city"): fn("nonexistent_city")