feat(scrapers): avito SERP дефолт на BrowserFetcher (camoufox), curl_cffi fallback (#1805)
This commit is contained in:
parent
a534dece06
commit
93f7c9ae3f
3 changed files with 166 additions and 52 deletions
|
|
@ -283,12 +283,13 @@ class Settings(BaseSettings):
|
||||||
cian_login_success_cookie: str = "DMIR_AUTH"
|
cian_login_success_cookie: str = "DMIR_AUTH"
|
||||||
cian_login_wait_ms: int = 4000
|
cian_login_wait_ms: int = 4000
|
||||||
|
|
||||||
# ── #884/#905: BrowserFetcher — HTTP-клиент к tradein-browser контейнеру ────
|
# ── #884/#905/#1805: BrowserFetcher — HTTP-клиент к tradein-browser ─────────
|
||||||
# scraper_fetch_mode: "curl_cffi" (дефолт, текущее поведение) или "browser"
|
# scraper_fetch_mode: "browser" (дефолт с #1805 — HTTP POST к tradein-browser
|
||||||
# (HTTP POST к tradein-browser /fetch эндпоинту). Phase 1+.
|
# /fetch через per-provider camoufox + ротирующий backconnect-прокси) или
|
||||||
# Пока дефолт "curl_cffi" — никаких изменений в рантайме.
|
# "curl_cffi" (legacy TLS-impersonate путь). Phase 2 epic #883: avito SERP-фетч
|
||||||
# ENV: SCRAPER_FETCH_MODE.
|
# по умолчанию через браузер; curl_cffi сохранён как fallback (см.
|
||||||
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "curl_cffi"
|
# AvitoScraper._fetch_serp_html) и явный opt-out через ENV SCRAPER_FETCH_MODE.
|
||||||
|
scraper_fetch_mode: Literal["curl_cffi", "browser"] = "browser"
|
||||||
# HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
|
# HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
|
||||||
# ENV: BROWSER_HTTP_ENDPOINT.
|
# ENV: BROWSER_HTTP_ENDPOINT.
|
||||||
browser_http_endpoint: str = "http://tradein-browser:3000"
|
browser_http_endpoint: str = "http://tradein-browser:3000"
|
||||||
|
|
|
||||||
|
|
@ -339,11 +339,21 @@ class AvitoScraper(BaseScraper):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"avito: proactive IP rotate at sweep start raised — proceeding", exc_info=True
|
"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")
|
self._browser = BrowserFetcher(source="avito")
|
||||||
await self._browser.__aenter__()
|
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
|
return self
|
||||||
|
# Explicit opt-out: legacy curl_cffi-only путь (SCRAPER_FETCH_MODE=curl_cffi).
|
||||||
await super().__aenter__()
|
await super().__aenter__()
|
||||||
self._cffi = self._build_cffi_session()
|
self._cffi = self._build_cffi_session()
|
||||||
return self
|
return self
|
||||||
|
|
@ -413,40 +423,86 @@ class AvitoScraper(BaseScraper):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _fetch_serp_html(self, url: str, page: int) -> str | None:
|
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-страницу
|
Если поднят BrowserFetcher (default с #1805) — фетчим через camoufox с
|
||||||
(не 403). Детектим по тексту; если задана changeip-ссылка — меняем IP и
|
curl_cffi-fallback на firewall (_fetch_serp_html_browser). Иначе
|
||||||
повторяем до ``avito_proxy_max_rotations`` раз. Транзиентные сетевые
|
(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:
|
Returns:
|
||||||
HTML при HTTP 200, либо ``None`` при non-200 (конец пагинации).
|
HTML при HTTP 200, либо ``None`` при non-200 (конец пагинации).
|
||||||
В browser-mode HTTP-статус недоступен: метод возвращает HTML или
|
|
||||||
поднимает ``AvitoBlockedError`` (pagination ends via empty-parse in callers).
|
|
||||||
Raises:
|
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
|
assert self._cffi is not None
|
||||||
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
|
max_rot = settings.avito_proxy_max_rotations if settings.avito_proxy_rotate_url else 0
|
||||||
# curl_cffi-сессия всегда egress через settings.scraper_proxy_url (backconnect
|
# curl_cffi-сессия всегда egress через settings.scraper_proxy_url (backconnect
|
||||||
|
|
@ -1901,8 +1957,7 @@ _NOT_ADDRESS_TAIL_RE = re.compile(
|
||||||
# первую букву обычного слова. Корпус «сN/кN» требует цифры, поэтому не путается
|
# первую букву обычного слова. Корпус «сN/кN» требует цифры, поэтому не путается
|
||||||
# с маркерами «с.» (село) / «к» — у тех нет цифры вплотную.
|
# с маркерами «с.» (село) / «к» — у тех нет цифры вплотную.
|
||||||
_GLUE_MARKER = (
|
_GLUE_MARKER = (
|
||||||
r"(?:р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок"
|
r"(?:р-?н|мкр\.?|микрорайон|район|г\.|пос\.?|посёлок|поселок" r"|снт|кв-л|квартал|д\.|с\.|пгт)"
|
||||||
r"|снт|кв-л|квартал|д\.|с\.|пгт)"
|
|
||||||
)
|
)
|
||||||
_GLUE_HOUSE_TOKEN = (
|
_GLUE_HOUSE_TOKEN = (
|
||||||
r"\d+" # дом: цифры
|
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:
|
Tests:
|
||||||
1. browser branch chosen: mode="browser" → _browser set, _cffi is None, HTML from mock browser.
|
1. browser branch default (#1805): __aenter__ → _browser set AND _cffi fallback armed.
|
||||||
2. curl branch unchanged: default mode → _browser is None, _cffi session used.
|
2. curl branch unchanged: _browser is None → _cffi session used directly.
|
||||||
3. firewall-detect in browser-mode: firewall HTML + no rotate URL → AvitoBlockedError.
|
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.
|
4. pagination terminates via empty-parse: browser HTML with 0 lots → caller loop breaks.
|
||||||
|
|
||||||
Без сети, без БД, без curl_cffi.
|
Без сети, без БД, без curl_cffi.
|
||||||
|
|
@ -45,8 +46,12 @@ def _make_mock_browser(html: str = _NORMAL_HTML) -> MagicMock:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_aenter_browser_mode_sets_browser_attribute() -> None:
|
async def test_aenter_browser_mode_arms_cffi_fallback() -> None:
|
||||||
"""mode=browser → __aenter__ instantiates BrowserFetcher, _cffi stays 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()
|
mock_browser = _make_mock_browser()
|
||||||
scraper = AvitoScraper()
|
scraper = AvitoScraper()
|
||||||
|
|
||||||
|
|
@ -60,16 +65,13 @@ async def test_aenter_browser_mode_sets_browser_attribute() -> None:
|
||||||
"app.services.scrapers.avito.BrowserFetcher",
|
"app.services.scrapers.avito.BrowserFetcher",
|
||||||
return_value=mock_browser,
|
return_value=mock_browser,
|
||||||
):
|
):
|
||||||
# Patch BaseScraper.__aenter__ so we skip its real setup (DB/network)
|
result = await scraper.__aenter__()
|
||||||
with patch(
|
|
||||||
"app.services.scrapers.base.BaseScraper.__aenter__",
|
|
||||||
new=AsyncMock(return_value=scraper),
|
|
||||||
):
|
|
||||||
result = await scraper.__aenter__()
|
|
||||||
|
|
||||||
assert result is scraper
|
assert result is scraper
|
||||||
assert scraper._browser is mock_browser
|
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
|
@pytest.mark.asyncio
|
||||||
|
|
@ -199,10 +201,11 @@ async def test_fetch_serp_html_curl_mode_uses_cffi() -> None:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_browser_mode_firewall_raises_blocked_error() -> None:
|
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()
|
scraper = AvitoScraper()
|
||||||
mock_browser = _make_mock_browser(_FIREWALL_HTML)
|
mock_browser = _make_mock_browser(_FIREWALL_HTML)
|
||||||
scraper._browser = mock_browser
|
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:
|
with patch("app.services.scrapers.avito.settings") as mock_settings:
|
||||||
mock_settings.avito_proxy_rotate_url = None # no rotation configured
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_browser_mode_firewall_rotates_and_retries() -> None:
|
async def test_browser_mode_firewall_rotates_and_retries() -> None:
|
||||||
"""Browser returns firewall on first call, normal HTML after rotation → returns HTML."""
|
"""Browser returns firewall on first call, normal HTML after rotation → returns HTML."""
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue