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/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 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 задан константой — выключатель конфига перестал работать" + )