diff --git a/tradein-mvp/backend/app/services/scrapers/avito_detail.py b/tradein-mvp/backend/app/services/scrapers/avito_detail.py index 61800152..f9a8d451 100644 --- a/tradein-mvp/backend/app/services/scrapers/avito_detail.py +++ b/tradein-mvp/backend/app/services/scrapers/avito_detail.py @@ -22,11 +22,12 @@ from __future__ import annotations import asyncio import json import logging +import random import re from dataclasses import dataclass, field from datetime import date from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin, urlparse +from urllib.parse import quote, urljoin, urlparse from curl_cffi.requests import AsyncSession from selectolax.parser import HTMLParser, Node @@ -49,6 +50,18 @@ logger = logging.getLogger(__name__) AVITO_BASE = "https://www.avito.ru" +# Живой прогрев сессии (#1551): GET Yandex SERP (органический referer-источник) -> GET +# Avito EKB search-страница (Referer=yandex) сеет антибот-куки, после чего прогретая +# curl_cffi-сессия держит батч detail-GET'ов без 403 (доказано пробами на проде: 22/22 +# карточек подряд, 0 блоков). referer на detail-GET = URL avito-search страницы. +_AVITO_WARM_SEARCH_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam?q=" + quote( + "купить квартиру" +) +_AVITO_WARM_YANDEX_URL = "https://yandex.ru/search/?text=" + quote( + "купить квартиру екатеринбург авито" +) +_AVITO_WARM_YANDEX_REFERER = "https://yandex.ru/" + # Reconnect-retry на detail-странице под backconnect-прокси — зеркало SERP-фикса # (#1765/#1769, avito.py): 403/firewall = залочен лишь текущий exit-IP backconnect-прокси; # пересоздание curl_cffi-сессии = новый CONNECT-туннель = свежий exit-IP → escape. @@ -283,6 +296,56 @@ def _build_detail_session() -> AsyncSession: ) +async def warm_up_session(session: AsyncSession) -> bool: + """Органический прогрев сессии: Yandex SERP (best-effort) -> Avito EKB search + (Referer=yandex). Сеет антибот-куки, после чего сессия держит батч detail-GET'ов + без 403. Возвращает True если search-страница загрузилась (не firewall). + """ + try: + await session.get(_AVITO_WARM_YANDEX_URL) # органический referer-источник, best-effort + except Exception as exc: + logger.debug("avito warm-up: yandex GET failed (non-fatal): %s", exc) + await asyncio.sleep(random.uniform(2.0, 3.5)) + try: + r = await session.get( + _AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER} + ) + except Exception as exc: + logger.warning("avito warm-up: search fetch error: %s", exc) + return False + ok = r.status_code == 200 and not _is_firewall_page(r.text) + if not ok: + logger.warning("avito warm-up: search page blocked sc=%s", r.status_code) + return ok + + +async def research_in_session(session: AsyncSession) -> bool: + """Лёгкий in-session перепоиск: re-GET avito search-страницы (Referer=yandex) на + ТЕКУЩЕЙ сессии — освежает антибот-куки БЕЗ смены exit-IP. Best-effort (search-страница + иногда 429, это не ломает detail-фетчи). Возвращает True если страница не firewall. + """ + try: + r = await session.get( + _AVITO_WARM_SEARCH_URL, headers={"Referer": _AVITO_WARM_YANDEX_REFERER} + ) + except Exception as exc: + logger.warning("avito re-search: fetch error: %s", exc) + return False + ok = r.status_code == 200 and not _is_firewall_page(r.text) + if not ok: + logger.info("avito re-search: search page sc=%s (non-fatal)", r.status_code) + return ok + + +async def build_warmed_session() -> AsyncSession: + """Построить detail-сессию (_build_detail_session) и прогреть её. Возвращает + прогретую сессию (даже если warm-up soft-failed — caller может всё равно пробовать). + """ + session = _build_detail_session() + await warm_up_session(session) + return session + + # ── Soft-block detection (#1967 / #1950) ────────────────────────────────────── # Под нагрузкой Avito иногда вместо detail-страницы отдаёт HTTP 200 с ОБЩЕЙ # витриной (Авито — Объявления на сайте Авито) вместо запрошенного @@ -327,11 +390,19 @@ async def fetch_detail( *, cffi_session: AsyncSession | None = None, browser_fetcher: BrowserFetcher | None = None, + referer: str | None = None, + reconnect_on_block: bool = True, ) -> DetailEnrichment: """GET /{item_url} → parse HTML via selectolax → DetailEnrichment. Если browser_fetcher передан — использует браузерный fetch (browser mode). Если cffi_session не передана — создаёт новую (impersonate='chrome120'). + referer — если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551: + referer = URL avito-search страницы, на которой прогрелась сессия). + reconnect_on_block — если False, 403/firewall поднимает AvitoBlockedError СРАЗУ + (без холодных reconnect'ов — ими займётся outer-loop через rebuild+rewarm прогретой + сессии); 429 после short-retry поднимает AvitoRateLimitedError. Дефолт True + сохраняет старое поведение own-session / scrape_pipeline путей. Raises: httpx.HTTPError — если status != 200 (через raise_for_status-like). ValueError — если item_id не извлечён из HTML. @@ -387,7 +458,12 @@ async def fetch_detail( # ТОЛЬКО на наличие proxy (зеркало SERP-фикса #1765). Без proxy — старое поведение # (raise на первом 403). Caller-provided сессию НЕ трогаем: reconnect использует # эфемерную retry_session, которую сами закрываем в finally. - backconnect = bool(settings.scraper_proxy_url) + # reconnect_on_block=False (warm-batch #1551): не делаем холодных per-card + # reconnect'ов — блок поднимает AvitoBlockedError сразу, outer-loop пересоздаёт+ + # прогревает сессию (свежий exit-IP). reconnect_on_block=True (own-session / + # scrape_pipeline) — старое поведение: backconnect-reconnect эфемерной сессией. + backconnect = bool(settings.scraper_proxy_url) and reconnect_on_block + req_headers = {"Referer": referer} if referer else None r403 = 0 r429 = 0 r429_reconnect = 0 @@ -395,7 +471,7 @@ async def fetch_detail( retry_session: AsyncSession | None = None try: while True: - response = await attempt_session.get(full_url) + response = await attempt_session.get(full_url, headers=req_headers) sc = response.status_code is_firewall = sc == 200 and ( _is_firewall_page(response.text) or _is_detail_soft_block(response.text) diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 490b78bd..f736856b 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio import logging +import random import time from dataclasses import dataclass, field from urllib.parse import urlparse @@ -30,7 +31,13 @@ from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod from app.services.scrape_pipeline import _CHROME_HEADERS, _avito_proxies from app.services.scrapers.avito import AvitoScraper -from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment +from app.services.scrapers.avito_detail import ( + _AVITO_WARM_SEARCH_URL, + build_warmed_session, + fetch_detail, + research_in_session, + save_detail_enrichment, +) from app.services.scrapers.avito_exceptions import ( AvitoBlockedError, AvitoListingGoneError, @@ -89,6 +96,10 @@ async def run_avito_detail_backfill( budget_sec = float(params.get("budget_sec", 3600)) request_delay_sec = float(params.get("request_delay_sec", 6.0)) max_consecutive_blocks = int(params.get("max_consecutive_blocks", 5)) + warm_batch = int(params.get("warm_batch", 500)) + research_every = int(params.get("research_every", 50)) + block_cooldown_sec = float(params.get("block_cooldown_sec", 30.0)) + warm_timeout_s = float(params.get("warm_timeout_s", 90.0)) # #1950: hard-timeout'ы на блокирующие await'ы внутри loop'а. Без них один # зависший fetch_detail/rotate ронял весь run в zombie (run 423 завис 7.7ч). @@ -129,6 +140,14 @@ async def run_avito_detail_backfill( await browser_fetcher.__aenter__() own_browser = True scraper._browser = browser_fetcher + elif use_curl: + # use_curl=True (#1551 warm-batch): прогретая shared-сессия на sticky МГТС-IP — + # yandex-referer -> avito-search сеет антибот-куки, сессия держит батч detail без + # 403 (доказано пробами: ≥66 карточек подряд, 0 блоков). NB: МГТС sticky — один + # фикс. exit-IP, per-connection ротации нет (rebuild != новый IP); on-block — + # cooldown + in-session re-search, не дискард сессии. + own_session = True + session = await build_warmed_session() elif not use_curl: # curl_cffi legacy path (scraper_fetch_mode="curl_cffi", use_curl=False): # строим shared сессию через auv, как делал scrape_pipeline. @@ -140,8 +159,6 @@ async def run_avito_detail_backfill( proxies=_avito_proxies(), ) scraper._cffi = session - # use_curl=True: ничего не строим — fetch_detail вызывает _build_detail_session() - # (scraper_proxy_url = backconnect mproxy) на каждый запрос. runs_mod.update_heartbeat(db, run_id, current_counters) @@ -197,6 +214,7 @@ async def run_avito_detail_backfill( consecutive_blocks = 0 do_sleep = False + items_since_warm = 0 for idx, row in enumerate(snapshot): # Budget guard @@ -227,9 +245,10 @@ async def run_avito_detail_backfill( ) break - # Delay before each request except the first + # Delay before each request except the first (джиттер ±30% — менее + # роботизированный паттерн на органически прогретой сессии). if do_sleep: - await asyncio.sleep(request_delay_sec) + await asyncio.sleep(request_delay_sec * random.uniform(0.7, 1.4)) do_sleep = True source_url: str = row["source_url"] @@ -238,6 +257,52 @@ async def run_avito_detail_backfill( # Normalise URL -> path (mirrors scrape_pipeline.py line 311) item_url = urlparse(source_url).path if source_url.startswith("http") else source_url + # use_curl warm-batch (#1551): интервалы по размеру SERP-страницы (~50). + # МГТС sticky-IP: exit-IP константа (rebuild НЕ даёт новый IP). Каждые + # warm_batch (~500) — полный re-warm (close+build) на ТОМ ЖЕ sticky exit-IP: + # session-hygiene (свежий TLS-хендшейк + свежие куки yandex->search), НЕ смена + # IP. Между ними — лёгкий in-session перепоиск каждые research_every (~50) + # (re-GET search той же сессией, освежить куки). On-block — cooldown ниже. + if use_curl and session is not None: + if warm_batch and items_since_warm >= warm_batch: + # периодический полный re-warm на том же sticky exit-IP (свежий TLS+куки). + # #1950: bound сетевой re-warm; строим НОВУЮ сессию до close старой — на + # timeout/fail сохраняем текущую рабочую сессию (graceful degrade). + try: + new_session = await asyncio.wait_for( + build_warmed_session(), timeout=warm_timeout_s + ) + try: + await session.close() + except Exception: + pass + session = new_session + except Exception: + logger.warning( + "avito_detail_backfill: run_id=%d re-warm timed out/failed (>%.0fs) " + "-- keeping current session", + run_id, + warm_timeout_s, + exc_info=True, + ) + items_since_warm = 0 + elif ( + research_every + and items_since_warm > 0 + and items_since_warm % research_every == 0 + ): + # лёгкий in-session перепоиск: тот же exit-IP, освежить куки. + # #1950: bound сетевой re-search. + try: + await asyncio.wait_for(research_in_session(session), timeout=warm_timeout_s) + except Exception: + logger.warning( + "avito_detail_backfill: run_id=%d in-session re-search " + "timed out/failed", + run_id, + exc_info=True, + ) + try: # #1950: hard-timeout — зависший fetch (browser hang / curl-stall) не # должен блокировать loop навсегда (иначе budget-guard/heartbeat молчат @@ -247,11 +312,15 @@ async def run_avito_detail_backfill( item_url, cffi_session=session, browser_fetcher=browser_fetcher, + referer=_AVITO_WARM_SEARCH_URL if use_curl else None, + reconnect_on_block=not use_curl, ), timeout=fetch_timeout_s, ) if save_detail_enrichment(db, enrichment): counters.enriched += 1 + if use_curl: + items_since_warm += 1 consecutive_blocks = 0 except AvitoListingGoneError: @@ -299,18 +368,8 @@ async def run_avito_detail_backfill( consecutive_blocks, e, ) - # #1950: bound rotate — _rotate_ip ждёт settle + до 3 changeip-попыток; - # на блокирующем changeip (зависшее соединение) без timeout loop виснет. - try: - await asyncio.wait_for(scraper._rotate_ip(), timeout=rotate_timeout_s) - except Exception: - logger.warning( - "avito_detail_backfill: run_id=%d rotate_ip timed out/failed " - "(>%.0fs) -- continuing", - run_id, - rotate_timeout_s, - exc_info=True, - ) + # abort FIRST — на последнем блоке не тратим cooldown+research/rotate + # (consecutive_blocks уже инкрементнут выше). if consecutive_blocks >= max_consecutive_blocks: logger.error( "avito_detail_backfill: run_id=%d ABORT -- %d consecutive blocks, " @@ -321,6 +380,40 @@ async def run_avito_detail_backfill( counters.attempted, ) break + # МГТС sticky-IP: один фикс. exit-IP, per-connection ротации нет (проверено: + # 6/6 свежих сессий = тот же IP 109.252.125.80; ротация только вручную + # кнопкой). Уйти на свежий IP софтом нельзя → блок = rate-limit текущего IP: + # даём окну остыть (cooldown) и освежаем куки in-session, НЕ дискардим + # рабочую прогретую сессию (rebuild на том же IP бесполезен для escape + + # грузит rate-limited IP полным прогревом). items_since_warm НЕ сбрасываем. + if use_curl: + await asyncio.sleep(block_cooldown_sec) + if session is not None: + # #1950: bound сетевой re-search. + try: + await asyncio.wait_for( + research_in_session(session), timeout=warm_timeout_s + ) + except Exception: + logger.warning( + "avito_detail_backfill: run_id=%d on-block re-search " + "timed out/failed", + run_id, + exc_info=True, + ) + else: + # #1950: bound rotate — _rotate_ip ждёт settle + до 3 changeip-попыток; + # на блокирующем changeip (зависшее соединение) без timeout loop виснет. + try: + await asyncio.wait_for(scraper._rotate_ip(), timeout=rotate_timeout_s) + except Exception: + logger.warning( + "avito_detail_backfill: run_id=%d rotate_ip timed out/failed " + "(>%.0fs) -- continuing", + run_id, + rotate_timeout_s, + exc_info=True, + ) except TimeoutError: # asyncio.wait_for → TimeoutError (py3.12: asyncio.TimeoutError — alias). diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup.py b/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup.py new file mode 100644 index 00000000..b741e0f4 --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_avito_detail_warmup.py @@ -0,0 +1,140 @@ +"""Живой прогрев сессии + батч-на-сессию (#1551). + +warm_up_session: GET Yandex SERP (best-effort) -> GET Avito EKB search-страница с +Referer=yandex сеет антибот-куки. Прогретая сессия потом держит батч detail-GET'ов +без 403 (доказано пробами на проде: 22/22 карточек подряд, 0 блоков). + +fetch_detail(referer=..., reconnect_on_block=False): шлёт Referer detail-GET'а и при +403/firewall поднимает AvitoBlockedError СРАЗУ — без холодных per-card reconnect'ов +(их делает outer-loop через rebuild+rewarm прогретой сессии в avito_detail_backfill). + +Стиль фикстур/моков зеркалит test_avito_detail_403_reconnect.py. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.core.config import settings +from app.services.scrapers import avito_detail as detail_mod +from app.services.scrapers.avito_detail import ( + fetch_detail, + research_in_session, + warm_up_session, +) +from app.services.scrapers.avito_exceptions import AvitoBlockedError + + +def _resp(status_code: int, text: str = "") -> MagicMock: + r = MagicMock() + r.status_code = status_code + r.text = text + return r + + +def _enable_backconnect(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "scraper_proxy_url_env", "http://u:p@mproxy.site:14619") + + +# ── warm_up_session ─────────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_warm_up_session_search_with_yandex_referer_true_on_200() -> None: + """warm_up_session шлёт GET на _AVITO_WARM_SEARCH_URL с Referer=yandex и + возвращает True на 200/non-firewall.""" + session = AsyncMock() + session.get = AsyncMock(return_value=_resp(200, "ok")) + + with ( + patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()), + patch("app.services.scrapers.avito_detail._is_firewall_page", return_value=False), + ): + ok = await warm_up_session(session) + + assert ok is True + # search-GET (на avito-search URL) ушёл ровно один раз и с Referer=yandex. + search_calls = [ + c + for c in session.get.await_args_list + if c.args and c.args[0] == detail_mod._AVITO_WARM_SEARCH_URL + ] + assert len(search_calls) == 1 + assert search_calls[0].kwargs["headers"]["Referer"] == detail_mod._AVITO_WARM_YANDEX_REFERER + # Yandex SERP — органический referer-источник — тоже фетчился (best-effort, до search). + assert any( + c.args and c.args[0] == detail_mod._AVITO_WARM_YANDEX_URL + for c in session.get.await_args_list + ) + + +@pytest.mark.asyncio +async def test_warm_up_session_false_on_403() -> None: + """search-страница вернула 403 → warm_up_session возвращает False.""" + session = AsyncMock() + session.get = AsyncMock(return_value=_resp(403)) + + with ( + patch("app.services.scrapers.avito_detail.asyncio.sleep", AsyncMock()), + patch("app.services.scrapers.avito_detail._is_firewall_page", return_value=False), + ): + ok = await warm_up_session(session) + + assert ok is False + + +# ── fetch_detail referer + reconnect_on_block=False ─────────────────────────── +@pytest.mark.asyncio +async def test_fetch_detail_referer_forwarded_and_block_raises_immediately( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """fetch_detail(referer="X", reconnect_on_block=False): Referer="X" уходит в + detail-GET, а 403 поднимает AvitoBlockedError СРАЗУ — без холодного reconnect + (даже когда backconnect-proxy включён → гейт = reconnect_on_block, не наличие proxy). + """ + _enable_backconnect(monkeypatch) + + caller = AsyncMock() + caller.get = AsyncMock(return_value=_resp(403)) + build = MagicMock() # _build_detail_session НЕ должна дёргаться (нет reconnect'а) + + with patch.object(detail_mod, "_build_detail_session", build): + with pytest.raises(AvitoBlockedError, match="HTTP 403"): + await fetch_detail( + "/ekaterinburg/kvartiry/test-123", + cffi_session=caller, + referer="X", + reconnect_on_block=False, + ) + + build.assert_not_called() # никаких холодных reconnect'ов + assert caller.get.await_count == 1 # один detail-GET → сразу raise + assert caller.get.await_args.kwargs["headers"] == {"Referer": "X"} + + +# ── research_in_session (in-session перепоиск, #1551 follow-up) ──────────────── +@pytest.mark.asyncio +async def test_research_in_session_re_get_search_with_yandex_referer_true_on_200() -> None: + """research_in_session re-GET'ит avito-search на ТОЙ ЖЕ сессии с Referer=yandex + и возвращает True на 200/non-firewall (свежесть куки без смены exit-IP).""" + session = AsyncMock() + session.get = AsyncMock(return_value=_resp(200, "ok")) + + with patch("app.services.scrapers.avito_detail._is_firewall_page", return_value=False): + ok = await research_in_session(session) + + assert ok is True + assert session.get.await_count == 1 # ровно один re-GET, без yandex-фазы (in-session) + assert session.get.await_args.args[0] == detail_mod._AVITO_WARM_SEARCH_URL + sent_referer = session.get.await_args.kwargs["headers"]["Referer"] + assert sent_referer == detail_mod._AVITO_WARM_YANDEX_REFERER + + +@pytest.mark.asyncio +async def test_research_in_session_false_on_403() -> None: + """search вернул 403 → research_in_session возвращает False (best-effort, не fatal).""" + session = AsyncMock() + session.get = AsyncMock(return_value=_resp(403)) + + with patch("app.services.scrapers.avito_detail._is_firewall_page", return_value=False): + ok = await research_in_session(session) + + assert ok is False diff --git a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py index 94bf1a4f..394e570d 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -27,6 +27,25 @@ def _reset_shutdown() -> None: _sd.reset_shutdown() +@pytest.fixture(autouse=True) +def _patch_build_warmed() -> object: + """use_curl-ветка (#1551) строит прогретую сессию через build_warmed_session и + освежает куки через research_in_session (реальный yandex/avito GET + sleep). + fetch_detail в этих тестах всё равно замокан — мокаем обе не-сетевыми AsyncMock, + чтобы setup / periodic re-warm / on-block cooldown не ходили в сеть.""" + with ( + patch( + "app.tasks.avito_detail_backfill.build_warmed_session", + AsyncMock(return_value=AsyncMock()), + ), + patch( + "app.tasks.avito_detail_backfill.research_in_session", + AsyncMock(return_value=True), + ) as research, + ): + yield research + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -53,6 +72,8 @@ _SESSION = "app.tasks.avito_detail_backfill.AsyncSession" _SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper" _SETTINGS = "app.tasks.avito_detail_backfill.settings" _SHUTDOWN = "app.tasks.avito_detail_backfill.shutdown_requested" +_BUILD_WARM = "app.tasks.avito_detail_backfill.build_warmed_session" +_RESEARCH = "app.tasks.avito_detail_backfill.research_in_session" # --------------------------------------------------------------------------- # Tests @@ -119,7 +140,11 @@ async def test_backfill_processes_snapshot_to_completion() -> None: @pytest.mark.asyncio async def test_backfill_blocked_abort_after_max_consecutive() -> None: - """5 consecutive AvitoBlockedError -> abort, mark_done (NOT mark_failed), rotate_ip x5.""" + """5 consecutive AvitoBlockedError -> abort, mark_done (NOT mark_failed). + + #1950 abort-reorder: abort-check ПЕРЕД recovery → на 5-м (аборт-)блоке rotate_ip + НЕ дёргается (не тратим recovery на финальном блоке). rotate_ip x4 (блоки 1-4). + """ from app.services.scrapers.avito_exceptions import AvitoBlockedError snapshot = _make_snapshot(10) @@ -129,7 +154,9 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None: mock_fetch = AsyncMock(side_effect=blocked_exc) mock_scraper = MagicMock() mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True) - fake_settings = MagicMock(scraper_fetch_mode="cffi") + # use_curl=False: legacy block→_rotate_ip путь (#1551 warm-batch rebuild только при + # use_curl=True). MagicMock без явного флага сделал бы use_curl truthy. + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False) with ( patch(_SETTINGS, fake_settings), patch(_SESSION, return_value=AsyncMock()), @@ -147,7 +174,8 @@ async def test_backfill_blocked_abort_after_max_consecutive() -> None: assert result.enriched == 0 runs.mark_done.assert_called_once() runs.mark_failed.assert_not_called() - assert mock_scraper.return_value._rotate_ip.call_count == 5 + # abort-check до recovery → 5-й блок абортит без rotate; rotate только на блоках 1-4. + assert mock_scraper.return_value._rotate_ip.call_count == 4 @pytest.mark.asyncio @@ -249,7 +277,9 @@ async def test_backfill_rotate_ip_called_on_each_block() -> None: mock_fetch = AsyncMock(side_effect=[blocked_exc, mock_enrichment]) mock_scraper = MagicMock() mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True) - fake_settings = MagicMock(scraper_fetch_mode="cffi") + # use_curl=False: legacy block→_rotate_ip путь (#1551 warm-batch rebuild только при + # use_curl=True). MagicMock без явного флага сделал бы use_curl truthy. + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False) with ( patch(_SETTINGS, fake_settings), patch(_SESSION, return_value=AsyncMock()), @@ -342,7 +372,14 @@ async def test_backfill_fetch_timeout_skips_and_continues() -> None: call_urls: list[str] = [] - async def _fetch(url: str, *, cffi_session: object = None, browser_fetcher: object = None): + async def _fetch( + url: str, + *, + cffi_session: object = None, + browser_fetcher: object = None, + referer: object = None, + reconnect_on_block: bool = True, + ): call_urls.append(url) if len(call_urls) == 1: await asyncio.Event().wait() # висит вечно → wait_for отменит по timeout @@ -394,7 +431,9 @@ async def test_backfill_listing_gone_marks_inactive_no_breaker() -> None: mock_fetch = AsyncMock(side_effect=AvitoListingGoneError("404 gone")) mock_scraper = MagicMock() mock_scraper.return_value._rotate_ip = AsyncMock(return_value=True) - fake_settings = MagicMock(scraper_fetch_mode="cffi") + # use_curl=False: legacy block→_rotate_ip путь (#1551 warm-batch rebuild только при + # use_curl=True). MagicMock без явного флага сделал бы use_curl truthy. + fake_settings = MagicMock(scraper_fetch_mode="cffi", avito_detail_backfill_use_curl=False) with ( patch(_SETTINGS, fake_settings), patch(_SESSION, return_value=AsyncMock()), @@ -510,3 +549,60 @@ async def test_backfill_use_curl_false_creates_browser_fetcher() -> None: assert kwargs.get("browser_fetcher") is not None assert result.enriched == 1 runs.mark_done.assert_called_once() + + +@pytest.mark.asyncio +async def test_backfill_use_curl_block_cooldown_research_no_rebuild() -> None: + """#1551 sticky-IP: on-block (use_curl) даёт cooldown + in-session research, НЕ + пересоздаёт прогретую сессию и НЕ дёргает changeip-ротацию. + + МГТС sticky — один фикс. exit-IP (rebuild != новый IP), уйти на свежий IP софтом + нельзя. Блок = rate-limit текущего IP → cooldown + re-search той же сессией. + fetch: 1-й вызов BLOCKED, 2-й — успех. + """ + from app.services.scrapers.avito_exceptions import AvitoBlockedError + + snapshot = _make_snapshot(2) + db = _mock_db(snapshot) + runs = MagicMock() + mock_enrichment = MagicMock() + blocked_exc = AvitoBlockedError("rate-limited") + mock_fetch = AsyncMock(side_effect=[blocked_exc, mock_enrichment]) + warmed_session = AsyncMock() + fake_settings = MagicMock( + scraper_fetch_mode="cffi", + avito_detail_backfill_use_curl=True, + ) + with ( + patch(_SETTINGS, fake_settings), + patch(_SCRAPER) as mock_scraper, + patch(_RUNS, runs), + patch(_FETCH, mock_fetch), + patch(_SAVE, return_value=True), + patch(_SLEEP, new_callable=AsyncMock), + patch(_BUILD_WARM, AsyncMock(return_value=warmed_session)) as mock_build, + patch(_RESEARCH, new_callable=AsyncMock) as mock_research, + ): + result = await run_avito_detail_backfill( + db, + run_id=14, + params={ + "batch_size": 10, + "budget_sec": 3600, + "max_consecutive_blocks": 5, + "block_cooldown_sec": 0.0, + }, + ) + + assert result.blocked == 1 + assert result.enriched == 1 + assert result.attempted == 2 + # on-block: research_in_session вызван (освежить куки in-session)... + mock_research.assert_awaited_once() + # ...а build_warmed_session НЕ дёргался повторно (вызван только 1× в setup — + # прогретая сессия НЕ пересоздаётся на блоке, sticky-IP rebuild бесполезен). + assert mock_build.await_count == 1 + # changeip-ротация (legacy путь) под use_curl НЕ дёргается. + mock_scraper.return_value._rotate_ip.assert_not_called() + runs.mark_done.assert_called_once() + runs.mark_failed.assert_not_called()