From 5bb998a625932007d26980ee7c2f11f0aaf3529e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 29 Jun 2026 01:16:12 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/avito-detail):=20sticky-IP=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=E2=80=94=20on-block=20cooldown+research=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D0=B1=D0=B5=D1=81=D0=BF=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D0=B7=D0=BD=D0=BE=D0=B3=D0=BE=20rebuild,=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20(=D0=9C=D0=93=D0=A2=D0=A1=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=80=D0=BE=D1=82=D0=B8=D1=80=D1=83=D0=B5=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/tasks/avito_detail_backfill.py | 38 +++++---- .../tests/tasks/test_avito_detail_backfill.py | 83 +++++++++++++++++-- 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index d56a63ff..1c7359a5 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -98,6 +98,7 @@ async def run_avito_detail_backfill( 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)) # #1950: hard-timeout'ы на блокирующие await'ы внутри loop'а. Без них один # зависший fetch_detail/rotate ронял весь run в zombie (run 423 завис 7.7ч). @@ -139,10 +140,11 @@ async def run_avito_detail_backfill( own_browser = True scraper._browser = browser_fetcher elif use_curl: - # use_curl=True (#1551 warm-batch): прогретая shared-сессия — yandex-referer -> - # avito-search сеет антибот-куки, сессия держит батч detail без 403 (доказано - # пробами). Ротация каждые warm_batch карточек И на блоке = свежий exit-IP + - # свежий органический прогрев. + # 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: @@ -255,12 +257,14 @@ async def run_avito_detail_backfill( item_url = urlparse(source_url).path if source_url.startswith("http") else source_url # use_curl warm-batch (#1551): интервалы по размеру SERP-страницы (~50). - # Полная ротация (новый exit-IP + полный прогрев) каждые warm_batch (~500); - # между ротациями — лёгкий in-session перепоиск каждые research_every (~50) - # на ТОМ ЖЕ exit-IP (освежить куки, без смены IP). On-block rebuild = backstop. + # МГТС 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: - # полная ротация: новый exit-IP + полный прогрев (yandex->search) + # периодический полный re-warm на том же sticky exit-IP (свежий TLS+куки) try: await session.close() except Exception: @@ -340,18 +344,16 @@ async def run_avito_detail_backfill( consecutive_blocks, e, ) - # use_curl warm-batch (#1551): блок = текущий exit-IP залочен → закрываем - # прогретую сессию и строим новую (свежий exit-IP + новый органический - # прогрев) вместо changeip-ротации. Иначе legacy auv/browser-путь — - # bound rotate backconnect-IP. + # МГТС 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: - try: - await session.close() - except Exception: - pass - session = await build_warmed_session() - items_since_warm = 0 + await research_in_session(session) else: # #1950: bound rotate — _rotate_ip ждёт settle + до 3 changeip-попыток; # на блокирующем changeip (зависшее соединение) без timeout loop виснет. 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 526e5825..332265fd 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -29,15 +29,21 @@ def _reset_shutdown() -> None: @pytest.fixture(autouse=True) def _patch_build_warmed() -> object: - """use_curl-ветка (#1551) строит прогретую сессию через build_warmed_session - (реальный yandex/avito warm-up + sleep). fetch_detail в этих тестах всё равно - замокан — мокаем build_warmed_session не-сетевой AsyncMock-сессией, чтобы setup - и rebuild-on-block не ходили в сеть.""" - with patch( - "app.tasks.avito_detail_backfill.build_warmed_session", - AsyncMock(return_value=AsyncMock()), - ) as p: - yield p + """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 # --------------------------------------------------------------------------- @@ -66,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 @@ -536,3 +544,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()