From d66a52d4926b11746593bdaab80e9988348380ae Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 15:58:26 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein/avito):=20=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D1=8B=D0=B9=20=D0=B1=D1=8D=D0=BA?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D0=BB=20=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=BC=D0=BE=20=D0=BF=D1=83=D0=BB=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ветка browser_mode в avito_detail_backfill конструировала BrowserFetcher без proxy_provider/use_pool/environment. Без них фетчер не кладёт "proxy" в тело POST /fetch, сайдкар берёт свой env-прокси, и прогон уходит мимо пула целиком: ни выбора узла по affinity, ни учёта scrape_proxy_source_bans, ни ротации при блоке. В логе это ровно `proxy_lease_id=None`. Ровно этот дефект чинили рядом — #2698 в house_imv_backfill, где он держал 35 отказов из 35 попыток в каждом прогоне полтора месяца, пока соседние свипы через ТОТ ЖЕ сайдкар тянули сотни объявлений. Здесь он остался. Замер 27.08, прогон 5098: mode=browser, proxy_lease_id=None BLOCKED #1..#5 подряд — firewall/soft-block (browser-mode) ABORT — 5 consecutive blocks, enriched=0 attempted=5 При этом пул здоров — 4 узла, все ok, ни один не занят, браузерная проверка пройдена в то же утро. А тот же URL Авито через прокси отдаёт 200 и 3.3 МБ страницы. То есть площадка нас пускала, запрос шёл не оттуда. Это объясняет, почему предыдущая правка (#3143, прокси в повторах curl-пути) не восстановила сбор: боевой режим бэкфилла — browser, и он до curl-веток вообще не доходит. Три теста: конструктор на месте (страховка от проверки пустоты), все три аргумента проводки передаются, use_pool читается из конфига а не зашит константой (зашитый True отнял бы у владельца выключатель, зашитый False вернул бы дефект незаметно). Проверил красноту на коде без проводки. Прогон: 113 тестов зелёные, ruff чист. Refs #3045, #3034, #2698 --- .../app/tasks/avito_detail_backfill.py | 25 +++++- ...test_3045_detail_backfill_browser_proxy.py | 85 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_3045_detail_backfill_browser_proxy.py diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index 658cafe1..8013bcec 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -71,7 +71,7 @@ from app.core.config import settings from app.core.shutdown import shutdown_requested from app.services import scrape_runs as runs_mod from app.services.proxy_egress import resolve_proxy_url -from app.services.scraper_adapters import RealScraperConfig +from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig # #2397 Part D1 (#2330 закрыт): _AVITO_WARM_SEARCH_URL/build_warmed_session больше # НЕ legacy — kit's scraper_kit.providers.avito.detail.build_warmed_session() теперь @@ -256,8 +256,29 @@ async def run_avito_detail_backfill( # эфемерную _build_detail_session() через settings.scraper_proxy_url (backconnect). # use_curl=False (legacy): browser_mode=True → BrowserFetcher/auv, как раньше. if browser_mode: + # proxy_provider/use_pool/environment — обязательная часть проводки, + # а не опция (#2698). Без них BrowserFetcher не кладёт "proxy" в тело + # POST /fetch, сайдкар берёт свой env-прокси, и прогон уходит мимо + # пула: ни выбора узла по affinity, ни учёта scrape_proxy_source_bans, + # ни ротации при блоке. В логе это видно как proxy_lease_id=None. + # + # Ровно этот дефект уже чинили в house_imv_backfill (#2698) — там он + # держал 35 из 35 отказов в каждом прогоне полтора месяца, пока + # соседние свипы через ТОТ ЖЕ сайдкар тянули сотни объявлений. Здесь + # он остался. + # + # Замер 27.08: прогон 5098 — mode=browser, proxy_lease_id=None, + # 5 блоков подряд из 5 попыток, enriched=0. При этом пул здоров + # (4 узла, все ok), а тот же URL через прокси отдаёт 200 и 3.3 МБ. + _cfg = RealScraperConfig() browser_fetcher = BrowserFetcher( - source="avito", endpoint=settings.browser_http_endpoint + source="avito", + endpoint=settings.browser_http_endpoint, + proxy_provider=RealProxyProvider(), + use_pool=_cfg.use_proxy_pool_browser, + # Без environment отказ «пул пуст» на этом пути мёртв — фетчер + # молча ушёл бы на env-прокси сайдкара (#2616 шаг 1). + environment=_cfg.environment, ) await browser_fetcher.__aenter__() own_browser = True diff --git a/tradein-mvp/backend/tests/test_3045_detail_backfill_browser_proxy.py b/tradein-mvp/backend/tests/test_3045_detail_backfill_browser_proxy.py new file mode 100644 index 00000000..22a41d52 --- /dev/null +++ b/tradein-mvp/backend/tests/test_3045_detail_backfill_browser_proxy.py @@ -0,0 +1,85 @@ +"""Браузерный бэкфилл Авито обязан ходить через пул прокси, а не мимо него. + +ДЕФЕКТ. В `avito_detail_backfill` ветка `browser_mode` конструировала +`BrowserFetcher(source=..., endpoint=...)` без `proxy_provider`/`use_pool`/ +`environment`. Без них фетчер не кладёт `"proxy"` в тело `POST /fetch`, сайдкар +берёт свой env-прокси, и прогон уходит мимо пула целиком: ни выбора узла по +affinity, ни учёта `scrape_proxy_source_bans`, ни ротации при блоке. В логе это +видно как `proxy_lease_id=None`. + +ЭТО УЖЕ ЧИНИЛИ РЯДОМ. `house_imv_backfill` страдал ровно тем же (#2698): 35 +отказов из 35 попыток в каждом прогоне полтора месяца, пока соседние свипы через +ТОТ ЖЕ сайдкар тянули сотни объявлений. Контракт там закреплён тестом +`test_2698_imv_sidecar_wiring.py`; здесь тот же контракт для второго пути. + +ЗАМЕР 27.08, прогон 5098: + + mode=browser, proxy_lease_id=None + BLOCKED #1..#5 подряд — firewall/soft-block (browser-mode) + ABORT — 5 consecutive blocks, enriched=0 attempted=5 + +При этом пул был здоров (4 узла, все ok, ни один не занят), а тот же URL Авито +через прокси отдавал 200 и 3.3 МБ страницы — то есть площадка нас пускала, +просто запрос шёл не оттуда. + +Проверяется исходник ровно того места, а не поведение целиком: прогнать задачу +end-to-end означало бы поднять снапшоты, БД и сайдкар — цена, несоразмерная +одному инварианту проводки. +""" + +from __future__ import annotations + +import ast +import inspect +import os + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.tasks import avito_detail_backfill as adb + +REQUIRED = ("proxy_provider", "use_pool", "environment") + + +def _browser_fetcher_calls() -> list[ast.Call]: + """Все конструкторы BrowserFetcher в модуле.""" + tree = ast.parse(inspect.getsource(adb)) + return [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "BrowserFetcher" + ] + + +def test_module_still_builds_a_browser_fetcher() -> None: + """Страховка от проверки пустоты: если конструктор уедет, тест ниже онемеет.""" + assert _browser_fetcher_calls(), ( + "в avito_detail_backfill не нашлось ни одного BrowserFetcher(...) — " + "тест проводки перестал что-либо проверять" + ) + + +def test_browser_fetcher_is_wired_to_the_proxy_pool() -> None: + """Каждый конструктор обязан получать все три аргумента проводки пула.""" + for call in _browser_fetcher_calls(): + passed = {kw.arg for kw in call.keywords if kw.arg} + missing = [name for name in REQUIRED if name not in passed] + assert not missing, ( + f"BrowserFetcher построен без {missing} — запрос уйдёт мимо пула " + f"(proxy_lease_id=None), как в прогоне 5098" + ) + + +def test_pool_flag_comes_from_config_not_hardcoded() -> None: + """`use_pool` читается из конфига, а не зашит константой. + + Зашитый `True` отнял бы у владельца выключатель `USE_PROXY_POOL_BROWSER`, + а зашитый `False` вернул бы дефект, ничего не сломав заметно. + """ + for call in _browser_fetcher_calls(): + for kw in call.keywords: + if kw.arg == "use_pool": + assert not isinstance(kw.value, ast.Constant), ( + "use_pool задан константой — выключатель конфига перестал работать" + ) From c1918b404de4cc86bb4e6b7db3bd942ce7465913 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 16:16:26 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test(tradein/avito):=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=20=D0=B7=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D1=8F=D0=BB?= =?UTF-8?q?=20=D0=BE=D1=82=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=83=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assert_called_once_with(source=..., endpoint=...)` требует, чтобы других аргументов у вызова НЕ БЫЛО. Тем самым тест закреплял ровно тот дефект, который чинит этот PR: браузерный фетчер обязан был строиться без проводки пула, иначе CI краснел. Заменено на проверку вхождения: source и endpoint по-прежнему сверяются, плюс явно требуется наличие proxy_provider/use_pool/environment — то, без чего прогон уходит мимо пула (proxy_lease_id=None, 5 блоков из 5 при здоровом пуле). Прогон: 277 тестов зелёные, ruff чист. --- .../tests/tasks/test_avito_detail_backfill.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) 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 29cc9f48..cabec75c 100644 --- a/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py +++ b/tradein-mvp/backend/tests/tasks/test_avito_detail_backfill.py @@ -777,10 +777,23 @@ async def test_backfill_use_curl_false_creates_browser_fetcher() -> None: ) # BrowserFetcher должен быть создан (source="avito", endpoint из settings — #2310 - # kit BrowserFetcher требует endpoint= обязательным keyword-only параметром) - mock_bf_cls.assert_called_once_with( - source="avito", endpoint=fake_settings.browser_http_endpoint - ) + # kit BrowserFetcher требует endpoint= обязательным keyword-only параметром). + # + # Проверяем ВХОЖДЕНИЕ аргументов, а не полное равенство вызова. Прежняя + # форма `assert_called_once_with(source=..., endpoint=...)` требовала, чтобы + # других аргументов НЕ БЫЛО, и тем самым закрепляла дефект: именно + # отсутствие проводки пула (#3045) уводило браузерный прогон мимо прокси — + # 5 блоков из 5 попыток при полностью здоровом пуле. Проводка проверяется + # отдельно ниже. + mock_bf_cls.assert_called_once() + _bf_kwargs = mock_bf_cls.call_args.kwargs + assert _bf_kwargs["source"] == "avito" + assert _bf_kwargs["endpoint"] == fake_settings.browser_http_endpoint + # #3045: без этих трёх фетчер не кладёт "proxy" в тело POST /fetch, и + # сайдкар уходит на свой env-прокси мимо пула (proxy_lease_id=None). + assert _bf_kwargs.get("proxy_provider") is not None, "пул не подключён" + assert "use_pool" in _bf_kwargs, "флаг пула не доезжает до фетчера" + assert "environment" in _bf_kwargs, "без него отказ «пул пуст» мёртв (#2616)" # fetch_detail вызван с browser_fetcher установленным (не None) assert mock_fetch.call_count == 1 _, kwargs = mock_fetch.call_args