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
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:
commit
ebdd70a38d
3 changed files with 166 additions and 52 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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+" # дом: цифры
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue