feat(scraper): снять привязку avito/cian/yandex к Екатеринбургу
Черновик московской развёртки: реестры городов перестают быть свердловскими. - cian: хост уехал из классового base_url в CityLocation.cian_host — поддомен региональный (ekb.cian.ru — вся Свердловская обл., www.cian.ru — Москва и МО) и обязан ехать вместе с region=, иначе они разъедутся молча. ЕКБ идёт по None-пути: URL байт в байт прежний. - реестры: moskva (cian region=1 / rgid 587795), moskovskaya_oblast (4593 / 587654) и объединённый moskva_i_mo (-1 / 741964) — значения замерены живьём 10-12.09.2026. У avito слаг один на оба региона (moskva_i_mo), деление по регионам делает импорт по полигонам. - якоря Москвы: сетка 5x5 = 25 узлов по ядру 55.55..55.95 / 37.30..37.90, шаг 0.10x0.15 градуса — полудиагональ ячейки 7.27 км против радиуса запроса 8 км, дыр в покрытии нет. Центр ядра стоит первым: его берёт гео-гард штампа. У области — по якорю на город-спутник, координаты из реестра репозитория. - радиус доверия штампа Москвы 30 км (дальний угол ядра 29.1 км) — дефолтные 15 км срезали бы city у легитимных лотов за МКАД. - avito_serp_ekb_only перестал быть глобальным «только Екатеринбург»: на слаге РЕГИОНА per-card фильтр выключается (в ссылках карточек /moskva/, /himki/ — слаг moskva_i_mo там не встречается, фильтр выбросил бы всю выдачу). - неизвестный слаг теперь ПАДАЕТ во всех трёх резолверах вместо тихого отката на Екатеринбург; у многогородних скоупов city=NULL вместо ложного штампа. Строк расписаний и ценовой бисекции здесь нет, живых прогонов не было.
This commit is contained in:
parent
660aced334
commit
a4ba03ce8e
6 changed files with 457 additions and 22 deletions
|
|
@ -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 (НЕ силентный
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
200
tradein-mvp/backend/tests/test_msk_sweep_registry.py
Normal file
200
tradein-mvp/backend/tests/test_msk_sweep_registry.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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-бакетов.
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue