fix(tradein/scraper): per-city SERP location for oblast city-sweep (#12) (#2525)
All checks were successful
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 1m37s
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 5m1s
Deploy Trade-In / deploy (push) Successful in 6m47s

Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
lekss361 2026-07-13 21:41:07 +00:00 committed by bot-reviewer
parent 67b50bf713
commit 698576e77b
6 changed files with 206 additions and 21 deletions

View file

@ -0,0 +1,104 @@
"""Per-city SERP location URL builders (#12 fix EKB-leak в oblast city-sweep).
Проблема: avito/cian/yandex провайдеры хардкодили ЕКБ-локацию (путь /ekaterinburg/,
CIAN_EKB_REGION_ID, _EKB_RGID) мимо anchor'а oblast-города → city-sweep целевого
города (напр. Н.Тагил) фактически скрейпил ЕКБ, n_analogs=0. Фикс новые
kw-параметры (avito: target_city_slug уже был #2487, теперь и в ПУТИ URL;
cian: city_region_id; yandex: city_rgid), все дефолт None ЕКБ-поведение
byte-identical прежнему (back-compat critical).
No network / no DB чистые URL-билдеры на stub-конфиге.
"""
from __future__ import annotations
import os
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.providers.avito.serp import NOVOSTROYKA_SLUG, AvitoScraper
from scraper_kit.providers.cian.serp import CIAN_EKB_REGION_ID, CianScraper
from scraper_kit.providers.yandex.serp import _EKB_RGID, YandexRealtyScraper
_STUB_CFG = SimpleNamespace()
def _query(url: str) -> dict[str, list[str]]:
return parse_qs(urlparse(url).query)
# ── Avito: city-slug в ПУТЬ URL (не только SERP-фильтр) ────────────────────────
def test_avito_web_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
url = s._build_web_url(57.910, 59.980, radius_km=2, page=1)
assert "/nizhniy_tagil/" in url
assert "/ekaterinburg/" not in url
def test_avito_citywide_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="kamensk-uralskiy") # type: ignore[arg-type]
url = s._build_citywide_url(page=2)
assert "/kamensk-uralskiy/" in url
assert "/ekaterinburg/" not in url
def test_avito_newbuilding_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="verhnyaya_pyshma") # type: ignore[arg-type]
url = s._build_newbuilding_url(page=1)
assert "/verhnyaya_pyshma/" in url
assert "/ekaterinburg/" not in url
assert NOVOSTROYKA_SLUG in url
def test_avito_rooms_url_uses_target_city_slug_in_path() -> None:
s = AvitoScraper(_STUB_CFG, target_city_slug="serov") # type: ignore[arg-type]
url = s._build_rooms_url("2k", page=1)
assert "/serov/" in url
assert "/ekaterinburg/" not in url
def test_avito_urls_default_to_ekaterinburg_when_no_target_city_slug() -> None:
"""Back-compat: target_city_slug=None → путь /ekaterinburg/ (ЕКБ full_load/pipeline)."""
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
assert "/ekaterinburg/" in s._build_web_url(56.84, 60.60, radius_km=1, page=1)
assert "/ekaterinburg/" in s._build_citywide_url(page=1)
assert "/ekaterinburg/" in s._build_newbuilding_url(page=1)
assert "/ekaterinburg/" in s._build_rooms_url("2k", page=1)
# ── Cian: city_region_id → region= (subdomain НЕ трогается) ────────────────────
def test_cian_build_url_uses_city_region_id() -> None:
s = CianScraper(_STUB_CFG, city_region_id=4886) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["region"] == ["4886"]
assert urlparse(url).hostname == "ekb.cian.ru"
def test_cian_build_url_defaults_to_ekb_region_when_no_city_region_id() -> None:
"""Back-compat: city_region_id=None → region=CIAN_EKB_REGION_ID (4743)."""
s = CianScraper(_STUB_CFG, city_region_id=None) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["region"] == [str(CIAN_EKB_REGION_ID)]
assert urlparse(url).hostname == "ekb.cian.ru"
# ── Yandex: city_rgid → rgid= ───────────────────────────────────────────────────
def test_yandex_build_url_uses_city_rgid() -> None:
s = YandexRealtyScraper(_STUB_CFG, city_rgid=328286) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["rgid"] == ["328286"]
def test_yandex_build_url_defaults_to_ekb_rgid_when_no_city_rgid() -> None:
"""Back-compat: city_rgid=None → rgid=_EKB_RGID (559132)."""
s = YandexRealtyScraper(_STUB_CFG, city_rgid=None) # type: ignore[arg-type]
url = s._build_url()
assert _query(url)["rgid"] == [str(_EKB_RGID)]

