diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index c649460f..748c0f2b 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -309,12 +309,13 @@ class Settings(BaseSettings): cian_login_success_cookie: str = "DMIR_AUTH" cian_login_wait_ms: int = 4000 - # ── #884/#905: BrowserFetcher — HTTP-клиент к tradein-browser контейнеру ──── - # scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser" - # (HTTP POST к tradein-browser /fetch эндпоинту). Phase 1+. - # Пока дефолт "curl_cffi" — никаких изменений в рантайме. - # ENV: SCRAPER_FETCH_MODE. - scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi" + # ── #884/#905/#1805: BrowserFetcher — HTTP-клиент к tradein-browser ───────── + # scraper_fetch_mode: "browser" (дефолт с #1805 — HTTP POST к tradein-browser + # /fetch через per-provider camoufox + ротирующий backconnect-прокси) или + # "curl_cffi" (legacy TLS-impersonate путь). Phase 2 epic #883: avito SERP-фетч + # по умолчанию через браузер; curl_cffi сохранён как fallback (см. + # AvitoScraper._fetch_serp_html) и явный opt-out через ENV SCRAPER_FETCH_MODE. + scraper_fetch_mode: Literal["curl_cffi", "browser"] = "browser" # HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose. # ENV: BROWSER_HTTP_ENDPOINT. browser_http_endpoint: str = "http://tradein-browser:3000" diff --git a/tradein-mvp/backend/app/services/scrapers/avito.py b/tradein-mvp/backend/app/services/scrapers/avito.py index 8ba14f6e..2acdd7ad 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito.py +++ b/tradein-mvp/backend/app/services/scrapers/avito.py @@ -339,11 +339,21 @@ class AvitoScraper(BaseScraper): logger.warning( "avito: proactive IP rotate at sweep start raised — proceeding", exc_info=True ) - if settings.scraper_fetch_mode == "browser": + if settings.scraper_fetch_mode != "curl_cffi": + # Default (#1805): SERP-фетч через camoufox (tradein-browser per-provider + # + ротирующий backconnect-прокси). curl_cffi поднимаем тут же как + # FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает + # один retry через curl_cffi. _build_cffi_session самодостаточен + # (отдельный AsyncSession, не зависит от BaseScraper.__aenter__). self._browser = BrowserFetcher(source="avito") await self._browser.__aenter__() - logger.info("avito: SERP fetch via BrowserFetcher (camoufox) — #901") + self._cffi = self._build_cffi_session() + logger.info( + "avito: SERP fetch via BrowserFetcher (camoufox) default, " + "curl_cffi fallback armed — #1805" + ) return self + # Explicit opt-out: legacy curl_cffi-only путь (SCRAPER_FETCH_MODE=curl_cffi). await super().__aenter__() self._cffi = self._build_cffi_session() return self @@ -413,40 +423,86 @@ class AvitoScraper(BaseScraper): return False async def _fetch_serp_html(self, url: str, page: int) -> str | None: - """GET SERP HTML с детектом IP-бана и ротацией мобильного IP (#623). + """GET SERP HTML — dispatcher между browser-default и curl_cffi (#1805). - Avito на забаненном datacenter-IP отдаёт HTTP 200 + firewall-страницу - (не 403). Детектим по тексту; если задана changeip-ссылка — меняем IP и - повторяем до ``avito_proxy_max_rotations`` раз. Транзиентные сетевые - обрывы (мобильный канал) ретраим без ротации. + Если поднят BrowserFetcher (default с #1805) — фетчим через camoufox с + curl_cffi-fallback на firewall (_fetch_serp_html_browser). Иначе + (legacy/explicit curl_cffi opt-out, либо тесты с прямым _cffi) — прямой + curl_cffi-путь (_fetch_serp_html_cffi). + + Returns: + HTML при успехе, либо ``None`` при non-200 (конец пагинации). + Raises: + AvitoBlockedError / AvitoRateLimitedError — если бан не снят. + """ + if self._browser is not None: + return await self._fetch_serp_html_browser(url, page) + return await self._fetch_serp_html_cffi(url, page) + + async def _fetch_serp_html_browser(self, url: str, page: int) -> str | None: + """SERP-фетч через camoufox-браузер (#1805, default) с curl_cffi-fallback. + + Браузер не отдаёт HTTP-статус — бан виден только как rendered firewall-HTML + (camoufox исполняет JS, поэтому firewall-интерстициал приходит готовым + документом; ловим теми же текст-маркерами _is_firewall_page). + + Pipeline: + 1. browser.fetch → firewall? → ротация IP (до max_rotations) + retry. + 2. Ротация исчерпана и всё ещё firewall/пусто → ОДИН retry через curl_cffi + (другой egress/TLS-fingerprint может проскочить). curl-fallback + доступен только если _cffi поднят (standalone __aenter__); в + pipeline-режиме с shared-browser _cffi=None → fallback пропускается. + 3. curl-fallback тоже firewall/пусто (или недоступен) → AvitoBlockedError. + """ + assert self._browser is not None + max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0 + rot_done = 0 + while True: + html = await self._browser.fetch(url) + if not _is_firewall_page(html): + return html + if rot_done < max_rot and await self._rotate_ip(): + rot_done += 1 + logger.info( + "avito page=%d browser firewall — retry after rotation #%d", + page, + rot_done, + ) + continue + # Ротация исчерпана/недоступна. Fallback: один retry через curl_cffi. + if self._cffi is not None: + logger.warning( + "avito page=%d browser firewall — falling back to curl_cffi (#1805)", + page, + ) + try: + fb_html = await self._fetch_serp_html_cffi(url, page) + except (AvitoBlockedError, AvitoRateLimitedError): + # curl-fallback тоже забанен → пробрасываем как hard-block. + logger.error("avito page=%d curl_cffi fallback also blocked url=%s", page, url) + raise + if fb_html is None or not _is_firewall_page(fb_html): + # None = non-200/конец пагинации; иначе валидный HTML. + return fb_html + logger.error("avito page=%d curl_cffi fallback still firewall url=%s", page, url) + logger.error("avito SERP firewall in browser-mode page=%d url=%s", page, url) + raise AvitoBlockedError( + f"Avito SERP firewall (browser-mode) at page={page} — IP banned" + ) + + async def _fetch_serp_html_cffi(self, url: str, page: int) -> str | None: + """GET SERP HTML через curl_cffi (legacy/fallback путь) с anti-block-логикой. + + Avito на забаненном datacenter-IP отдаёт HTTP 200 + firewall-страницу (не + 403). 429 через backconnect-прокси — transient conn-limit (short-retry без + ротации). 403/firewall на backconnect — залочен текущий exit-IP, escape + через reconnect (_reset_cffi) либо changeip-ротацию. Returns: HTML при HTTP 200, либо ``None`` при non-200 (конец пагинации). - В browser-mode HTTP-статус недоступен: метод возвращает HTML или - поднимает ``AvitoBlockedError`` (pagination ends via empty-parse in callers). Raises: - AvitoBlockedError / AvitoRateLimitedError — если бан не снят ротацией. + AvitoBlockedError / AvitoRateLimitedError — если бан не снят. """ - if self._browser is not None: - # Browser-mode (#901): no HTTP status code — soft firewall-detect + rotate. - max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0 - rot_done = 0 - while True: - html = await self._browser.fetch(url) - if _is_firewall_page(html): - if rot_done < max_rot and await self._rotate_ip(): - rot_done += 1 - logger.info( - "avito page=%d browser firewall — retry after rotation #%d", - page, - rot_done, - ) - continue - logger.error("avito SERP firewall in browser-mode page=%d url=%s", page, url) - raise AvitoBlockedError( - f"Avito SERP firewall (browser-mode) at page={page} — IP banned" - ) - return html assert self._cffi is not None max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0 # curl_cffi-сессия всегда egress через settings.scraper_proxy_url (backconnect @@ -1901,8 +1957,7 @@ _NOT_ADDRESS_TAIL_RE = re.compile( # первую букву обычного слова. Корпус «сN/кN» требует цифры, поэтому не путается # с маркерами «с.» (село) / «к» — у тех нет цифры вплотную. _GLUE_MARKER = ( - r"(?:р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок" - r"|снт|кв-л|квартал|д\.|с\.|пгт)" + r"(?:р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок" r"|снт|кв-л|квартал|д\.|с\.|пгт)" ) _GLUE_HOUSE_TOKEN = ( r"\d+" # дом: цифры diff --git a/tradein-mvp/backend/tests/test_avito_browser_mode.py b/tradein-mvp/backend/tests/test_avito_browser_mode.py index 75e453be..3959f9fb 100644 --- a/tradein-mvp/backend/tests/test_avito_browser_mode.py +++ b/tradein-mvp/backend/tests/test_avito_browser_mode.py @@ -1,9 +1,10 @@ -"""#901 — avito SERP fetch via BrowserFetcher behind scraper_fetch_mode flag. +"""#901/#1805 — avito SERP fetch via BrowserFetcher (camoufox), curl_cffi fallback. Tests: -1. browser branch chosen: mode="browser" → _browser set, _cffi is None, HTML from mock browser. -2. curl branch unchanged: default mode → _browser is None, _cffi session used. -3. firewall-detect in browser-mode: firewall HTML + no rotate URL → AvitoBlockedError. +1. browser branch default (#1805): __aenter__ → _browser set AND _cffi fallback armed. +2. curl branch unchanged: _browser is None → _cffi session used directly. +3. firewall-detect in browser-mode: firewall HTML + no rotate URL + no cffi → AvitoBlockedError. +3b. #1805 curl_cffi fallback: browser firewall → curl retry returns HTML / re-raises if also banned. 4. pagination terminates via empty-parse: browser HTML with 0 lots → caller loop breaks. Без сети, без БД, без curl_cffi. @@ -45,8 +46,12 @@ def _make_mock_browser(html: str = _NORMAL_HTML) -> MagicMock: @pytest.mark.asyncio -async def test_aenter_browser_mode_sets_browser_attribute() -> None: - """mode=browser → __aenter__ instantiates BrowserFetcher, _cffi stays None.""" +async def test_aenter_browser_mode_arms_cffi_fallback() -> None: + """#1805: mode=browser → __aenter__ поднимает BrowserFetcher И curl_cffi-fallback. + + Раньше browser-mode оставлял _cffi=None; теперь curl_cffi-сессия строится тут + же как fallback на firewall/пусто (см. _fetch_serp_html_browser). + """ mock_browser = _make_mock_browser() scraper = AvitoScraper() @@ -60,16 +65,13 @@ async def test_aenter_browser_mode_sets_browser_attribute() -> None: "app.services.scrapers.avito.BrowserFetcher", return_value=mock_browser, ): - # Patch BaseScraper.__aenter__ so we skip its real setup (DB/network) - with patch( - "app.services.scrapers.base.BaseScraper.__aenter__", - new=AsyncMock(return_value=scraper), - ): - result = await scraper.__aenter__() + result = await scraper.__aenter__() assert result is scraper assert scraper._browser is mock_browser - assert scraper._cffi is None + # curl_cffi fallback armed (real AsyncSession built, no network/proxy). + assert scraper._cffi is not None + await scraper._cffi.close() @pytest.mark.asyncio @@ -199,10 +201,11 @@ async def test_fetch_serp_html_curl_mode_uses_cffi() -> None: @pytest.mark.asyncio async def test_browser_mode_firewall_raises_blocked_error() -> None: - """Browser returns firewall page + no rotate URL configured → AvitoBlockedError.""" + """Browser firewall + no rotate URL + no curl fallback (_cffi=None) → AvitoBlockedError.""" scraper = AvitoScraper() mock_browser = _make_mock_browser(_FIREWALL_HTML) scraper._browser = mock_browser + scraper._cffi = None # no curl_cffi fallback available (pipeline shared-browser path) with patch("app.services.scrapers.avito.settings") as mock_settings: mock_settings.avito_proxy_rotate_url = None # no rotation configured @@ -212,6 +215,61 @@ async def test_browser_mode_firewall_raises_blocked_error() -> None: await scraper._fetch_serp_html("https://www.avito.ru/test", page=1) +# --------------------------------------------------------------------------- +# 3b. #1805 — curl_cffi fallback when browser returns firewall +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_browser_firewall_falls_back_to_cffi_returns_html() -> None: + """#1805: browser firewall + curl_cffi fallback armed → curl returns clean HTML.""" + scraper = AvitoScraper() + scraper._browser = _make_mock_browser(_FIREWALL_HTML) + + # curl_cffi fallback: HTTP 200 with clean (non-firewall) HTML. + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = _NORMAL_HTML + mock_cffi = MagicMock() + mock_cffi.get = AsyncMock(return_value=mock_resp) + scraper._cffi = mock_cffi + + with patch("app.services.scrapers.avito.settings") as mock_settings: + mock_settings.avito_proxy_rotate_url = None + mock_settings.avito_proxy_max_rotations = 0 + mock_settings.scraper_proxy_url = None + + result = await scraper._fetch_serp_html("https://www.avito.ru/test", page=1) + + assert result == _NORMAL_HTML + mock_cffi.get.assert_awaited_once_with("https://www.avito.ru/test") + + +@pytest.mark.asyncio +async def test_browser_firewall_cffi_fallback_also_firewall_raises() -> None: + """#1805: browser firewall AND curl_cffi fallback also firewall → AvitoBlockedError.""" + scraper = AvitoScraper() + scraper._browser = _make_mock_browser(_FIREWALL_HTML) + + # curl_cffi fallback: HTTP 200 but ALSO firewall HTML → no escape. + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = _FIREWALL_HTML + mock_cffi = MagicMock() + mock_cffi.get = AsyncMock(return_value=mock_resp) + scraper._cffi = mock_cffi + + with patch("app.services.scrapers.avito.settings") as mock_settings: + mock_settings.avito_proxy_rotate_url = None + mock_settings.avito_proxy_max_rotations = 0 + mock_settings.scraper_proxy_url = None # no backconnect reconnect retries + + # curl fallback raises its own AvitoBlockedError (firewall на HTTP 200); + # browser branch re-raises it as a hard-block. + with pytest.raises(AvitoBlockedError): + await scraper._fetch_serp_html("https://www.avito.ru/test", page=1) + + @pytest.mark.asyncio async def test_browser_mode_firewall_rotates_and_retries() -> None: """Browser returns firewall on first call, normal HTML after rotation → returns HTML."""