From 698576e77b11001ed1fd409b443d9744a611f835 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 13 Jul 2026 21:41:07 +0000 Subject: [PATCH] fix(tradein/scraper): per-city SERP location for oblast city-sweep (#12) (#2525) Co-authored-by: lekss361 Co-committed-by: lekss361 --- .../tests/test_serp_city_location_urls.py | 104 ++++++++++++++++++ .../src/scraper_kit/orchestration/pipeline.py | 66 ++++++++++- .../scraper_kit/orchestration/scheduler.py | 2 + .../src/scraper_kit/providers/avito/serp.py | 25 +++-- .../src/scraper_kit/providers/cian/serp.py | 21 ++-- .../src/scraper_kit/providers/yandex/serp.py | 9 +- 6 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_serp_city_location_urls.py diff --git a/tradein-mvp/backend/tests/test_serp_city_location_urls.py b/tradein-mvp/backend/tests/test_serp_city_location_urls.py new file mode 100644 index 00000000..72e24bff --- /dev/null +++ b/tradein-mvp/backend/tests/test_serp_city_location_urls.py @@ -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)] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index bc6b5397..756f315e 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -310,6 +310,39 @@ def get_city_anchors(city_slug: str | None) -> list[tuple[float, float, str]] | 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 = { "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", @@ -855,9 +888,14 @@ async def run_avito_city_sweep( зовётся через enrichment (EnrichmentJobs Protocol). """ # city_slug (#2487): слаг города-цели (напр. "nizhniy_tagil") прокидывается в - # AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города. + # AvitoScraper.target_city_slug → SERP-фильтр оставляет карточки этого города, + # а avito_slug (#12) — в путь URL, скоупя сам запрос на город-цель вместо ЕКБ. # None → ЕКБ-дефолт (совпадает с anchors=EKB_ANCHORS fallback ниже). _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)) all_touched_house_ids: set[int] = set() @@ -964,8 +1002,9 @@ async def run_avito_city_sweep( # ── Phase 1+2: SERP + save ───────────────────────────────── # #2487: target_city_slug → _parse_html оставляет карточки - # города-цели (oblast slug), а не хардкод /ekaterinburg/. None → ЕКБ. - scraper = AvitoScraper(config, target_city_slug=city_slug) + # города-цели (oblast slug). #12: _avito_slug (может отличаться + # от city_slug) идёт в путь URL — не хардкод /ekaterinburg/. None → ЕКБ. + scraper = AvitoScraper(config, target_city_slug=_avito_slug) if browser_mode: scraper._browser = shared_bf # Shared-browser режим: _cffi=None → curl_cffi-fallback на @@ -1706,6 +1745,7 @@ async def run_yandex_city_sweep( shutdown_requested: Callable[[], bool] = lambda: False, proxy_provider: ProxyProvider | None = None, anchors: list[tuple[float, float, str]] | None = None, + city_slug: str | None = None, radius_m: int = 25000, pages_per_anchor: int = 2, request_delay_sec: float | None = None, @@ -1739,6 +1779,10 @@ async def run_yandex_city_sweep( else: _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()) _price_ranges = price_ranges or DEFAULT_PRICE_RANGES # newFlat-сегменты: оба прохода (vtorichka + novostroyki) по одним combos. @@ -1864,7 +1908,11 @@ async def run_yandex_city_sweep( 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( _a_lat, _a_lon, @@ -2213,6 +2261,7 @@ async def run_cian_city_sweep( shutdown_requested: Callable[[], bool] = lambda: False, proxy_provider: ProxyProvider | None = None, anchors: list[tuple[float, float, str]] | None = None, + city_slug: str | None = None, radius_m: int = 1500, pages_per_anchor: int = 3, request_delay_sec: float = 5.0, @@ -2236,6 +2285,9 @@ async def run_cian_city_sweep( mark_done вызывается ВСЕГДА (finally outer). """ _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)) consecutive_failures = 0 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 # ── 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( _a_lat, _a_lon, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 4bd24103..46e52841 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -538,6 +538,7 @@ async def _job_yandex_city_sweep( shutdown_requested=ctx.shutdown_requested, proxy_provider=ctx.proxy_provider, anchors=anchors, + city_slug=city, pages_per_anchor=int(params.get("pages_per_anchor", 2)), request_delay_sec=float(params.get("request_delay_sec", 9.0)), radius_m=int(params.get("radius_m", 1500)), @@ -560,6 +561,7 @@ async def _job_cian_city_sweep( shutdown_requested=ctx.shutdown_requested, proxy_provider=ctx.proxy_provider, anchors=anchors, + city_slug=city, pages_per_anchor=int(params.get("pages_per_anchor", 3)), request_delay_sec=float(params.get("request_delay_sec", 5.0)), radius_m=int(params.get("radius_m", 1500)), diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 4c1a0bd0..9b6e7329 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -859,6 +859,11 @@ class AvitoScraper(BaseScraper): return all_lots # ── 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: params = { "geoCoords": f"{lat},{lon}", @@ -866,24 +871,30 @@ class AvitoScraper(BaseScraper): "s": 104, # sort by date "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: - """T6: URL всего города ЕКБ без geo-фильтра, сортировка по дате. + """T6: URL всего города (по умолчанию ЕКБ) без geo-фильтра, сортировка по дате. Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек). """ 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: - """URL ЕКБ-выборки только новостроек (novostroyka-filter), сортировка по дате. + """URL городской выборки только новостроек (novostroyka-filter), сортировка по дате. Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки. """ 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)}" def _build_rooms_url( @@ -893,7 +904,7 @@ class AvitoScraper(BaseScraper): min_price: int | None = None, max_price: int | None = None, ) -> str: - """T6: URL с фильтром по комнатности для всего ЕКБ (no geo). + """T6: URL с фильтром по комнатности для всего города (no geo). Дробление по комнатности позволяет обойти cap ~5000 на category. room_slug — берётся из ROOM_SLUGS (кодирует ASgB-параметры выборки). @@ -907,7 +918,7 @@ class AvitoScraper(BaseScraper): params["pmin"] = min_price if max_price is not None: 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: """Извлечь общее число результатов из Avito SERP (`page-title/count`). diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py index 3c7f5176..7b03fca5 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/serp.py @@ -120,6 +120,7 @@ class CianScraper(BaseScraper): *, delay_provider: Callable[[str], float] | None = None, proxy_provider: ProxyProvider | None = None, + city_region_id: int | None = None, ) -> None: super().__init__() # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи @@ -133,6 +134,9 @@ 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). + self._region_id = city_region_id or CIAN_EKB_REGION_ID async def __aenter__(self) -> CianScraper: await super().__aenter__() @@ -173,10 +177,12 @@ class CianScraper(BaseScraper): rooms: tuple[int, ...] | None = None, page: int = 1, ) -> list[ScrapedLot]: - """Найти объявления Циан по ЕКБ (lat/lon для reference; Cian не поддерживает bbox). + """Найти объявления Циан по городу-цели (region_id; lat/lon для reference — + Cian не поддерживает bbox). - Cian SERP отдаёт весь ЕКБ — фильтрация по радиусу происходит в postgres - через ST_DWithin после сохранения (точные coords из state). + Cian SERP отдаёт весь город (self._region_id, ЕКБ-дефолт) — фильтрация по + радиусу происходит в postgres через ST_DWithin после сохранения (точные + coords из state). rooms — список из 1,2,3,4 (Cian's room codes). Если None — все. page — страница выдачи (Cian ~28 объявлений на страницу). @@ -241,17 +247,18 @@ class CianScraper(BaseScraper): min_price: int | None = None, max_price: int | None = None, ) -> str: - """URL для Cian каталога вторички ЕКБ. + """URL для Cian каталога вторички города-цели (по умолчанию ЕКБ). - Используем ekb.cian.ru (city-specific subdomain). - Регион задаётся через region= param как fallback для reliability. + Используем ekb.cian.ru (city-specific subdomain — общий для всей Свердловской + обл., НЕ меняется per-city). region= param — фактический скоуп выборки + (#12: self._region_id, ЕКБ-дефолт CIAN_EKB_REGION_ID если city_region_id не задан). min_price / max_price — опциональные ценовые границы (руб.) для price-бакетов. """ params: list[tuple[str, Any]] = [ ("deal_type", "sale"), ("engine_version", "2"), ("offer_type", "flat"), - ("region", CIAN_EKB_REGION_ID), + ("region", self._region_id), ("sort", "creation_date_desc"), ] if rooms: diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py index 8a42a6f1..d05ad5fb 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/serp.py @@ -480,6 +480,7 @@ class YandexRealtyScraper(BaseScraper): *, delay_provider: Callable[[str], float] | None = None, proxy_provider: ProxyProvider | None = None, + city_rgid: int | None = None, ) -> None: super().__init__() self.city = city @@ -494,6 +495,9 @@ class YandexRealtyScraper(BaseScraper): # None → BrowserFetcher без пула = env-прокси браузера (ship-dark, как сейчас). self._proxy_provider = proxy_provider 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). self._cffi_session: _CurlCffiSession | None = None self._cookies: dict[str, str] = {} @@ -591,7 +595,7 @@ class YandexRealtyScraper(BaseScraper): "YES" -> novostroyki (primary-sale / reassignment segment). """ params: dict[str, str | int] = { - "rgid": _EKB_RGID, + "rgid": self._rgid, "type": "SELL", "category": "APARTMENT", "newFlat": new_flat, @@ -648,7 +652,8 @@ class YandexRealtyScraper(BaseScraper): price_max: int | None = None, new_flat: str = "NO", ) -> 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 -> rotate IP + retry up to _YANDEX_TARPIT_MAX_RETRIES times.