View file

@ -310,6 +310,39 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] |
return CITY_ANCHORS.get(city_slug) return CITY_ANCHORS.get(city_slug)
@dataclass(frozen=True)
class CityLocation:
"""Per-provider гео-идентификаторы города-цели SERP-запроса (oblast rollout).
avito_slug city-slug в пути avito URL (ОТЛИЧАЕТСЯ от нашего slug у некоторых
городов: kamensk-uralskiy через дефис, verhnyaya_pyshma без kh).
cian_region_id числовой region= в ekb.cian.ru/cat.php (ГОРОД, не область).
yandex_rgid rgid в gate-API Яндекс.Недвижимости (город).
"""
avito_slug: str
cian_region_id: int
yandex_rgid: int
# Референс ЕКБ (avito ekaterinburg / cian 4743 / yandex 559132) — дефолты в провайдерах,
# в карте держать НЕ обязательно (None-путь = ЕКБ). Города Свердловской обл. (region 66):
CITY_LOCATIONS: dict[str, CityLocation] = {
"nizhniy_tagil": CityLocation("nizhniy_tagil", 4886, 328286),
"kamensk_uralskiy": CityLocation("kamensk-uralskiy", 4781, 328468),
"pervouralsk": CityLocation("pervouralsk", 4925, 328379),
"verkhnyaya_pyshma": CityLocation("verhnyaya_pyshma", 4697, 166366),
"serov": CityLocation("serov", 4982, 328238),
}
def get_city_location(city_slug: str | None) -> CityLocation | None:
"""CityLocation по slug (CITY_LOCATIONS). Неизвестный slug/None → None (=ЕКБ-дефолт)."""
if city_slug is None:
return None
return CITY_LOCATIONS.get(city_slug)
_CHROME_HEADERS = { _CHROME_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8",
@ -855,9 +888,14 @@ async def run_avito_city_sweep(
зовётся через enrichment (EnrichmentJobs Protocol). зовётся через enrichment (EnrichmentJobs Protocol).
""" """
# city_slug (#2487): слаг города-цели (напр. "nizhniy_tagil") прокидывается в # city_slug (#2487): слаг города-цели (напр. "nizhniy_tagil") прокидывается в
# AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города. # AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города,
# а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ.
# None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже). # None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже).
_anchors = anchors if anchors is not None else EKB_ANCHORS _anchors = anchors if anchors is not None else EKB_ANCHORS
_loc = get_city_location(city_slug)
# kamensk-uralskiy (дефис) / verhnyaya_pyshma (kh→h) отличаются от нашего city_slug —
# вычисляем один раз до цикла anchor'ов, не внутри closure на каждый anchor.
_avito_slug = _loc.avito_slug if _loc else city_slug
counters = CitySweepCounters(anchors_total=len(_anchors)) counters = CitySweepCounters(anchors_total=len(_anchors))
all_touched_house_ids: set[int] = set() all_touched_house_ids: set[int] = set()
@ -964,8 +1002,9 @@ async def run_avito_city_sweep(
# ── Phase 1+2: SERP + save ───────────────────────────────── # ── Phase 1+2: SERP + save ─────────────────────────────────
# #2487: target_city_slug → _parse_html оставляет карточки # #2487: target_city_slug → _parse_html оставляет карточки
# города-цели (oblast slug), а не хардкод /ekaterinburg/. None → ЕКБ. # города-цели (oblast slug). #12: _avito_slug (может отличаться
scraper = AvitoScraper(config, target_city_slug=city_slug) # от city_slug) идёт в путь URL — не хардкод /ekaterinburg/. None → ЕКБ.
scraper = AvitoScraper(config, target_city_slug=_avito_slug)
if browser_mode: if browser_mode:
scraper._browser = shared_bf scraper._browser = shared_bf
# Shared-browser режим: _cffi=None → curl_cffi-fallback на # Shared-browser режим: _cffi=None → curl_cffi-fallback на
@ -1706,6 +1745,7 @@ async def run_yandex_city_sweep(
shutdown_requested: Callable[[], bool] = lambda: False, shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
anchors: list[tuple[float, float, str]] | None = None, anchors: list[tuple[float, float, str]] | None = None,
city_slug: str | None = None,
radius_m: int = 25000, radius_m: int = 25000,
pages_per_anchor: int = 2, pages_per_anchor: int = 2,
request_delay_sec: float | None = None, request_delay_sec: float | None = None,
@ -1739,6 +1779,10 @@ async def run_yandex_city_sweep(
else: else:
_anchors = [(56.8400, 60.6050, "Центр (combos)")] _anchors = [(56.8400, 60.6050, "Центр (combos)")]
# city_slug (#12): rgid города-цели → YandexRealtyScraper.city_rgid скоупит SERP
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
_loc = get_city_location(city_slug)
_rooms_list = rooms_list or list(ROOM_PATH.keys()) _rooms_list = rooms_list or list(ROOM_PATH.keys())
_price_ranges = price_ranges or DEFAULT_PRICE_RANGES _price_ranges = price_ranges or DEFAULT_PRICE_RANGES
# newFlat-сегменты: оба прохода (vtorichka + novostroyki) по одним combos. # newFlat-сегменты: оба прохода (vtorichka + novostroyki) по одним combos.
@ -1864,7 +1908,11 @@ async def run_yandex_city_sweep(
counters.lots_fetched, counters.lots_fetched,
) )
async with YandexRealtyScraper(config, proxy_provider=proxy_provider) as scraper: async with YandexRealtyScraper(
config,
proxy_provider=proxy_provider,
city_rgid=(_loc.yandex_rgid if _loc else None),
) as scraper:
await scraper.fetch_around_multi_room( await scraper.fetch_around_multi_room(
_a_lat, _a_lat,
_a_lon, _a_lon,
@ -2213,6 +2261,7 @@ async def run_cian_city_sweep(
shutdown_requested: Callable[[], bool] = lambda: False, shutdown_requested: Callable[[], bool] = lambda: False,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
anchors: list[tuple[float, float, str]] | None = None, anchors: list[tuple[float, float, str]] | None = None,
city_slug: str | None = None,
radius_m: int = 1500, radius_m: int = 1500,
pages_per_anchor: int = 3, pages_per_anchor: int = 3,
request_delay_sec: float = 5.0, request_delay_sec: float = 5.0,
@ -2236,6 +2285,9 @@ async def run_cian_city_sweep(
mark_done вызывается ВСЕГДА (finally outer). mark_done вызывается ВСЕГДА (finally outer).
""" """
_anchors = anchors if anchors is not None else EKB_ANCHORS _anchors = anchors if anchors is not None else EKB_ANCHORS
# city_slug (#12): region_id города-цели → CianScraper.city_region_id скоупит SERP
# на город вместо дефолтного ЕКБ. None/неизвестный slug → ЕКБ-дефолт в конструкторе.
_loc = get_city_location(city_slug)
counters = CianCitySweepCounters(anchors_total=len(_anchors)) counters = CianCitySweepCounters(anchors_total=len(_anchors))
consecutive_failures = 0 consecutive_failures = 0
cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep cian_rotations_done = 0 # #1848: бюджет IP-ротаций на весь sweep
@ -2310,7 +2362,11 @@ async def run_cian_city_sweep(
nonlocal anchor_lots, consecutive_failures, cian_rotations_done nonlocal anchor_lots, consecutive_failures, cian_rotations_done
# ── Phase 1+2: SERP + save ───────────────────────────────── # ── Phase 1+2: SERP + save ─────────────────────────────────
async with CianScraper(config, proxy_provider=proxy_provider) as scraper: async with CianScraper(
config,
proxy_provider=proxy_provider,
city_region_id=(_loc.cian_region_id if _loc else None),
) as scraper:
anchor_lots = await scraper.fetch_around_multi_room( anchor_lots = await scraper.fetch_around_multi_room(
_a_lat, _a_lat,
_a_lon, _a_lon,

View file

@ -538,6 +538,7 @@ async def _job_yandex_city_sweep(
shutdown_requested=ctx.shutdown_requested, shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider, proxy_provider=ctx.proxy_provider,
anchors=anchors, anchors=anchors,
city_slug=city,
pages_per_anchor=int(params.get("pages_per_anchor", 2)), pages_per_anchor=int(params.get("pages_per_anchor", 2)),
request_delay_sec=float(params.get("request_delay_sec", 9.0)), request_delay_sec=float(params.get("request_delay_sec", 9.0)),
radius_m=int(params.get("radius_m", 1500)), radius_m=int(params.get("radius_m", 1500)),
@ -560,6 +561,7 @@ async def _job_cian_city_sweep(
shutdown_requested=ctx.shutdown_requested, shutdown_requested=ctx.shutdown_requested,
proxy_provider=ctx.proxy_provider, proxy_provider=ctx.proxy_provider,
anchors=anchors, anchors=anchors,
city_slug=city,
pages_per_anchor=int(params.get("pages_per_anchor", 3)), pages_per_anchor=int(params.get("pages_per_anchor", 3)),
request_delay_sec=float(params.get("request_delay_sec", 5.0)), request_delay_sec=float(params.get("request_delay_sec", 5.0)),
radius_m=int(params.get("radius_m", 1500)), radius_m=int(params.get("radius_m", 1500)),

View file

@ -859,6 +859,11 @@ class AvitoScraper(BaseScraper):
return all_lots return all_lots
# ── Strategy B: HTML scrape ───────────────────────────────────────────── # ── Strategy B: HTML scrape ─────────────────────────────────────────────
def _city_seg(self) -> str:
"""City-slug для пути avito URL (#12: раньше хардкод /ekaterinburg/ во всех
билдерах скоупил oblast city-sweep на ЕКБ, невзирая на geoCoords/фильтр)."""
return self._target_city_slug or "ekaterinburg"
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str: def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str:
params = { params = {
"geoCoords": f"{lat},{lon}", "geoCoords": f"{lat},{lon}",
@ -866,24 +871,30 @@ class AvitoScraper(BaseScraper):
"s": 104, # sort by date "s": 104, # sort by date
"p": page, "p": page,
} }
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" return (
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
f"?{urlencode(params)}"
)
def _build_citywide_url(self, page: int = 1) -> str: def _build_citywide_url(self, page: int = 1) -> str:
"""T6: URL всего города ЕКБ без geo-фильтра, сортировка по дате. """T6: URL всего города (по умолчанию ЕКБ) без geo-фильтра, сортировка по дате.
Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек). Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек).
""" """
params = {"s": 104, "p": page} params = {"s": 104, "p": page}
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}" return (
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
f"?{urlencode(params)}"
)
def _build_newbuilding_url(self, page: int = 1) -> str: def _build_newbuilding_url(self, page: int = 1) -> str:
"""URL ЕКБ-выборки только новостроек (novostroyka-filter), сортировка по дате. """URL городской выборки только новостроек (novostroyka-filter), сортировка по дате.
Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug
категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки. категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки.
""" """
params = {"s": 104, "p": page} params = {"s": 104, "p": page}
path = f"/ekaterinburg/kvartiry/prodam/{NOVOSTROYKA_SLUG}" path = f"/{self._city_seg()}/kvartiry/prodam/{NOVOSTROYKA_SLUG}"
return f"{self.base_url}{path}?{urlencode(params)}" return f"{self.base_url}{path}?{urlencode(params)}"
def _build_rooms_url( def _build_rooms_url(
@ -893,7 +904,7 @@ class AvitoScraper(BaseScraper):
min_price: int | None = None, min_price: int | None = None,
max_price: int | None = None, max_price: int | None = None,
) -> str: ) -> str:
"""T6: URL с фильтром по комнатности для всего ЕКБ (no geo). """T6: URL с фильтром по комнатности для всего города (no geo).
Дробление по комнатности позволяет обойти cap ~5000 на category. Дробление по комнатности позволяет обойти cap ~5000 на category.
room_slug берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки). room_slug берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки).
@ -907,7 +918,7 @@ class AvitoScraper(BaseScraper):
params["pmin"] = min_price params["pmin"] = min_price
if max_price is not None: if max_price is not None:
params["pmax"] = max_price params["pmax"] = max_price
return f"{self.base_url}/ekaterinburg/kvartiry/prodam/{room_slug}?{urlencode(params)}" return f"{self.base_url}/{self._city_seg()}/kvartiry/prodam/{room_slug}?{urlencode(params)}"
def _extract_total_count(self, html: str) -> int | None: def _extract_total_count(self, html: str) -> int | None:
"""Извлечь общее число результатов из Avito SERP (`page-title/count`). """Извлечь общее число результатов из Avito SERP (`page-title/count`).

View file

@ -120,6 +120,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,
) -> None: ) -> None:
super().__init__() super().__init__()
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
@ -133,6 +134,9 @@ 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 — общий
# поддомен всей Свердловской обл., НЕ меняется). None → ЕКБ-дефолт (CIAN_EKB_REGION_ID).
self._region_id = city_region_id or CIAN_EKB_REGION_ID
async def __aenter__(self) -> CianScraper: async def __aenter__(self) -> CianScraper:
await super().__aenter__() await super().__aenter__()
@ -173,10 +177,12 @@ class CianScraper(BaseScraper):
rooms: tuple[int, ...] | None = None, rooms: tuple[int, ...] | None = None,
page: int = 1, page: int = 1,
) -> list[ScrapedLot]: ) -> list[ScrapedLot]:
"""Найти объявления Циан по ЕКБ (lat/lon для reference; Cian не поддерживает bbox). """Найти объявления Циан по городу-цели (region_id; lat/lon для reference —
Cian не поддерживает bbox).
Cian SERP отдаёт весь ЕКБ фильтрация по радиусу происходит в postgres Cian SERP отдаёт весь город (self._region_id, ЕКБ-дефолт) фильтрация по
через ST_DWithin после сохранения (точные coords из state). радиусу происходит в postgres через ST_DWithin после сохранения (точные
coords из state).
rooms список из 1,2,3,4 (Cian's room codes). Если None — все. rooms список из 1,2,3,4 (Cian's room codes). Если None — все.
page страница выдачи (Cian ~28 объявлений на страницу). page страница выдачи (Cian ~28 объявлений на страницу).
@ -241,17 +247,18 @@ class CianScraper(BaseScraper):
min_price: int | None = None, min_price: int | None = None,
max_price: int | None = None, max_price: int | None = None,
) -> str: ) -> str:
"""URL для Cian каталога вторички ЕКБ. """URL для Cian каталога вторички города-цели (по умолчанию ЕКБ).
Используем ekb.cian.ru (city-specific subdomain). Используем ekb.cian.ru (city-specific subdomain общий для всей Свердловской
Регион задаётся через region= param как fallback для reliability. обл., НЕ меняется per-city). region= param фактический скоуп выборки
(#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан).
min_price / max_price опциональные ценовые границы (руб.) для price-бакетов. min_price / max_price опциональные ценовые границы (руб.) для price-бакетов.
""" """
params: list[tuple[str, Any]] = [ params: list[tuple[str, Any]] = [
("deal_type", "sale"), ("deal_type", "sale"),
("engine_version", "2"), ("engine_version", "2"),
("offer_type", "flat"), ("offer_type", "flat"),
("region", CIAN_EKB_REGION_ID), ("region", self._region_id),
("sort", "creation_date_desc"), ("sort", "creation_date_desc"),
] ]
if rooms: if rooms:

View file

@ -480,6 +480,7 @@ class YandexRealtyScraper(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_rgid: int | None = None,
) -> None: ) -> None:
super().__init__() super().__init__()
self.city = city self.city = city
@ -494,6 +495,9 @@ class YandexRealtyScraper(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): rgid города-цели SERP-запроса (gate-API). None →
# ЕКБ-дефолт (_EKB_RGID).
self._rgid = city_rgid or _EKB_RGID
# _cffi_session retained only for _rotate_ip (changeip call). # _cffi_session retained only for _rotate_ip (changeip call).
self._cffi_session: _CurlCffiSession | None = None self._cffi_session: _CurlCffiSession | None = None
self._cookies: dict[str, str] = {} self._cookies: dict[str, str] = {}
@ -591,7 +595,7 @@ class YandexRealtyScraper(BaseScraper):
"YES" -> novostroyki (primary-sale / reassignment segment). "YES" -> novostroyki (primary-sale / reassignment segment).
""" """
params: dict[str, str | int] = { params: dict[str, str | int] = {
"rgid": _EKB_RGID, "rgid": self._rgid,
"type": "SELL", "type": "SELL",
"category": "APARTMENT", "category": "APARTMENT",
"newFlat": new_flat, "newFlat": new_flat,
@ -648,7 +652,8 @@ class YandexRealtyScraper(BaseScraper):
price_max: int | None = None, price_max: int | None = None,
new_flat: str = "NO", new_flat: str = "NO",
) -> list[ScrapedLot]: ) -> list[ScrapedLot]:
"""Fetch ONE page of gate-API results. lat/lon/radius_m ignored (uses rgid). """Fetch ONE page of gate-API results. lat/lon/radius_m ignored (uses rgid,
city-scoped self._rgid, ЕКБ-дефолт если city_rgid не задан).
page: 1-based. Tarpit resilience: status_code==0 or JSON error page: 1-based. Tarpit resilience: status_code==0 or JSON error
-> rotate IP + retry up to _YANDEX_TARPIT_MAX_RETRIES times. -> rotate IP + retry up to _YANDEX_TARPIT_MAX_RETRIES times.