Merge pull request 'feat(scrapers): avito SERP дефолт на BrowserFetcher (camoufox), curl_cffi fallback (#1805)' (#1809) from feat/avito-serp-browser-default into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 6s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m15s
Deploy Trade-In / build-backend (push) Successful in 58s
Deploy Trade-In / deploy (push) Successful in 45s

Reviewed-on: #1809
This commit is contained in:
lekss361 2026-06-19 17:53:12 +00:00
commit ebdd70a38d
3 changed files with 166 additions and 52 deletions

View file

@ -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"

View file

@ -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+" # дом: цифры

View file

@ -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."""