gendesign/tradein-mvp/backend/tests/test_msk_sweep_registry.py
lekss361 6da7de06a4
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m50s
Deploy Trade-In / build-backend (push) Successful in 1m49s
Deploy Trade-In / deploy (push) Successful in 4m41s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 1m44s
Черновик регулярного сбора по Москве и области: якоря, ID площадок, гео-профили ДомКлика (#3533)
ДомКлик: DomClickGeoProfile вместо зашитых _EKB_ADDRESS_GUID/_EKB_AREA_ID; GUID Москвы
и области проверены живьём, aids вне ЕКБ не нужен, гард по bbox профиля. Страница
прогрева 77/50 — апексный domclick.ru: субдомены msk./moskovskaya-oblast. отдают 301.

Циан/Авито/Яндекс: CityLocation.cian_host, три новых скоупа (moskva, moskovskaya_oblast,
moskva_i_mo), avito_slug_is_region, city=NULL у мультигородских скоупов. Неизвестный слаг
теперь падает с ValueError вместо молчаливого отката на Екатеринбург.

Якоря: Москва — сетка 25 точек под radius_m=8000; область — 22 города-спутника
(10 добраны из Nominatim) плюс 22 кластера лот-массы. Замер на проде: города радиусом
10 км дают 73.8% лот-массы области, вместе с кластерами — 95.2%.

Ценовые коридоры: планировщик plan_price_corridors со статистикой усечения плюс
BisectionStats в живом движке. Провайдеры их пока не передают — отдельный заход.

Расписаний scrape_schedules для 77/50 в этом PR нет: они пойдут после первого ручного
прогона, подтверждающего живость профилей.
2026-09-15 17:51:30 +00:00

233 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей.
Черновик 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")