From b1fb0c571172d3afa87aaa846d6f831b35759f72 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 19 Jun 2026 20:41:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(scrapers):=20cian=20SERP=20=D0=BD=D0=B0=20?= =?UTF-8?q?BrowserFetcher=20(camoufox),=20=D1=81=D0=BD=D1=8F=D1=82=20code-?= =?UTF-8?q?side=20rotate=20(#1806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/services/scrapers/cian.py | 136 ++++++------------ .../tests/scrapers/test_cian_exhaustive.py | 2 +- .../backend/tests/test_cian_serp_scraper.py | 98 +++++++++++++ 3 files changed, 144 insertions(+), 92 deletions(-) diff --git a/tradein-mvp/backend/app/services/scrapers/cian.py b/tradein-mvp/backend/app/services/scrapers/cian.py index a344cff3..7fda5282 100644 --- a/tradein-mvp/backend/app/services/scrapers/cian.py +++ b/tradein-mvp/backend/app/services/scrapers/cian.py @@ -5,9 +5,12 @@ - URL pattern: https://ekb.cian.ru/cat.php?deal_type=sale&offer_type=flat&engine_version=2 ekb.cian.ru — city-specific subdomain для ЕКБ (per Schema_Cian_SERP_Inventory sec 13). -ВАЖНО: Циан блокирует httpx по TLS fingerprint → "Обнаружен подозрительный трафик" (403). -Используем curl_cffi с impersonate='chrome120' — это libcurl-impersonate под капотом, -который воспроизводит TLS ClientHello как настоящий Chrome. +ВАЖНО: Циан блокирует httpx/curl по TLS+fingerprint и сыплет Google reCAPTCHA на +датацентр-IP. Транспорт SERP переведён на BrowserFetcher (camoufox real-browser +fingerprint через tradein-browser сервис, #1806 / epic #883 Phase 2). Браузер +отдаёт полный SSR-HTML с Redux initialState — парсинг state не меняется. +Мобильный прокси (ha.mobileproxy.space) применяется server-side в браузер-контейнере +и ротирует IP сам, поэтому code-side _rotate_ip (changeip) больше не нужен. НЕ используем anchor jitter: offer.geo.coordinates.{lat,lng} — точные координаты прямо из SERP state. Jitter запрещён per implementation plan. @@ -16,7 +19,6 @@ Exhaustive load (fetch_all_secondary): - Cian SERP — регион-wide (не geo-bbox). Anchor-loop в существующем city-sweep избыточен для Cian (все anchor'ы дают одну выдачу). Полный сбор = ОДИН проход с партиционированием по КОМНАТНОСТИ × ЦЕНЕ (адаптивное бинарное деление диапазона). -- IP-ротация через _rotate_ip() (changeip, зеркало avito.py). """ from __future__ import annotations @@ -32,11 +34,11 @@ from typing import Any from urllib.parse import urlencode import sentry_sdk -from curl_cffi.requests import AsyncSession from app.core.config import settings from app.services.scraper_settings import get_scraper_delay from app.services.scrapers.base import BaseScraper, ScrapedLot +from app.services.scrapers.browser_fetcher import BrowserFetcher from app.services.scrapers.cian_state_parser import extract_state from app.services.scrapers.price_brackets import get_price_seed_brackets from app.services.scrapers.repair_state_normalizer import ( @@ -82,10 +84,14 @@ _DEFAULT_ROOMS_BUCKETS: list[tuple[int, ...]] = [ class CianScraper(BaseScraper): - """Cian SERP scraper. Использует curl_cffi для обхода TLS fingerprint. + """Cian SERP scraper. Тянет SSR-HTML через BrowserFetcher (camoufox). Извлекает 137 полей на offer из Redux initialState через cian_state_parser. Координаты точные — НЕТ anchor jitter (offer.geo.coordinates). + + Транспорт: одна BrowserFetcher(source="cian") сессия открывается в __aenter__ + и переиспользуется на всех page-fetch'ах (#1806). camoufox-fingerprint + мобильный + прокси server-side обходят Cian reCAPTCHA; IP ротирует сам прокси. """ name = "cian" @@ -97,79 +103,34 @@ class CianScraper(BaseScraper): def __init__(self) -> None: super().__init__() self.request_delay_sec = get_scraper_delay(self.name) - self._cffi: AsyncSession | None = None + self._browser: BrowserFetcher | None = None async def __aenter__(self) -> CianScraper: await super().__aenter__() - # curl_cffi session с Chrome 120 TLS fingerprint — КРИТИЧЕСКИ ВАЖНО для Cian. - # proxies: mobile-proxy egress (#806) — аналогично Avito (#623). Без прокси - # datacenter-IP блокируется Cian (403/captcha) несмотря на валидные cookies. - # Пусто (SCRAPER_PROXY_URL/AVITO_PROXY_URL не заданы) → прямое подключение (dev). - _proxy_url = settings.cian_proxy_url - _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None - self._cffi = AsyncSession( - impersonate="chrome120", - timeout=30, - proxies=_proxies, - 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", - "Cache-Control": "max-age=0", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - }, - ) - # Warm-up: посетить homepage чтобы получить сессионные cookies (_CIAN_GK/_yasc) - # и не выглядеть как наглый бот (первый запрос сразу на /cat.php подозрителен). - # Failure не критична — SERP-запросы продолжатся в любом случае. - try: - _warm = await self._cffi.get(f"{self.base_url}/") - logger.info( - "cian warm-up GET / → HTTP %d (cookies=%d)", - _warm.status_code, - len(list(self._cffi.cookies.jar)), - ) - # Последующие SERP-запросы выглядят как in-site navigation (Referer = /) - self._cffi.headers.update( - { - "Referer": f"{self.base_url}/", - "Sec-Fetch-Site": "same-origin", - } - ) - except Exception: - logger.warning("cian warm-up GET / failed — continuing without seed cookies") + # Одна BrowserFetcher(source="cian") сессия на весь scrape — camoufox real-browser + # fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на + # отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому + # code-side warm-up cookies и changeip-ротация больше не нужны. + self._browser = BrowserFetcher(source="cian") + await self._browser.__aenter__() return self async def __aexit__(self, *args: Any) -> None: - if self._cffi is not None: - await self._cffi.close() + if self._browser is not None: + await self._browser.__aexit__(*args) + self._browser = None await super().__aexit__(*args) - # ── Anti-block: IP rotation (зеркало avito.py _rotate_ip) ───────────────── + # ── Anti-block: IP rotation ─────────────────────────────────────────────── async def _rotate_ip(self) -> bool: - """Сменить мобильный IP через changeip-ссылку mobileproxy. + """No-op после миграции на BrowserFetcher (#1806). - Дёргается напрямую (без прокси) — это API провайдера, не Cian. Ждём ~9с: - мобильному модему нужно время поднять новый IP. Returns True при успехе. - Зеркало AvitoScraper._rotate_ip (#623). + Мобильный прокси в браузер-контейнере ротирует IP сам, code-side changeip + больше не нужен. Метод сохранён (returns False) для обратной совместимости + с вызовом в _walk_price_range и тестами, которые его мокают. """ - rotate_url = settings.cian_proxy_rotate_url or settings.avito_proxy_rotate_url - if not rotate_url: - return False - sep = "&" if "?" in rotate_url else "?" - try: - async with AsyncSession(timeout=30) as rot: - await rot.get(f"{rotate_url}{sep}format=json") - await asyncio.sleep(9) - logger.info("cian proxy: IP rotated via changeip") - return True - except Exception: - logger.warning("cian proxy: IP rotation failed", exc_info=True) - return False + return False async def fetch_around( self, @@ -189,17 +150,13 @@ class CianScraper(BaseScraper): """ url = self._build_url(rooms, page) try: - assert self._cffi is not None - response = await self._cffi.get(url) + assert self._browser is not None + html = await self._browser.fetch(url) except Exception: - logger.exception("cian curl_cffi fetch failed for url=%s", url) + logger.exception("cian BrowserFetcher fetch failed for url=%s", url) return [] - if response.status_code != 200: - logger.warning("cian returned HTTP %d for url=%s", response.status_code, url) - return [] - - lots = self._parse_serp_html(response.text) + lots = self._parse_serp_html(html) logger.info( "cian: %d lots fetched rooms=%s page=%d url=%s", len(lots), @@ -299,18 +256,14 @@ class CianScraper(BaseScraper): min_price: int | None, max_price: int | None, ) -> str | None: - """GET одной SERP-страницы, возвращает HTML или None при ошибке.""" - assert self._cffi is not None + """GET одной SERP-страницы через BrowserFetcher, возвращает HTML или None при ошибке.""" + assert self._browser is not None url = self._build_url(rooms, page, min_price, max_price) try: - resp = await self._cffi.get(url) + return await self._browser.fetch(url) except Exception: - logger.exception("cian: GET failed url=%s", url) + logger.exception("cian: BrowserFetcher fetch failed url=%s", url) return None - if resp.status_code != 200: - logger.warning("cian: HTTP %d for url=%s", resp.status_code, url) - return None - return resp.text async def fetch_all_secondary( self, @@ -446,21 +399,22 @@ class CianScraper(BaseScraper): if html is not None: total = self._extract_total_offers(html) - # Ретрай на captcha/ошибку: rotate IP + 1 retry + # Ретрай на captcha/ошибку: 1 повторный fetch. Мобильный прокси в браузер- + # контейнере ротирует IP сам (#1806), поэтому отдельный changeip-вызов + # (_rotate_ip) больше не нужен — просто повторяем запрос на чистом IP. if total is None: logger.warning( - "cian: totalOffers=None for %s [%d, %s] depth=%d — rotating IP + retry", + "cian: totalOffers=None for %s [%d, %s] depth=%d — retry (proxy auto-rotates IP)", room_label, lo, _hi_repr, _depth, ) - rotated = await self._rotate_ip() - if rotated: - html = await self._fetch_page_html(rooms, 1, _lo_param, hi) - await self.sleep_between_requests() - if html is not None: - total = self._extract_total_offers(html) + await self._rotate_ip() # no-op (back-compat); прокси ротирует IP сам + html = await self._fetch_page_html(rooms, 1, _lo_param, hi) + await self.sleep_between_requests() + if html is not None: + total = self._extract_total_offers(html) if total is None: logger.error( diff --git a/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py b/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py index 0eb8bd48..888918ce 100644 --- a/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py +++ b/tradein-mvp/backend/tests/scrapers/test_cian_exhaustive.py @@ -51,7 +51,7 @@ def scraper() -> CianScraper: s.name = "cian" s.base_url = "https://ekb.cian.ru" s.request_delay_sec = 0.0 - s._cffi = MagicMock() # не None → assert self._cffi is not None пройдёт + s._browser = MagicMock() # не None → assert self._browser is not None пройдёт return s diff --git a/tradein-mvp/backend/tests/test_cian_serp_scraper.py b/tradein-mvp/backend/tests/test_cian_serp_scraper.py index a12a4029..e6feffd9 100644 --- a/tradein-mvp/backend/tests/test_cian_serp_scraper.py +++ b/tradein-mvp/backend/tests/test_cian_serp_scraper.py @@ -5,6 +5,7 @@ Fixture формирует минимальный JS push() conforming to _cianC """ import json +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -454,6 +455,103 @@ def test_cian_serp_no_coords_does_not_crash(): assert lots[0].lon is None +# ── Transport: BrowserFetcher (camoufox) миграция (#1806) ──────────────────── + + +@pytest.mark.asyncio +async def test_cian_aenter_opens_browser_fetcher() -> None: + """__aenter__ открывает ОДНУ BrowserFetcher(source="cian") сессию (camoufox).""" + mock_browser = MagicMock() + mock_browser.__aenter__ = AsyncMock(return_value=mock_browser) + mock_browser.__aexit__ = AsyncMock(return_value=None) + + captured: dict = {} + + def _fake_browser(*args, **kwargs): + captured["source"] = kwargs.get("source") if kwargs else (args[0] if args else None) + return mock_browser + + scraper = CianScraper() + with patch("app.services.scrapers.cian.BrowserFetcher", _fake_browser): + await scraper.__aenter__() + + mock_browser.__aenter__.assert_awaited_once() + assert scraper._browser is mock_browser + assert captured["source"] == "cian" + + await scraper.__aexit__(None, None, None) + mock_browser.__aexit__.assert_awaited_once() + assert scraper._browser is None + + +@pytest.mark.asyncio +async def test_cian_fetch_page_html_uses_browser_fetcher() -> None: + """_fetch_page_html тянет HTML через BrowserFetcher.fetch, не через curl_cffi.""" + scraper = CianScraper.__new__(CianScraper) + scraper.name = "cian" + scraper.base_url = "https://ekb.cian.ru" + scraper.request_delay_sec = 0.0 + + fetched_urls: list[str] = [] + + async def fake_fetch(url: str) -> str: + fetched_urls.append(url) + return "serp-page" + + scraper._browser = MagicMock() + scraper._browser.fetch = AsyncMock(side_effect=fake_fetch) + + html = await scraper._fetch_page_html((2,), 1, None, None) + + assert html == "serp-page" + assert len(fetched_urls) == 1 + assert "cat.php" in fetched_urls[0] + + +@pytest.mark.asyncio +async def test_cian_fetch_page_html_returns_none_on_browser_error() -> None: + """Если BrowserFetcher.fetch падает — _fetch_page_html возвращает None (не краш).""" + scraper = CianScraper.__new__(CianScraper) + scraper.name = "cian" + scraper.base_url = "https://ekb.cian.ru" + scraper.request_delay_sec = 0.0 + + scraper._browser = MagicMock() + scraper._browser.fetch = AsyncMock(side_effect=RuntimeError("browser down")) + + html = await scraper._fetch_page_html((1,), 2, 1_000_000, 5_000_000) + assert html is None + + +@pytest.mark.asyncio +async def test_cian_fetch_around_parses_browser_html() -> None: + """fetch_around парсит лоты из SSR-HTML, отданного BrowserFetcher.""" + offer = _make_full_offer(cian_id=777) + serp_html = _make_cian_serp_html([offer]) + + scraper = CianScraper.__new__(CianScraper) + scraper.name = "cian" + scraper.base_url = "https://ekb.cian.ru" + scraper.request_delay_sec = 0.0 + scraper._browser = MagicMock() + scraper._browser.fetch = AsyncMock(return_value=serp_html) + + with patch.object(scraper, "sleep_between_requests", new_callable=AsyncMock): + lots = await scraper.fetch_around(56.84, 60.60, rooms=(2,), page=1) + + assert len(lots) == 1 + assert lots[0].source_id == "777" + scraper._browser.fetch.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_cian_rotate_ip_is_noop() -> None: + """_rotate_ip после миграции — no-op, всегда False (прокси ротирует IP сам).""" + scraper = CianScraper.__new__(CianScraper) + result = await scraper._rotate_ip() + assert result is False + + # ── Address formatter tests ────────────────────────────────────────────────── -- 2.45.3