From 65e44c91954eefc155256fdd272ea98bf0543a39 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 19 Sep 2026 23:22:43 +0300 Subject: [PATCH] =?UTF-8?q?fix(avito):=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=B1=D0=B5=D1=80=D1=83=D1=82=D1=81=D1=8F?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=B2=D0=B5=D0=B6=D0=B5=D0=BC=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B5=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=20=D0=BE=D0=B1=D1=89=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обогащение держало один контекст браузера на весь прогон (reuse_context=True). Авито закрывает карточки проверкой работой на JS, а её куки pow_challenge и pow_solved живут около пяти минут. Прогон живёт десятки минут, токен протухает и в живом контексте не обновляется, поэтому после первых карточек каждая следующая получает бан-страницу, и прогон рвёт защита по доле блокировок. Замер на проде 19.09, отдельная копия сайдкара, один и тот же прокси, 16 карточек на плечо, схема A-B-A: свежий контекст 14/16 и 13/16 успехов, 8.7 и 7.9 с на карточку общий контекст 6/16 успехов, 8.4 с на карточку Времени свежий контекст не стоит, а успешных карточек даёт вдвое больше. В плече с общим контекстом отказы нарастают по ходу: первые три успешны, последние пять подряд забанены. Переиспользование контекста пришло из #3118, где оно нужно ДомКлику, чтобы не терять куку его защиты. Для ДомКлика это остаётся верным, на Авито было перенесено по аналогии и замером опровергнуто. Поведение вынесено в AVITO_DETAIL_REUSE_CONTEXT с дефолтом false, чтобы вернуть старый режим без выката. --- tradein-mvp/backend/app/core/config.py | 20 +++ .../app/tasks/avito_detail_backfill.py | 44 ++++--- .../tests/test_3251_avito_warm_context.py | 119 +++++++++++++++--- ...test_avito_detail_reuse_context_default.py | 33 +++++ 4 files changed, 183 insertions(+), 33 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_avito_detail_reuse_context_default.py diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 0a2b15de..295a56c3 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -1066,6 +1066,26 @@ class Settings(BaseSettings): default=False, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL" ) + # reuse_context для BrowserFetcher в avito_detail_backfill (browser-режим). + # ENV: AVITO_DETAIL_REUSE_CONTEXT. + # + # Дефолт False (замер 19.09.2026, probe-browser, прокси узел 14, схема A-B-A, + # 16 карточек ЕКБ на плечо): A1 (новый изолированный context на карточку) 14/16, + # B (reuse_context=True + origin/referer) 6/16, A2 (снова изолированный) 13/16. + # В плече B отказы нарастали по ходу — карточки 1-3 успешны, последние пять + # подряд бан: выгорание контекста, а не совпадение. Живые куки pow_challenge/ + # pow_solved у Авито держатся ~5 минут (замер: выданы 23:08, истекли 23:13), + # а прогон обогащения идёт десятки минут с паузой 5-6с между карточками — в + # переиспользуемом context'е токен протухает и не обновляется, дальше каждая + # карточка получает бан-страницу. В свежем context'е навигация решает PoW + # заново. Решение про reuse_context=True перенесено на Авито по аналогии с + # DomClick (#3118, QRATOR-PoW, 26/26 блоков на изолированных контекстах против + # 5/5 успехов в тёплом) — для DomClick это по-прежнему верно, для Авито + # опровергнуто измерением. Флаг — аварийный откат без выката кода. + avito_detail_reuse_context: bool = Field( + default=False, validation_alias="AVITO_DETAIL_REUSE_CONTEXT" + ) + # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший # fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда → # budget-guard (раз в итерацию) не срабатывает → heartbeat не обновляется → run diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 94053726..cf07e4b7 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -411,13 +411,22 @@ async def run_avito_detail_backfill( # Замер 27.08: прогон 5098 — mode=browser, proxy_lease_id=None, # 5 блоков подряд из 5 попыток, enriched=0. При этом пул здоров # (4 узла, все ok), а тот же URL через прокси отдаёт 200 и 3.3 МБ. - # reuse_context=True (#3180/#3251): без него sidecar's browser.new_page() - # создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch — пройденный - # QRATOR-PoW предыдущей карточки выбрасывается, и следующий запрос снова - # холодный. У DomClick (#3118) это измеренно давало 100% блоков (26/26) - # на изолированных контекстах против 5/5 успехов в тёплом контексте. - # Сброс сожжённого context'а — ниже, через bf.request_context_reset() - # (#3212: один раз за прогон, не на каждый блок — см. except-ветку). + # + # reuse_context управляется settings.avito_detail_reuse_context, дефолт + # False (#3180/#3251 -> опровергнуто новым замером, см. config.py). + # Изначально сюда перенесли reuse_context=True по аналогии с DomClick + # (#3118, QRATOR-PoW): на изолированных контекстах 26/26 блоков против + # 5/5 успехов в тёплом — для DomClick это по-прежнему верно и не + # менялось. Для Авито замер 19.09.2026 (probe-browser, прокси узел 14, + # схема A-B-A, 16 карточек ЕКБ на плечо) дал обратный результат: + # изолированный context 14/16 и 13/16, тёплый reuse_context=True 6/16 + # с нарастающими отказами к концу плеча. Причина — куки Авито + # pow_challenge/pow_solved живут ~5 минут, а прогон между карточками + # растянут на десятки минут: в тёплом context'е токен протухает и не + # обновляется, свежая навигация решает PoW заново. Сброс сожжённого + # context'а (актуален только при reuse_context=True) — ниже, через + # bf.request_context_reset() (#3212: один раз за прогон, не на каждый + # блок — см. except-ветку). _cfg = RealScraperConfig() browser_fetcher = BrowserFetcher( source="avito", @@ -427,7 +436,7 @@ async def run_avito_detail_backfill( # Без environment отказ «пул пуст» на этом пути мёртв — фетчер # молча ушёл бы на env-прокси сайдкара (#2616 шаг 1). environment=_cfg.environment, - reuse_context=True, + reuse_context=settings.avito_detail_reuse_context, ) await browser_fetcher.__aenter__() own_browser = True @@ -576,12 +585,15 @@ async def run_avito_detail_backfill( # прокси. Обнуляется на каждой ротации (успешной ИЛИ неуспешной — иначе # исчерпанный дневной лимит провайдера дёргал бы rotate_proxy на каждой попытке). attempts_since_rotation = 0 - # #3251: сброс переиспользуемого browser-context'а (reuse_context=True выше) - # разрешён РОВНО один раз за прогон — зеркалит domclick_detail_backfill (#3212). - # Сброс на КАЖДЫЙ блок сам себя поддерживает: пройденный QRATOR-PoW живёт в - # context'е, сброс его выбрасывает, повторная проверка с того же IP сразу - # после принятой снова блокируется — одна осечка превращается в необратимый - # каскад блоков (см. except-ветку ниже). + # #3251: сброс переиспользуемого browser-context'а разрешён РОВНО один раз + # за прогон — зеркалит domclick_detail_backfill (#3212). Сброс на КАЖДЫЙ + # блок сам себя поддерживает: пройденный QRATOR-PoW живёт в context'е, + # сброс его выбрасывает, повторная проверка с того же IP сразу после + # принятой снова блокируется — одна осечка превращается в необратимый + # каскад блоков (см. except-ветку ниже). Осмыслен только при + # settings.avito_detail_reuse_context=True — при выключенном + # переиспользовании (дефолт) bare-reset ниже не вызывается вовсе: каждый + # fetch и так идёт в свежем изолированном context'е. context_reset_used = False # #3283g: сколько раз ЗА ПРОГОН уже сработала ротация-на-бан (в отличие от # context_reset_used эта ротация меняет IP вместе со сбросом, поэтому не @@ -901,7 +913,9 @@ async def run_avito_detail_backfill( ): context_reset_used = True - if not context_reset_used: + # Бессмысленно при reuse_context=False (дефолт): каждый fetch и + # так идёт в свежем изолированном context'е, сбрасывать нечего. + if settings.avito_detail_reuse_context and not context_reset_used: context_reset_used = True browser_fetcher.request_context_reset() logger.warning( diff --git a/tradein-mvp/backend/tests/test_3251_avito_warm_context.py b/tradein-mvp/backend/tests/test_3251_avito_warm_context.py index 39aeff8b..57f328df 100644 --- a/tradein-mvp/backend/tests/test_3251_avito_warm_context.py +++ b/tradein-mvp/backend/tests/test_3251_avito_warm_context.py @@ -1,16 +1,24 @@ -"""Тёплый переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251). +"""Переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251/#3595). -Переносит на Avito рецепт, измеренно поднявший добор карточек у DomClick (#3118): -sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch — -пройденный QRATOR-подобный анти-бот проход площадки выбрасывается вместе с ним, и -каждая следующая карточка снова холодная. reuse_context=True держит ОДИН sidecar- -context живым на весь прогон; сброс сожжённого context'а оркестратор просит через -bf.request_context_reset() — как и DomClick, ровно один раз за прогон (#3212), а не -на каждый обнаруженный блок (иначе сброс сам себя поддерживает — см. domclick -docstring). Reset относится ТОЛЬКО к реальному бану площадкой (AvitoBlockedError и -подтипы), не к отказу нашего тракта (AvitoSidecarUnavailableError) и не к мёртвому -листингу (AvitoListingGoneError) — площадка тут ни при чём в обоих случаях, context -цел. +Рецепт reuse_context=True изначально перенесли на Avito по аналогии с DomClick +(#3118): sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на +КАЖДЫЙ /fetch — пройденный анти-бот проход площадки выбрасывается вместе с ним, и +каждая следующая карточка снова холодная. Для DomClick это подтверждено (26/26 +блоков на изолированных контекстах против 5/5 успехов в тёплом). Для Avito замер +19.09.2026 (probe-browser, A-B-A, 16 карточек ЕКБ/плечо) дал обратный результат: +изолированный context 14/16 и 13/16, reuse_context=True 6/16 с нарастающими +отказами к концу плеча — 5-минутная PoW-кука протухает внутри растянутого на +десятки минут прогона и не обновляется в тёплом context'е. Дефолт теперь +settings.avito_detail_reuse_context=False; флаг остаётся аварийным откатом. + +Когда reuse_context=True (явный override), сброс сожжённого context'а +оркестратор просит через bf.request_context_reset() — как и DomClick, ровно один +раз за прогон (#3212), а не на каждый обнаруженный блок (иначе сброс сам себя +поддерживает — см. domclick docstring). Reset относится ТОЛЬКО к реальному бану +площадкой (AvitoBlockedError и подтипы), не к отказу нашего тракта +(AvitoSidecarUnavailableError) и не к мёртвому листингу (AvitoListingGoneError) — +площадка тут ни при чём в обоих случаях, context цел. При reuse_context=False +(дефолт) reset не вызывается вовсе — сбрасывать нечего. Контракт тестов сформулирован от требования площадки/архитектуры (что ДОЛЖНО происходить при блоке/не-блоке), а не от текущей реализации — см. #3262, где @@ -80,6 +88,9 @@ def _fake_settings(**overrides: object) -> MagicMock: # rotate-on-ban веткой -- она молчаливо остаётся в "gap не выдержан". "avito_detail_backfill_rotate_on_ban_max": 2, "avito_detail_backfill_rotate_on_ban_min_gap": 10, + # Реальный дефолт (config.py, замер 19.09.2026) — изолированный context на + # каждый fetch. Тесты, которым нужен тёплый context, переопределяют явно. + "avito_detail_reuse_context": False, } defaults.update(overrides) return MagicMock(**defaults) @@ -118,12 +129,17 @@ def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]: return MagicMock(return_value=instance), instance -# ── BrowserFetcher(reuse_context=True) ────────────────────────────────────────── +# ── BrowserFetcher(reuse_context=) ───────── @pytest.mark.asyncio -async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None: - """BrowserFetcher поднимается с reuse_context=True (#3180) -- тёплый sidecar-context.""" +async def test_backfill_constructs_browser_fetcher_with_reuse_context_default_false() -> None: + """Дефолт settings.avito_detail_reuse_context=False -- fetcher идёт с ним же. + + Замер 19.09.2026 (probe-browser, A-B-A, 16 карточек ЕКБ/плечо): изолированный + context 14/16 и 13/16, reuse_context=True 6/16 с нарастающими отказами -- + выгорание ~5-минутной PoW-куки. См. config.py docstring. + """ snapshot = _make_snapshot(1) db = _mock_db(snapshot) runs = MagicMock() @@ -147,6 +163,37 @@ async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None: mock_bf_cls.assert_called_once() _, call_kwargs = mock_bf_cls.call_args assert call_kwargs["source"] == "avito" + assert call_kwargs["reuse_context"] is False + + +@pytest.mark.asyncio +async def test_backfill_forwards_reuse_context_from_settings_not_constant() -> None: + """reuse_context идёт из settings.avito_detail_reuse_context, не из хардкода. + + Аварийный откат на тёплый context (settings=True) должен доходить до + BrowserFetcher без правки кода. + """ + snapshot = _make_snapshot(1) + db = _mock_db(snapshot) + runs = MagicMock() + mock_fetch = AsyncMock(return_value=MagicMock()) + fake_settings = _fake_settings(avito_detail_reuse_context=True) + mock_bf_cls, _instance = _mock_browser_fetcher_cls() + with ( + patch(_SETTINGS, fake_settings), + patch(_SESSION), + patch(_SCRAPER), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, mock_bf_cls), + patch(_FETCH, mock_fetch), + patch(_SAVE, return_value=True), + patch(_SLEEP, new_callable=AsyncMock), + ): + await run_avito_detail_backfill( + db, run_id=209, params={"batch_size": 10, "budget_sec": 3600} + ) + + _, call_kwargs = mock_bf_cls.call_args assert call_kwargs["reuse_context"] is True @@ -155,18 +202,20 @@ async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None: @pytest.mark.asyncio async def test_context_reset_requested_at_most_once_per_run() -> None: - """3 подряд AvitoBlockedError -> request_context_reset() запрошен ровно 1 раз. + """3 подряд AvitoBlockedError + reuse_context=True -> reset запрошен ровно 1 раз. Зеркалит domclick #3212: сброс на КАЖДЫЙ блок сам себя поддерживает (пройденный анти-бот проход живёт в context'е, сброс его выбрасывает -> следующая проверка с того же IP снова блокируется) -- одна осечка становится необратимым каскадом. + Актуально только при reuse_context=True -- при дефолтном False см. + test_context_reset_not_requested_when_reuse_context_disabled ниже. """ snapshot = _make_snapshot(10) db = _mock_db(snapshot) runs = MagicMock() blocked_exc = AvitoBlockedError("firewall/soft-block") mock_fetch = AsyncMock(side_effect=blocked_exc) - fake_settings = _fake_settings() + fake_settings = _fake_settings(avito_detail_reuse_context=True) mock_bf_cls, instance = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -196,7 +245,7 @@ async def test_content_blocked_subtype_also_requests_reset() -> None: db = _mock_db(snapshot) runs = MagicMock() mock_fetch = AsyncMock(side_effect=AvitoContentBlockedError("0 карточек, captcha")) - fake_settings = _fake_settings() + fake_settings = _fake_settings(avito_detail_reuse_context=True) mock_bf_cls, instance = _mock_browser_fetcher_cls() with ( patch(_SETTINGS, fake_settings), @@ -217,6 +266,40 @@ async def test_content_blocked_subtype_also_requests_reset() -> None: instance.request_context_reset.assert_called_once() +@pytest.mark.asyncio +async def test_context_reset_not_requested_when_reuse_context_disabled() -> None: + """reuse_context=False (дефолт, замер 19.09.2026) -- bare reset не вызывается вовсе. + + Каждый fetch уже идёт в свежем изолированном context'е -- сбрасывать нечего, + даже когда блоки идут подряд. Отличает дефолт от test_context_reset_requested_ + at_most_once_per_run, где то же самое AvitoBlockedError с reuse_context=True + даёт ровно один reset. + """ + snapshot = _make_snapshot(10) + db = _mock_db(snapshot) + runs = MagicMock() + mock_fetch = AsyncMock(side_effect=AvitoBlockedError("firewall/soft-block")) + fake_settings = _fake_settings() + mock_bf_cls, instance = _mock_browser_fetcher_cls() + with ( + patch(_SETTINGS, fake_settings), + patch(_SESSION), + patch(_SCRAPER), + patch(_RUNS, runs), + patch(_BROWSER_FETCHER, mock_bf_cls), + patch(_FETCH, mock_fetch), + patch(_SLEEP, new_callable=AsyncMock), + ): + result = await run_avito_detail_backfill( + db, + run_id=210, + params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3}, + ) + + assert result.blocked == 3 + instance.request_context_reset.assert_not_called() + + @pytest.mark.asyncio async def test_sidecar_unavailable_does_not_request_context_reset() -> None: """AvitoSidecarUnavailableError -- НАШ тракт, площадка ни при чём: reset НЕ звать. diff --git a/tradein-mvp/backend/tests/test_avito_detail_reuse_context_default.py b/tradein-mvp/backend/tests/test_avito_detail_reuse_context_default.py new file mode 100644 index 00000000..5f1d5dfd --- /dev/null +++ b/tradein-mvp/backend/tests/test_avito_detail_reuse_context_default.py @@ -0,0 +1,33 @@ +"""avito_detail_reuse_context — дефолт False (замер 19.09.2026, probe-browser). + +Схема A-B-A, 16 карточек ЕКБ на плечо, один прокси-узел: изолированный context +14/16 и 13/16, reuse_context=True (тёплый) 6/16 с нарастающими отказами к концу +плеча — выгорание PoW-куки (~5 минут жизни) внутри растянутого на десятки минут +прогона. Перенесено на Avito по аналогии с DomClick (#3118) и опровергнуто +измерением; для DomClick reuse_context остаётся верным решением. Проверка по +значению поля Settings без env и без .env-файла — зеркалит test_3045_use_curl_default.py. +""" + +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.core.config import Settings + + +def test_avito_detail_reuse_context_defaults_to_false(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AVITO_DETAIL_REUSE_CONTEXT", raising=False) + + assert Settings(_env_file=None).avito_detail_reuse_context is False + + +def test_env_still_switches_reuse_context_on(monkeypatch: pytest.MonkeyPatch) -> None: + """Контроль: переменная окружения по-прежнему включает режим — поле живое. + + Флаг остаётся аварийным откатом на тёплый context без выката кода. + """ + monkeypatch.setenv("AVITO_DETAIL_REUSE_CONTEXT", "true") + + assert Settings(_env_file=None).avito_detail_reuse_context is True