diff --git a/tradein-mvp/backend/tests/test_city_sweep.py b/tradein-mvp/backend/tests/test_city_sweep.py index e138a150..a848c2d5 100644 --- a/tradein-mvp/backend/tests/test_city_sweep.py +++ b/tradein-mvp/backend/tests/test_city_sweep.py @@ -54,7 +54,13 @@ def test_resolve_city_name_known_oblast_slugs() -> None: "verkhnyaya_pyshma": "Верхняя Пышма", "serov": "Серов", } - assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) + # msk-rollout: многогородние скоупы (MULTI_CITY_SLUGS — область и + # «Москва+область») намеренно БЕЗ display-имени: в их batch'ах городов + # несколько, и любой один штамп city был бы ложью — resolve_city_name + # отдаёт для них None, а save_listings пишет city=NULL. + from scraper_kit.orchestration.pipeline import MULTI_CITY_SLUGS + + assert set(CITY_DISPLAY_NAMES) == set(CITY_LOCATIONS) - MULTI_CITY_SLUGS assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items() for slug, name in wave1_expected.items(): assert resolve_city_name(slug) == name @@ -76,11 +82,19 @@ def test_resolve_city_name_none_defaults_to_ekaterinburg() -> None: assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург" -def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: - """Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors.""" +def test_resolve_city_name_unknown_slug_raises() -> None: + """Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург». + + Раньше здесь был ЕКБ-дефолт — симметрично get_city_location/get_city_anchors. + Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы, + что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города, + со штатными счётчиками и без единой аномалии в логах. + """ + import pytest from scraper_kit.orchestration.pipeline import resolve_city_name - assert resolve_city_name("nonexistent_city") == "Екатеринбург" + with pytest.raises(ValueError, match="nonexistent_city"): + resolve_city_name("nonexistent_city") # ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный diff --git a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py index 492fdb1c..261d6993 100644 --- a/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py +++ b/tradein-mvp/backend/tests/test_migration_262_oblast_city_sweeps_wave2.py @@ -95,6 +95,13 @@ _WAVE2_SLUGS = { assert len(_WAVE2_SLUGS) == 40 assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) +# msk-rollout (черновик): Москва, область и объединённый скоуп заведены в +# CITY_ANCHORS/CITY_LOCATIONS, но строк расписаний у них СОЗНАТЕЛЬНО нет — +# реестры едут отдельным заходом от включения сбора. Проверки соответствия +# «реестр ↔ schedule row» ниже — про свердловскую волну #262, поэтому +# московские скоупы из них вычитаются явно (а не тихо расширяют ожидания). +_MSK_SCOPES = {"moskva", "moskovskaya_oblast", "moskva_i_mo"} + def _sql(path: Path) -> str: return path.read_text(encoding="utf-8") @@ -262,7 +269,7 @@ def test_every_confirmed_avito_slug_has_a_schedule_row_and_vice_versa() -> None: scheduled_avito = rows_262 | rows_179 confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} - assert scheduled_avito == confirmed_avito, ( + assert scheduled_avito == confirmed_avito - _MSK_SCOPES, ( f"missing schedule rows: {confirmed_avito - scheduled_avito}; " f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_avito}" ) @@ -274,7 +281,7 @@ def test_every_confirmed_yandex_rgid_has_a_schedule_row_and_vice_versa() -> None scheduled_yandex = rows_262 | rows_179 confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} - assert scheduled_yandex == confirmed_yandex, ( + assert scheduled_yandex == confirmed_yandex - _MSK_SCOPES, ( f"missing schedule rows: {confirmed_yandex - scheduled_yandex}; " f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_yandex}" ) @@ -288,7 +295,7 @@ def test_every_confirmed_cian_region_id_has_a_schedule_row_and_vice_versa() -> N confirmed_cian = { slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None } - assert scheduled_cian == confirmed_cian, ( + assert scheduled_cian == confirmed_cian - _MSK_SCOPES, ( f"missing schedule rows: {confirmed_cian - scheduled_cian}; " f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_cian}" ) @@ -331,7 +338,7 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None: seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | { slug for _p, slug in _row_sources(_MIGRATION_262) } - orphaned = set(CITY_ANCHORS) - seeded_slugs + orphaned = set(CITY_ANCHORS) - seeded_slugs - _MSK_SCOPES assert not orphaned, ( f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(orphaned)}" ) @@ -340,8 +347,8 @@ def test_city_anchors_has_no_slug_without_schedule_rows() -> None: def test_city_anchors_wave2_count_and_content() -> None: """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" - assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS - assert len(CITY_ANCHORS) == 45 + assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES + assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES) def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: diff --git a/tradein-mvp/backend/tests/test_msk_sweep_registry.py b/tradein-mvp/backend/tests/test_msk_sweep_registry.py new file mode 100644 index 00000000..ce44a28e --- /dev/null +++ b/tradein-mvp/backend/tests/test_msk_sweep_registry.py @@ -0,0 +1,200 @@ +"""Москва и МО в реестрах развёрток: хост Циана, идентификаторы, сетка якорей. + +Черновик 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, + 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_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") 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 f7317d43..2fad6c5a 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 @@ -282,6 +282,90 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [ (56.8650, 60.6200, "Пионерский"), ] + +# ── Москва (77) и Московская область (50): якоря развёрток ────────────────── +# +# Продуктовое ядро Москвы — то же окно, что у REGIONS[77].bbox_product_core в +# backend/app/services/regions.py: lat 55.55..55.95, lon 37.30..37.90. Это ~44.5 км +# с юга на север и ~37.6 км с запада на восток (на широте 55.75° градус долготы = +# 62.6 км). Один центральный anchor, как у компактных городов области, покрыл бы +# отсюда круг радиусом 8 км — меньше пятой части ядра. Поэтому сетка. +MOSCOW_CORE_BBOX_DEG: tuple[float, float, float, float] = (55.55, 55.95, 37.30, 37.90) + +# Радиус ОДНОГО anchor-запроса московских развёрток. 8 км — не оценка на глаз: +# столько намерено на прод-корпусе (35 552 активных вторичных лота region_code=77) +# у московской сетки центроидов, см. большой комментарий над `_MOSCOW_GRID_DEG` в +# backend/app/api/v1/trade_in.py (R=5..8 дают одинаковые 0.00% вне радиуса, при +# R=10 внутрь входит уже Наро-Фоминск). Кит не имеет права импортировать app.* +# (гейтится тестом), поэтому здесь осознанный дубль, а не второе независимое число. +# +# ВАЖНО: `radius_m` у run_avito_city_sweep/run_cian_city_sweep по умолчанию 1500 — +# строка расписания московской развёртки обязана передать 8000 явно, иначе круги +# соседних узлов не сомкнутся и между ними останутся непокрытые полосы. +MOSCOW_ANCHOR_RADIUS_M = 8000 + +# Шаг сетки согласован именно с этим радиусом: любая точка ядра должна лежать не +# дальше MOSCOW_ANCHOR_RADIUS_M от ближайшего узла. Худшая точка — центр ячейки, +# расстояние до узла = hypot(шаг_lat/2, шаг_lon/2). При 0.10° по широте (11.13 км) +# и 0.15° по долготе (9.39 км) это hypot(5.56, 4.69) = 7.27 км < 8 км — с запасом +# 0.7 км. Узлы стоят по границам окна включительно (55.55/55.65/…/55.95 × +# 37.30/37.45/…/37.90): 5 × 5 = 25 якорей, все внутри ядра, ни один не свисает +# наружу. Крупнее (0.15° × 0.20°) дало бы полудиагональ 10.5 км — дыры в покрытии; +# мельче — лишние прогоны страниц без нового покрытия. +_MOSCOW_GRID_LAT_STEP_DEG = 0.10 +_MOSCOW_GRID_LON_STEP_DEG = 0.15 +# Центр ядра сам является узлом сетки, и он же ПЕРВЫЙ в списке: get_city_anchor_point +# берёт anchors[0] опорной точкой гео-гарда штампа (см. её docstring), а из угла ядра +# гард мерил бы диагональ в 58 км и перестал бы что-либо отсекать. +_MOSCOW_CENTER_DEG: tuple[float, float] = (55.75, 37.60) + + +def _build_moscow_anchor_grid() -> list[tuple[float, float, str]]: + """Сетка якорей ядра Москвы: центр первым, остальные узлы — с юго-запада на север.""" + lat_min, lat_max, lon_min, lon_max = MOSCOW_CORE_BBOX_DEG + lat_steps = round((lat_max - lat_min) / _MOSCOW_GRID_LAT_STEP_DEG) + lon_steps = round((lon_max - lon_min) / _MOSCOW_GRID_LON_STEP_DEG) + grid: list[tuple[float, float, str]] = [] + for i in range(lat_steps + 1): + lat = round(lat_min + i * _MOSCOW_GRID_LAT_STEP_DEG, 4) + for j in range(lon_steps + 1): + lon = round(lon_min + j * _MOSCOW_GRID_LON_STEP_DEG, 4) + grid.append((lat, lon, f"Москва {lat:.2f}/{lon:.2f}")) + # sort стабилен: центр уезжает в начало, порядок остальных не меняется. + grid.sort(key=lambda a: (a[0], a[1]) != _MOSCOW_CENTER_DEG) + return grid + + +MOSCOW_ANCHORS: list[tuple[float, float, str]] = _build_moscow_anchor_grid() + +# Московская область: по якорю на крупный город-спутник. Координаты — WGS84-центры +# НП, взятые из ЕДИНСТВЕННОГО места репозитория, где они уже есть и перепроверены +# замером: `_COVERAGE_NEGATIVE_CENTROIDS_DEG` в backend/app/api/v1/trade_in.py +# (тот же запрет на импорт app.* — значения продублированы, править нужно оба места). +# +# TODO(msk): порядок — по величине города, а НЕ по числу лотов: замера «сколько +# лотов у какого города области» (в области всего 60 231 лот, cian region=4593) нет +# ни в репозитории, ни в прод-корпусе, а гадать ранжирование нечем. Крупные города +# области, которых нет в источнике координат (Домодедово, Раменское, Щёлково, +# Сергиев Посад, Ногинск, Электросталь, Серпухов, Коломна, Орехово-Зуево, +# Наро-Фоминск), СОЗНАТЕЛЬНО не заведены — выдуманные координаты хуже отсутствующих; +# добавлять их следует вместе с записью в _COVERAGE_NEGATIVE_CENTROIDS_DEG по общему +# замеру. +MOSCOW_OBLAST_ANCHORS: list[tuple[float, float, str]] = [ + (55.7969, 37.9386, "Балашиха"), + (55.8894, 37.4450, "Химки"), + (55.4312, 37.5450, "Подольск"), + (55.9116, 37.7308, "Мытищи"), + (55.9142, 37.8256, "Королёв"), + (55.6767, 37.8931, "Люберцы"), + (55.8317, 37.3300, "Красногорск"), + (55.6789, 37.2639, "Одинцово"), + (55.9386, 37.5100, "Долгопрудный"), + (55.5519, 37.7133, "Видное"), + (55.7614, 37.8564, "Реутов"), + (55.7444, 38.0128, "Железнодорожный"), +] + # Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66). # По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules. # default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек @@ -346,6 +430,13 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = { "verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")], "talitsa": [(57.0148, 63.7302, "Талица центр")], "novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")], + # ── Москва (77) и Московская область (50) ──────────────────────────────── + # Три записи вместо одной, чтобы строка расписания могла выбрать скоуп: + # только Москва, только область, либо обе сразу (у cian/yandex под это есть + # отдельные идентификаторы, у avito один слаг на всё — см. CITY_LOCATIONS). + "moskva": MOSCOW_ANCHORS, + "moskovskaya_oblast": MOSCOW_OBLAST_ANCHORS, + "moskva_i_mo": [*MOSCOW_ANCHORS, *MOSCOW_OBLAST_ANCHORS], } @@ -359,7 +450,14 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] | """ if city_slug is None: return None - return CITY_ANCHORS.get(city_slug) + try: + return CITY_ANCHORS[city_slug] + except KeyError: + raise ValueError( + f"get_city_anchors: неизвестный city_slug={city_slug!r}. Раньше здесь был " + "тихий откат на ЕКБ (None → EKB_ANCHORS у вызывающей стороны) — развёртка " + "чужого города молча собирала Екатеринбург. Заведи слаг в CITY_ANCHORS." + ) from None def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None: @@ -387,6 +485,17 @@ def get_city_anchor_point(city_slug: str | None) -> tuple[float, float] | None: return (lat, lon) +# Хост Циана — РЕГИОНАЛЬНЫЙ поддомен, а не городской: ekb.cian.ru общий для всей +# Свердловской области (все 45 городов ниже), www.cian.ru обслуживает Москву и МО. +# Почему полем CityLocation, а не вторым реестром "слаг → хост": хост и region= — +# один и тот же гео-скоуп ОДНОГО запроса, и разъехаться они не имеют права +# (region=1 на ekb.cian.ru — чужая либо пустая выдача, причём молча). Отдельная +# карта завела бы второй ключ в том же ключевом пространстве и ровно этот риск +# рассинхрона; поле в той же frozen-записи рассинхронизировать нечем. +CIAN_SVERDLOVSK_HOST = "https://ekb.cian.ru" +CIAN_MOSCOW_HOST = "https://www.cian.ru" + + @dataclass(frozen=True) class CityLocation: """Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout). @@ -406,11 +515,25 @@ class CityLocation: Тот же жёсткий инвариант, что у avito_slug — run_yandex_city_sweep явно падает (ValueError), если city_slug известен, а yandex_rgid для него None. + cian_host — поддомен Циана (см. CIAN_SVERDLOVSK_HOST выше). Дефолт — + свердловский: все 45 записей области остаются байт в байт, а + город ВНЕ Свердловской обл. обязан передать хост явно, иначе + region= уедет на чужой поддомен. + avito_slug_is_region + — True, если avito_slug — слаг РЕГИОНА, а не города (moskva_i_mo + покрывает Москву и всю область одним скоупом: отдельного слага + у области нет, замер 10-12.09.2026). Такой слаг НЕ появляется в + ссылках карточек (там /moskva/, /himki/, /balashiha/…), поэтому + per-card фильтр `avito_serp_ekb_only` на нём выбросил бы 100% + выдачи — run_avito_city_sweep выключает фильтр для таких + записей (AvitoScraper(filter_cards_by_city_slug=False)). """ avito_slug: str | None cian_region_id: int yandex_rgid: int | None + cian_host: str = CIAN_SVERDLOVSK_HOST + avito_slug_is_region: bool = False # Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах, @@ -473,14 +596,41 @@ CITY_LOCATIONS: dict[str, CityLocation] = { "verkhoturye": CityLocation(None, 174499, 328251), # ── wave 2: ни avito, ни yandex — только cian (1 город) ───────────────────── "mikhaylovsk": CityLocation(None, 175332, None), + # ── Москва (77) и Московская область (50) ────────────────────────────── + # Значения проверены живьём 10-12.09.2026 (scripts/local-avito-msk/collect.py): + # • avito — слаг ОДИН на оба региона (moskva_i_mo), отдельного слага у области + # нет; деление на 77/50 происходит позже, на импорте, по полигонам + # (app.services.regions.region_for_point), а не по слагу. + # • cian — region=1 Москва (92 817 лотов), 4593 область (60 231), -1 обе + # вместе (153 049). Два параметра region НЕ объединяются (побеждает + # последний), поэтому «Москва+область» — это именно -1, а не пара значений. + # • yandex — rgid 587795 Москва (18 705), 587654 область (14 242), 741964 обе + # вместе (31 074); контроль ЕКБ 559132 (4 060). + # Объединённый скоуп безопасен именно потому, что region_code лота считается по + # полигонам, а не по слагу развёртки: штамп региона не соврёт, даже когда в одном + # прогоне приезжают лоты обоих регионов. + "moskva": CityLocation("moskva_i_mo", 1, 587795, CIAN_MOSCOW_HOST, True), + "moskovskaya_oblast": CityLocation("moskva_i_mo", 4593, 587654, CIAN_MOSCOW_HOST, True), + "moskva_i_mo": CityLocation("moskva_i_mo", -1, 741964, CIAN_MOSCOW_HOST, True), } def get_city_location(city_slug: str | None) -> CityLocation | None: - """CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт).""" + """CityLocation по slug (CITY_LOCATIONS). None → None (=ЕКБ-дефолт). + + Неизвестный slug — ValueError, НЕ ЕКБ-дефолт: тихий откат означал бы, что + развёртка с опечаткой в `default_params["city"]` собирает Екатеринбург под + чужим именем города, со штатными счётчиками и без аномалий в логах. + """ if city_slug is None: return None - return CITY_LOCATIONS.get(city_slug) + try: + return CITY_LOCATIONS[city_slug] + except KeyError: + raise ValueError( + f"get_city_location: неизвестный city_slug={city_slug!r} — развёртка " + "откатилась бы на ЕКБ. Заведи запись в CITY_LOCATIONS." + ) from None # Человекочитаемые названия городов области — пишутся в `listings.city` (#2594). @@ -542,11 +692,19 @@ CITY_DISPLAY_NAMES: dict[str, str] = { "verkhoturye": "Верхотурье", "talitsa": "Талица", "novaya_lyalya": "Новая Ляля", + "moskva": "Москва", } EKATERINBURG_CITY_NAME = "Екатеринбург" +# Скоупы, внутри которых лоты принадлежат РАЗНЫМ городам: один штамп city на такой +# batch был бы ложью (у области это Химки/Балашиха/Подольск/…, у объединённого +# скоупа — ещё и Москва). resolve_city_name возвращает для них None, save_listings +# пишет city=NULL, и город доезжает позже из адреса (геокодер), а не из слага +# развёртки. Регион определяется полигонами, поэтому region_code от этого не страдает. +MULTI_CITY_SLUGS: frozenset[str] = frozenset({"moskovskaya_oblast", "moskva_i_mo"}) -def resolve_city_name(city_slug: str | None) -> str: + +def resolve_city_name(city_slug: str | None) -> str | None: """Человекочитаемое имя города для `save_listings(..., city=...)` (#2594). city_slug=None → Екатеринбург. Это НЕ заглушка «не знаем» — это симметрия с @@ -555,11 +713,26 @@ def resolve_city_name(city_slug: str | None) -> str: собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" — иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ — нет». Неизвестный slug (не в CITY_DISPLAY_NAMES) — тоже ЕКБ-дефолт, тем же путём, что и - get_city_location/get_city_anchors для неизвестных slug'ов. + get_city_location/get_city_anchors (оба теперь падают на неизвестном slug'е). + + Слаг из MULTI_CITY_SLUGS → None: в таком batch'е городов несколько, и любой + один штамп был бы ложью (save_listings при city=None просто не трогает поле, а + гео-гард штампа там инертен — см. `_geo_guard_active` в scraper_kit.base). + Неизвестный slug → ValueError, а не «Екатеринбург»: молчаливый ЕКБ-штамп на + лотах чужого города — ровно тот дефект, из-за которого сломанную развёртку вне + ЕКБ было не отличить от исправной по логам. """ if city_slug is None: return EKATERINBURG_CITY_NAME - return CITY_DISPLAY_NAMES.get(city_slug, EKATERINBURG_CITY_NAME) + if city_slug in MULTI_CITY_SLUGS: + return None + try: + return CITY_DISPLAY_NAMES[city_slug] + except KeyError: + raise ValueError( + f"resolve_city_name: неизвестный city_slug={city_slug!r} — раньше здесь " + "молча возвращался 'Екатеринбург'. Заведи имя в CITY_DISPLAY_NAMES." + ) from None # Гео-guard радиус (км) от anchor'а города-цели (get_city_anchor_point), за пределами @@ -586,6 +759,19 @@ def resolve_city_name(city_slug: str | None) -> str: _DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0 _CITY_STAMP_RADIUS_KM: dict[str, float] = { "verkhnyaya_pyshma": 8.0, + # Москва: гард меряет от ЦЕНТРА ядра (MOSCOW_ANCHORS[0] = 55.75/37.60 — центр + # стоит первым специально, см. _build_moscow_anchor_grid). Дальний угол ядра — + # hypot(22.25, 18.78) = 29.1 км, поэтому дефолтные 15 км срезали бы city у всего + # за МКАД и части внутри неё (Бутово, Митино, Некрасовка, Солнцево — легитимная + # Москва, ~20-27 км от центра). 30 км = 29.1 + ~0.9 км запаса на адреса, чьи + # координаты чуть выходят за рамку окна. + # Цена решения: в 30 км попадают и ближние города области (Химки ~20, Красногорск + # ~16, Люберцы ~14, Реутов ~12 км) — их лоты, если их захватит московская выдача, + # получат city="Москва". РЕГИОН при этом не врёт: region_code считается по + # полигонам (app.services.regions.region_for_point), а не по штампу города. + # Развёрток "moskovskaya_oblast"/"moskva_i_mo" это не касается вовсе: у них + # city=None (MULTI_CITY_SLUGS), а при city=None гард в save_listings инертен. + "moskva": 30.0, } @@ -1263,6 +1449,9 @@ async def run_avito_city_sweep( # kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug — # вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor. _avito_slug = _loc.avito_slug if _loc else city_slug + # Слаг региона (moskva_i_mo) в ссылках карточек не встречается — per-card фильтр + # по нему выбросил бы всю выдачу. См. CityLocation.avito_slug_is_region. + _avito_card_filter = not (_loc.avito_slug_is_region if _loc is not None else False) # #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы # одного run'а бьют по одному city_slug), вычисляем один раз до цикла. _city_name = resolve_city_name(city_slug) @@ -1410,7 +1599,10 @@ async def run_avito_city_sweep( # shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда # переопределяет scraper._browser напрямую, минуя __aenter__. scraper = AvitoScraper( - config, target_city_slug=_avito_slug, proxy_provider=proxy_provider + config, + target_city_slug=_avito_slug, + proxy_provider=proxy_provider, + filter_cards_by_city_slug=_avito_card_filter, ) if browser_mode: scraper._browser = shared_bf @@ -3082,6 +3274,7 @@ async def run_cian_city_sweep( config, proxy_provider=proxy_provider, city_region_id=(_loc.cian_region_id if _loc else None), + city_host=(_loc.cian_host if _loc else None), ) as scraper: anchor_lots = await scraper.fetch_around_multi_room( _a_lat, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index c31e9c7a..8ef633b2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -398,6 +398,7 @@ class AvitoScraper(BaseScraper): delay_provider: Callable[[str], float] | None = None, target_city_slug: str | None = None, proxy_provider: ProxyProvider | None = None, + filter_cards_by_city_slug: bool = True, ) -> None: super().__init__() # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи @@ -420,6 +421,14 @@ class AvitoScraper(BaseScraper): # None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only, # чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/. self._target_city_slug = target_city_slug + # Слаг РЕГИОНА (moskva_i_mo покрывает Москву и всю область одним скоупом) в + # ссылках карточек не встречается — там /moskva/, /himki/, /balashiha/… . + # Поэтому per-card фильтр по слагу города-цели на таком скоупе выбросил бы + # 100% выдачи (замер 10-12.09.2026). False — фильтр выключен ДЛЯ ЭТОГО + # прогона, независимо от глобального config.avito_serp_ekb_only: флаг + # настройки говорит «отбрасывать добивку по всей России», а не «собирать + # только Екатеринбург», и на региональном слаге он неприменим физически. + self._filter_cards_by_city_slug = filter_cards_by_city_slug # #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время # fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы # остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча. @@ -2111,7 +2120,7 @@ class AvitoScraper(BaseScraper): ts_map = _build_sort_timestamp_map(html) cards = tree.css('[data-marker="item"]') lots: list[ScrapedLot] = [] - ekb_only = self._config.avito_serp_ekb_only + ekb_only = self._config.avito_serp_ekb_only and self._filter_cards_by_city_slug # #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" — # ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load). kept_slug = self._target_city_slug or "ekaterinburg" diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py index 55f28626..3082a8a8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py @@ -86,6 +86,7 @@ _MIN_BRACKET = 50_000 # Cian SERP показывает ~28 офферов на страницу. _CIAN_OFFERS_PER_PAGE = 28 + # Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся # per-call (price_cap_per_bucket) — здесь только структурные константы/политики. # probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается. @@ -97,6 +98,7 @@ def _cian_bisection_config(cap: int) -> BisectionConfig: probe_fail_policy=ProbeFailPolicy.SKIP, ) + # Комнатности по умолчанию для exhaustive-загрузки. # Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+. # TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param. @@ -140,6 +142,7 @@ class CianScraper(BaseScraper): delay_provider: Callable[[str], float] | None = None, proxy_provider: ProxyProvider | None = None, city_region_id: int | None = None, + city_host: str | None = None, ) -> None: super().__init__() # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи @@ -153,9 +156,17 @@ class CianScraper(BaseScraper): # None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас). self._proxy_provider = proxy_provider self._browser: BrowserFetcher | None = None - # #12 (oblast rollout): region= города-цели SERP-запроса (ekb.cian.ru — общий - # поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID). + # #12 (oblast rollout): region= города-цели SERP-запроса. None → ЕКБ-дефолт + # (CIAN_EKB_REGION_ID). self._region_id = city_region_id or CIAN_EKB_REGION_ID + # Поддомен Циана — РЕГИОНАЛЬНЫЙ, а не городской: ekb.cian.ru общий для всей + # Свердловской обл. (поэтому раньше был захардкожен классовым base_url), но + # Москву и область он не обслуживает — там www.cian.ru. Приходит из реестра + # города (CityLocation.cian_host в orchestration/pipeline.py) вместе с region=, + # чтобы хост и region не разъехались. None → класс-дефолт ЕКБ: URL прежних + # свердловских развёрток остаются байт в байт. + if city_host is not None: + self.base_url = city_host # #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса. # Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены # вёрстки (state extraction провалилась → None). Читаются вызывающим кодом @@ -296,8 +307,9 @@ class CianScraper(BaseScraper): ) -> str: """URL для Cian каталога вторички города-цели (по умолчанию ЕКБ). - Используем ekb.cian.ru (city-specific subdomain — общий для всей Свердловской - обл., НЕ меняется per-city). region= param — фактический скоуп выборки + Хост берётся из self.base_url (региональный поддомен: ekb.cian.ru — вся + Свердловская обл., www.cian.ru — Москва и МО; задаётся city_host в __init__, + по умолчанию ЕКБ). region= param — фактический скоуп выборки (#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан). min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов. """