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
ДомКлик: 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 нет: они пойдут после первого ручного прогона, подтверждающего живость профилей.
233 lines
11 KiB
Python
233 lines
11 KiB
Python
"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей.
|
||
|
||
Черновик 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")
|