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:
bot-backend 2026-09-15 19:49:57 +03:00
parent 660aced334
commit a4ba03ce8e
6 changed files with 457 additions and 22 deletions

View file

@ -54,7 +54,13 @@ def test_resolve_city_name_known_oblast_slugs() -> None:
"verkhnyaya_pyshma": "Верхняя Пышма", "verkhnyaya_pyshma": "Верхняя Пышма",
"serov": "Серов", "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() assert wave1_expected.items() <= CITY_DISPLAY_NAMES.items()
for slug, name in wave1_expected.items(): for slug, name in wave1_expected.items():
assert resolve_city_name(slug) == name 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 == "Екатеринбург" assert resolve_city_name(None) == EKATERINBURG_CITY_NAME == "Екатеринбург"
def test_resolve_city_name_unknown_slug_defaults_to_ekaterinburg() -> None: def test_resolve_city_name_unknown_slug_raises() -> None:
"""Неизвестный slug — тот же ЕКБ-дефолт, что и get_city_location/get_city_anchors.""" """Неизвестный slug ПАДАЕТ (msk-rollout), а не штампует молча «Екатеринбург».
Раньше здесь был ЕКБ-дефолт симметрично get_city_location/get_city_anchors.
Оба они теперь тоже падают: с приходом Москвы тихий откат на ЕКБ означал бы,
что развёртка с опечаткой в слаге собирает Екатеринбург под чужим именем города,
со штатными счётчиками и без единой аномалии в логах.
"""
import pytest
from scraper_kit.orchestration.pipeline import resolve_city_name 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 (НЕ силентный # ── #262: явный fail на известный город БЕЗ подтверждённого provider-id (НЕ силентный

View file

@ -95,6 +95,13 @@ _WAVE2_SLUGS = {
assert len(_WAVE2_SLUGS) == 40 assert len(_WAVE2_SLUGS) == 40
assert "bisert" not in _WAVE2_SLUGS # пгт, не город — исключена целиком (нет у Циана) 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: def _sql(path: Path) -> str:
return path.read_text(encoding="utf-8") 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 scheduled_avito = rows_262 | rows_179
confirmed_avito = {slug for slug, loc in CITY_LOCATIONS.items() if loc.avito_slug is not None} 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"missing schedule rows: {confirmed_avito - scheduled_avito}; "
f"schedule rows без подтверждённого avito_slug: {scheduled_avito - confirmed_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 scheduled_yandex = rows_262 | rows_179
confirmed_yandex = {slug for slug, loc in CITY_LOCATIONS.items() if loc.yandex_rgid is not None} 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"missing schedule rows: {confirmed_yandex - scheduled_yandex}; "
f"schedule rows без подтверждённого yandex_rgid: {scheduled_yandex - confirmed_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 = { confirmed_cian = {
slug for slug, loc in CITY_LOCATIONS.items() if loc.cian_region_id is not None 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"missing schedule rows: {confirmed_cian - scheduled_cian}; "
f"schedule rows без подтверждённого cian_region_id: {scheduled_cian - confirmed_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)} | { seeded_slugs = {slug for _p, slug in _row_sources(_MIGRATION_179)} | {
slug for _p, slug in _row_sources(_MIGRATION_262) 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, ( assert not orphaned, (
f"CITY_ANCHORS содержит slug без scrape_schedules-строк: {sorted(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: def test_city_anchors_wave2_count_and_content() -> None:
"""CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 — """CITY_ANCHORS содержит ровно wave-1 (5) + wave-2 (40) = 45 ключей, и wave-2 —
ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи).""" ровно ожидаемый набор slug'ов (защита от опечатки при заведении записи)."""
assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS assert set(CITY_ANCHORS) == _WAVE1_SLUGS | _WAVE2_SLUGS | _MSK_SCOPES
assert len(CITY_ANCHORS) == 45 assert len(CITY_ANCHORS) == 45 + len(_MSK_SCOPES)
def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None: def test_city_anchors_wave2_entries_have_single_anchor_with_label() -> None:

View 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")

View file

@ -282,6 +282,90 @@ EKB_ANCHORS: list[tuple[float, float, str]] = [
(56.8650, 60.6200, "Пионерский"), (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). # Anchors для oblast-городов вне ЕКБ (B1 rollout — Свердловская область, region 66).
# По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules. # По 1 anchor на город (центр) — radius_m (передаётся отдельно, в scrape_schedules.
# default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек # default_params) должен покрывать город целиком; в отличие от EKB_ANCHORS (5 точек
@ -346,6 +430,13 @@ CITY_ANCHORS: dict[str, list[tuple[float, float, str]]] = {
"verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")], "verkhoturye": [(58.8617, 60.8093, "Верхотурье центр")],
"talitsa": [(57.0148, 63.7302, "Талица центр")], "talitsa": [(57.0148, 63.7302, "Талица центр")],
"novaya_lyalya": [(59.0527, 60.5960, "Н.Ляля центр")], "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: if city_slug is None:
return 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: 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) 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) @dataclass(frozen=True)
class CityLocation: class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout). """Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
@ -406,11 +515,25 @@ class CityLocation:
Тот же жёсткий инвариант, что у avito_slug run_yandex_city_sweep Тот же жёсткий инвариант, что у avito_slug run_yandex_city_sweep
явно падает (ValueError), если city_slug известен, а yandex_rgid явно падает (ValueError), если city_slug известен, а yandex_rgid
для него None. для него 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 avito_slug: str | None
cian_region_id: int cian_region_id: int
yandex_rgid: int | None yandex_rgid: int | None
cian_host: str = CIAN_SVERDLOVSK_HOST
avito_slug_is_region: bool = False
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах, # Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
@ -473,14 +596,41 @@ CITY_LOCATIONS: dict[str, CityLocation] = {
"verkhoturye": CityLocation(None, 174499, 328251), "verkhoturye": CityLocation(None, 174499, 328251),
# ── wave 2: ни avito, ни yandex — только cian (1 город) ───────────────────── # ── wave 2: ни avito, ни yandex — только cian (1 город) ─────────────────────
"mikhaylovsk": CityLocation(None, 175332, None), "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: 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: if city_slug is None:
return 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). # Человекочитаемые названия городов области — пишутся в `listings.city` (#2594).
@ -542,11 +692,19 @@ CITY_DISPLAY_NAMES: dict[str, str] = {
"verkhoturye": "Верхотурье", "verkhoturye": "Верхотурье",
"talitsa": "Талица", "talitsa": "Талица",
"novaya_lyalya": "Новая Ляля", "novaya_lyalya": "Новая Ляля",
"moskva": "Москва",
} }
EKATERINBURG_CITY_NAME = "Екатеринбург" 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). """Человекочитаемое имя города для `save_listings(..., city=...)` (#2594).
city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с city_slug=None Екатеринбург. Это НЕ заглушка «не знаем» это симметрия с
@ -555,11 +713,26 @@ def resolve_city_name(city_slug: str | None) -> str:
собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург" собирают ЕКБ, поэтому их листинги тоже должны получать city="Екатеринбург"
иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет». иначе была бы обратная асимметрия «у области город проставлен, у ЕКБ нет».
Неизвестный slug (не в CITY_DISPLAY_NAMES) тоже ЕКБ-дефолт, тем же путём, что и Неизвестный 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: if city_slug is None:
return EKATERINBURG_CITY_NAME 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), за пределами # Гео-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 _DEFAULT_CITY_STAMP_RADIUS_KM: float = 15.0
_CITY_STAMP_RADIUS_KM: dict[str, float] = { _CITY_STAMP_RADIUS_KM: dict[str, float] = {
"verkhnyaya_pyshma": 8.0, "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 — # kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor. # вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug _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'ы # #2594: город для save_listings(..., city=...) — один на весь sweep (все anchor'ы
# одного run'а бьют по одному city_slug), вычисляем один раз до цикла. # одного run'а бьют по одному city_slug), вычисляем один раз до цикла.
_city_name = resolve_city_name(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 выше) всегда # shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда
# переопределяет scraper._browser напрямую, минуя __aenter__. # переопределяет scraper._browser напрямую, минуя __aenter__.
scraper = AvitoScraper( 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: if browser_mode:
scraper._browser = shared_bf scraper._browser = shared_bf
@ -3082,6 +3274,7 @@ async def run_cian_city_sweep(
config, config,
proxy_provider=proxy_provider, proxy_provider=proxy_provider,
city_region_id=(_loc.cian_region_id if _loc else None), city_region_id=(_loc.cian_region_id if _loc else None),
city_host=(_loc.cian_host if _loc else None),
) as scraper: ) as scraper:
anchor_lots = await scraper.fetch_around_multi_room( anchor_lots = await scraper.fetch_around_multi_room(
_a_lat, _a_lat,

View file

@ -398,6 +398,7 @@ class AvitoScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
target_city_slug: str | None = None, target_city_slug: str | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
filter_cards_by_city_slug: bool = True,
) -> None: ) -> None:
super().__init__() super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -420,6 +421,14 @@ class AvitoScraper(BaseScraper):
# None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only, # None → ЕКБ (дефолт). Используется _parse_html-фильтром avito_serp_ekb_only,
# чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/. # чтобы оставлять карточки TARGET-города, а не хардкодить /ekaterinburg/.
self._target_city_slug = target_city_slug 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. Включается на время # #3033: режим «родной фильтр вторички» у _build_rooms_url. Включается на время
# fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы # fetch_all_secondary(secondary_only=True) и сбрасывается по выходу — чтобы
# остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча. # остальные билдеры (fetch_by_rooms, sweep'ы) не меняли форму URL молча.
@ -2111,7 +2120,7 @@ class AvitoScraper(BaseScraper):
ts_map = _build_sort_timestamp_map(html) ts_map = _build_sort_timestamp_map(html)
cards = tree.css('[data-marker="item"]') cards = tree.css('[data-marker="item"]')
lots: list[ScrapedLot] = [] 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" — # #2487: слаг города, карточки которого оставляем. Дефолт "ekaterinburg" —
# ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load). # ЕКБ-поведение неизменно, когда target не задан (обычный ЕКБ-sweep/full-load).
kept_slug = self._target_city_slug or "ekaterinburg" kept_slug = self._target_city_slug or "ekaterinburg"

View file

@ -86,6 +86,7 @@ _MIN_BRACKET = 50_000
# Cian SERP показывает ~28 офферов на страницу. # Cian SERP показывает ~28 офферов на страницу.
_CIAN_OFFERS_PER_PAGE = 28 _CIAN_OFFERS_PER_PAGE = 28
# Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся # Конфиг единого движка бисекции (scraper_kit.pricing) для cian. cap задаётся
# per-call (price_cap_per_bucket) — здесь только структурные константы/политики. # per-call (price_cap_per_bucket) — здесь только структурные константы/политики.
# probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается. # probe_fail=SKIP: totalOffers недоступен после ретрая → бакет пропускается.
@ -97,6 +98,7 @@ def _cian_bisection_config(cap: int) -> BisectionConfig:
probe_fail_policy=ProbeFailPolicy.SKIP, probe_fail_policy=ProbeFailPolicy.SKIP,
) )
# Комнатности по умолчанию для exhaustive-загрузки. # Комнатности по умолчанию для exhaustive-загрузки.
# Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+. # Коды Cian: room1=1к, room2=2к, room3=3к, room4=4к, room5=5к, room6=6+.
# TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param. # TODO: студии — Cian использует отдельный тип flat_type=studio, не room-param.
@ -140,6 +142,7 @@ class CianScraper(BaseScraper):
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
city_region_id: int | None = None, city_region_id: int | None = None,
city_host: str | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -153,9 +156,17 @@ class CianScraper(BaseScraper):
# None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас). # None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас).
self._proxy_provider = proxy_provider self._proxy_provider = proxy_provider
self._browser: BrowserFetcher | None = None self._browser: BrowserFetcher | None = None
# #12 (oblast rollout): region= города-цели SERP-запроса (ekb.cian.ru — общий # #12 (oblast rollout): region= города-цели SERP-запроса. None → ЕКБ-дефолт
# поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID). # (CIAN_EKB_REGION_ID).
self._region_id = city_region_id or 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-инстанса. # #2625: счётчики Redux-state extraction за время жизни этого scraper-инстанса.
# Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены # Различают честную пустую выдачу (state извлёкся, offers=[]) от капчи/смены
# вёрстки (state extraction провалилась → None). Читаются вызывающим кодом # вёрстки (state extraction провалилась → None). Читаются вызывающим кодом
@ -296,8 +307,9 @@ class CianScraper(BaseScraper):
) -> str: ) -> str:
"""URL для Cian каталога вторички города-цели (по умолчанию ЕКБ). """URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
Используем ekb.cian.ru (city-specific subdomain общий для всей Свердловской Хост берётся из self.base_url (региональный поддомен: ekb.cian.ru вся
обл., НЕ меняется per-city). region= param фактический скоуп выборки Свердловская обл., www.cian.ru Москва и МО; задаётся city_host в __init__,
по умолчанию ЕКБ). region= param фактический скоуп выборки
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан). (#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
min_price / max_price опциональные ценовые границы (руб.) для price-бакетов. min_price / max_price опциональные ценовые границы (руб.) для price-бакетов.
""" """