From a95c38bdcc2ee0f2952ede0a6c5b8d056928b72e Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 2 Aug 2026 17:31:00 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/scraper):=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=D1=82=D1=8C=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=BF=D1=83=D1=82?= =?UTF-8?q?=D1=8C=20=D0=90=D0=B2=D0=B8=D1=82=D0=BE=20=D0=BA=20=D0=BF=D1=83?= =?UTF-8?q?=D0=BB=D1=83=20=D0=BF=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 --- tradein-mvp/backend/app/api/v1/admin.py | 4 +- .../test_admin_scrape_avito_proxy_provider.py | 104 +++++++++++++++++ .../backend/tests/test_kit_serp_proxy_pool.py | 108 +++++++++++++++++- .../tests/test_scraper_kit_pipeline_parity.py | 50 +++++++- .../test_scraper_kit_pipeline_parity2.py | 97 +++++++++++++++- .../test_scraper_kit_scheduler_parity.py | 52 +++++++++ .../src/scraper_kit/orchestration/pipeline.py | 29 ++++- .../scraper_kit/orchestration/scheduler.py | 2 + .../src/scraper_kit/providers/avito/serp.py | 12 +- 9 files changed, 438 insertions(+), 20 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index 01dc273d..277981f5 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -198,7 +198,9 @@ async def scrape_around( for source in payload.sources: scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper if source == "avito": - scraper_ctx = AvitoScraper(config, delay_provider=get_scraper_delay) + scraper_ctx = AvitoScraper( + config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider + ) elif source == "cian": scraper_ctx = CianScraper( config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider diff --git a/tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py b/tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py new file mode 100644 index 00000000..aff66ae7 --- /dev/null +++ b/tradein-mvp/backend/tests/test_admin_scrape_avito_proxy_provider.py @@ -0,0 +1,104 @@ +"""#2616: POST /api/v1/admin/scrape (source=avito) прокидывает proxy_provider в AvitoScraper. + +Root cause (issue #2613/#2616): `AvitoScraper(config, delay_provider=get_scraper_delay)` в +`scrape_around` не передавал `proxy_provider=proxy_provider`, хотя переменная уже вычислена +в scope и передаётся в CianScraper/YandexRealtyScraper на соседних строках. Браузерный путь +Avito (SCRAPER_FETCH_MODE=browser) в результате всегда шёл через мёртвый `BROWSER_PROXY_AVITO` +env-прокси вместо пула. + +Offline: db/AvitoScraper/save_listings мокаются, NO live network/DB. +""" + +from __future__ import annotations + +import os +from typing import Any, ClassVar +from unittest.mock import MagicMock, patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def client() -> TestClient: + from app.api.v1 import admin as admin_module + from app.core.db import get_db + + app = FastAPI() + app.include_router(admin_module.router, prefix="/api/v1/admin") + + def fake_db() -> Any: + yield MagicMock() + + app.dependency_overrides[get_db] = fake_db + return TestClient(app) + + +class _RecordingScraper: + """Recording async-context-manager: пишет init-kwargs в общий список, fetch_around + возвращает пустой список лотов (save_listings ниже мокнут, до БД не доходим).""" + + calls: ClassVar[list[dict[str, Any]]] = [] + + def __init__(self, config: Any, **kwargs: Any) -> None: + type(self).calls.append({"config": config, **kwargs}) + + async def __aenter__(self) -> _RecordingScraper: + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def fetch_around(self, *_args: Any, **_kwargs: Any) -> list[Any]: + return [] + + +def test_scrape_avito_passes_proxy_provider(client: TestClient) -> None: + """source=['avito'] → AvitoScraper(config, ..., proxy_provider=). + + Falsification: если admin.py перестанет передавать proxy_provider= в AvitoScraper(...), + calls[-1] не будет содержать ключ 'proxy_provider' со значением sentinel (либо будет + None) — assert падает на VALUE (или KeyError на .get None-fallback), не на TypeError + (recorder принимает **kwargs, сигнатуру не проверяет). + """ + sentinel = object() + _RecordingScraper.calls = [] + + with ( + patch("app.api.v1.admin.AvitoScraper", _RecordingScraper), + patch("app.api.v1.admin._kit_proxy_provider", return_value=sentinel), + patch("app.api.v1.admin.save_listings", return_value=(0, 0)), + ): + r = client.post( + "/api/v1/admin/scrape", + json={"lat": 56.8332, "lon": 60.5944, "radius_m": 1000, "sources": ["avito"]}, + ) + + assert r.status_code == 200 + assert len(_RecordingScraper.calls) == 1 + assert _RecordingScraper.calls[0].get("proxy_provider") is sentinel + + +def test_scrape_avito_no_pool_default_none(client: TestClient) -> None: + """_kit_proxy_provider() → None (pool off, ship-dark default) → AvitoScraper получает + + proxy_provider=None — поведение прежнее. + """ + _RecordingScraper.calls = [] + + with ( + patch("app.api.v1.admin.AvitoScraper", _RecordingScraper), + patch("app.api.v1.admin._kit_proxy_provider", return_value=None), + patch("app.api.v1.admin.save_listings", return_value=(0, 0)), + ): + r = client.post( + "/api/v1/admin/scrape", + json={"lat": 56.8332, "lon": 60.5944, "radius_m": 1000, "sources": ["avito"]}, + ) + + assert r.status_code == 200 + assert len(_RecordingScraper.calls) == 1 + assert _RecordingScraper.calls[0].get("proxy_provider") is None diff --git a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py index 8352d77d..1c9905c1 100644 --- a/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_serp_proxy_pool.py @@ -1,8 +1,15 @@ -"""Тесты проброса browser-пула в SERP-скраперы yandex/cian (#2160). +"""Тесты проброса browser-пула в SERP-скраперы avito/yandex/cian (#2160, #2616). -Зеркало P4 (#2164) для avito: там пул раздавался только в avito-путях, а yandex/cian -SERP создавали BrowserFetcher сами в __aenter__ без пула → весь трафик шёл через -env-прокси браузера. Здесь проверяем, что YandexRealtyScraper / CianScraper прокидывают +Изначально (#2164 P4) пул раздавался только в avito-путях, а yandex/cian SERP создавали +BrowserFetcher сами в __aenter__ без пула → их трафик шёл через env-прокси браузера — +покрыто ниже (секции Yandex/Cian). #2616 закрывает СИММЕТРИЧНЫЙ пробел: сам Avito SERP +(`AvitoScraper.__init__`/`__aenter__`) не принимал `proxy_provider` вообще (в отличие от +CianScraper/YandexRealtyScraper) — `build_browser_fetcher(config, "avito")` вызывался +без proxy_provider, поэтому браузерный путь Avito ходил ТОЛЬКО через мёртвый +`BROWSER_PROXY_AVITO` (issue #2613/#2616, root cause 52/53 banned-прогонов за 14 дней). +Секция Avito ниже проверяет тот же инвариант, что и Yandex/Cian. + +Здесь проверяем, что AvitoScraper / YandexRealtyScraper / CianScraper прокидывают proxy_provider + use_pool=config.use_proxy_pool_browser в BrowserFetcher. Инвариант ship-dark: @@ -33,6 +40,7 @@ from typing import Any import pytest from scraper_kit.providers import _base as kit_base +from scraper_kit.providers.avito import serp as avito_serp from scraper_kit.providers.cian import serp as cian_serp from scraper_kit.providers.yandex import serp as yandex_serp @@ -177,3 +185,95 @@ async def test_cian_serp_no_pool_by_default(monkeypatch: pytest.MonkeyPatch) -> assert len(calls) == 1 assert calls[0]["proxy_provider"] is None assert calls[0]["use_pool"] is False + + +# ── Avito SERP (#2616) ────────────────────────────────────────────────────── + + +class _FakeCffiSession: + """No-op curl_cffi AsyncSession stand-in — AvitoScraper.__aenter__ builds a + curl_cffi fallback session даже в browser-mode (#1805, через + providers._base.build_document_session → _base.AsyncSession — НЕ + avito_serp.AsyncSession, тот используется только в _rotate_ip), не относится + к проверяемому здесь инварианту (BrowserFetcher kwargs). Патчим + kit_base.AsyncSession, чтобы не конструировать реальную curl_cffi сессию.""" + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def close(self) -> None: + return None + + +def _avito_config(*, use_pool: bool) -> types.SimpleNamespace: + return types.SimpleNamespace( + browser_http_endpoint="http://browser:3000", + use_proxy_pool_browser=use_pool, + scraper_fetch_mode="browser", + avito_proxy_rotate_url=None, + scraper_proxy_url=None, + ) + + +@pytest.mark.asyncio +async def test_avito_serp_wires_pool_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None: + """proxy_provider + флаг on → BrowserFetcher получает провайдер + use_pool=True. + + #2616 root-cause: до этого фикса AvitoScraper.__init__ не принимал + proxy_provider вовсе (AttributeError/TypeError на попытке передать) — + build_browser_fetcher(config, "avito") в __aenter__ вызывался БЕЗ + proxy_provider, поэтому браузерный путь Avito шёл только через мёртвый + BROWSER_PROXY_AVITO env-прокси. + """ + rec_fetcher, calls = _make_recorder() + monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher) + monkeypatch.setattr(kit_base, "AsyncSession", _FakeCffiSession) + provider = _FakeProxyProvider() + + scraper = avito_serp.AvitoScraper(_avito_config(use_pool=True), proxy_provider=provider) + async with scraper: + pass + + assert len(calls) == 1 + assert calls[0]["source"] == "avito" + assert calls[0]["proxy_provider"] is provider + assert calls[0]["use_pool"] is True + + +@pytest.mark.asyncio +async def test_avito_serp_no_pool_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + """Без provider/флага → BrowserFetcher(proxy_provider=None, use_pool=False) — ship-dark. + + AvitoScraper(config) без явного proxy_provider= (позиционка совместимость со + старыми вызывающими) — поведение byte-в-byte как до #2616. + """ + rec_fetcher, calls = _make_recorder() + monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher) + monkeypatch.setattr(kit_base, "AsyncSession", _FakeCffiSession) + + scraper = avito_serp.AvitoScraper(_avito_config(use_pool=False)) + async with scraper: + pass + + assert len(calls) == 1 + assert calls[0]["source"] == "avito" + assert calls[0]["proxy_provider"] is None + assert calls[0]["use_pool"] is False + + +@pytest.mark.asyncio +async def test_avito_serp_provider_without_flag_stays_dark( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """provider задан, но флаг off → use_pool=False (BrowserFetcher пул не трогает).""" + rec_fetcher, calls = _make_recorder() + monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher) + monkeypatch.setattr(kit_base, "AsyncSession", _FakeCffiSession) + provider = _FakeProxyProvider() + + scraper = avito_serp.AvitoScraper(_avito_config(use_pool=False), proxy_provider=provider) + async with scraper: + pass + + assert calls[0]["proxy_provider"] is provider + assert calls[0]["use_pool"] is False diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py index 262af3dd..0f211154 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity.py @@ -174,16 +174,25 @@ def _async_session_cm() -> MagicMock: return sess -async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) -> _DriveResult: - """capture: опциональный dict — если передан, кладём туда save_mock (#2594) для - инспекции call_args (city=...) без изменения возвращаемого _DriveResult (backward-compat - для всех существующих вызовов _drive без capture).""" +async def _drive( + scenario: _Scenario, + *, + capture: dict[str, Any] | None = None, + proxy_provider: Any = None, +) -> _DriveResult: + """capture: опциональный dict — если передан, кладём туда save_mock (#2594) и + avito_scraper_cls (#2616, MagicMock class — для инспекции AvitoScraper(...) call_args, + напр. proxy_provider=) для инспекции call_args (city=...) без изменения возвращаемого + _DriveResult (backward-compat для всех существующих вызовов _drive без capture). + proxy_provider: прокидывается в run_avito_city_sweep(...) как есть (#2616 wiring test).""" recorder = _RunsRecorder() db = _make_db(scenario) scraper = _make_scraper(scenario, AvitoBlockedError) save_mock = MagicMock(side_effect=scenario._save_side_effects()) + avito_scraper_cls = MagicMock(return_value=scraper) if capture is not None: capture["save_mock"] = save_mock + capture["avito_scraper_cls"] = avito_scraper_cls imv_res = None if scenario.imv_result is not None: @@ -195,7 +204,7 @@ async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) pfx = "scraper_kit.orchestration.pipeline" with ( - patch(f"{pfx}.AvitoScraper", return_value=scraper), + patch(f"{pfx}.AvitoScraper", avito_scraper_cls), patch(f"{pfx}.save_listings", save_mock), patch(f"{pfx}.fetch_house_catalog", AsyncMock(return_value=MagicMock())), patch(f"{pfx}.save_house_catalog_enrichment", return_value={"house_id": 1}), @@ -215,6 +224,7 @@ async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) matcher=MagicMock(), enrichment=enrichment, shutdown_requested=lambda: False, + proxy_provider=proxy_provider, radius_m=1000, anchors=scenario.anchors, city_slug=scenario.city_slug, @@ -405,3 +415,33 @@ async def test_avito_city_sweep_no_geo_guard_anchor_for_ekaterinburg() -> None: save_mock = capture["save_mock"] assert save_mock.call_args.kwargs["city_anchor"] is None assert save_mock.call_args.kwargs["city_radius_km"] is None + + +# ── #2616: run_avito_city_sweep прокидывает proxy_provider в AvitoScraper(...) ── +# +# NOT load-bearing здесь (в отличие от run_avito_full_load): browser_mode переопределяет +# scraper._browser напрямую shared_bf'ом (уже построенным с proxy_provider=proxy_provider +# ВЫШЕ по стеку, до конструктора AvitoScraper) — __aenter__ вообще не вызывается для +# per-anchor scraper'а. Это регрессионный замок консистентности с cian/yandex-паттерном, +# на случай будущего рефакторинга, который начнёт полагаться на __aenter__. + + +@pytest.mark.asyncio +async def test_avito_city_sweep_passes_proxy_provider_to_scraper_constructor() -> None: + """proxy_provider=X → AvitoScraper(config, target_city_slug=..., proxy_provider=X). + + Falsification: если pipeline.py перестанет прокидывать proxy_provider в конструктор + AvitoScraper внутри run_avito_city_sweep, avito_scraper_cls.call_args.kwargs не будет + содержать sentinel — assert падает на VALUE, не на TypeError (MagicMock не проверяет + сигнатуру). + """ + sentinel = object() + scenario = _Scenario( + anchors=[(56.84, 60.60, "A1")], + per_anchor=[("lots", 1, 1, 0)], + ) + capture: dict[str, Any] = {} + await _drive(scenario, capture=capture, proxy_provider=sentinel) + avito_scraper_cls = capture["avito_scraper_cls"] + avito_scraper_cls.assert_called_once() + assert avito_scraper_cls.call_args.kwargs.get("proxy_provider") is sentinel diff --git a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py index eb05dc07..87fbc11b 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_pipeline_parity2.py @@ -289,8 +289,12 @@ async def test_domclick_city_sweep_blocked_failed() -> None: # ── Avito newbuilding sweep ─────────────────────────────────────────────────── -async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveResult: - """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...).""" +async def _drive_nb_sweep( + *, capture: dict[str, Any] | None = None, proxy_provider: Any = None +) -> _DriveResult: + """capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...) и + avito_scraper_cls (#2616, MagicMock class — инспекция AvitoScraper(...) call_args). + proxy_provider: прокидывается в run_avito_newbuilding_sweep(...) как есть.""" recorder = _RunsRecorder() db = MagicMock() lots = [MagicMock() for _ in range(6)] @@ -299,17 +303,25 @@ async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveRes scraper._browser = None scraper.fetch_newbuildings = AsyncMock(return_value=lots) save_mock = MagicMock(side_effect=[(5, 1)]) + avito_scraper_cls = MagicMock(return_value=scraper) if capture is not None: capture["save_mock"] = save_mock + capture["avito_scraper_cls"] = avito_scraper_cls cfg = _config() with ( - patch(f"{PFX}.AvitoScraper", return_value=scraper), + patch(f"{PFX}.AvitoScraper", avito_scraper_cls), patch(f"{PFX}.save_listings", save_mock), patch(f"{PFX}.runs", recorder), patch(f"{PFX}.AsyncSession", return_value=_async_session_cm()), ): counters = await run_avito_newbuilding_sweep( - db, config=cfg, matcher=MagicMock(), run_id=1, pages=2, request_delay_sec=0.0 + db, + config=cfg, + matcher=MagicMock(), + run_id=1, + pages=2, + request_delay_sec=0.0, + proxy_provider=proxy_provider, ) return counters.to_dict(), _normalize(recorder.calls) @@ -324,6 +336,23 @@ async def test_avito_newbuilding_sweep() -> None: assert calls[-1][0] == "mark_done" +@pytest.mark.asyncio +async def test_avito_newbuilding_sweep_passes_proxy_provider_to_scraper_constructor() -> None: + """#2616: proxy_provider=X → AvitoScraper(config, proxy_provider=X). + + NOT load-bearing (browser_mode переопределяет scraper._browser напрямую с + shared_bf, построенным с proxy_provider=proxy_provider выше по стеку) — регрессионный + замок консистентности с cian/yandex, см. run_avito_city_sweep эквивалент. + Falsification: без проброса в pipeline.py mock.call_args.kwargs не содержит sentinel. + """ + sentinel = object() + capture: dict[str, Any] = {} + await _drive_nb_sweep(capture=capture, proxy_provider=sentinel) + avito_scraper_cls = capture["avito_scraper_cls"] + avito_scraper_cls.assert_called_once() + assert avito_scraper_cls.call_args.kwargs.get("proxy_provider") is sentinel + + # ── Full loads (smoke через on_bucket) ───────────────────────────────────────── @@ -390,6 +419,66 @@ async def test_full_load_smoke(source: str) -> None: assert calls[-1][0] == "mark_done" +# ── #2616: run_avito_full_load прокидывает proxy_provider в AvitoScraper ───── +# +# run_avito_full_load — единственное из мест создания AvitoScraper в pipeline.py, где +# `async with AvitoScraper(...) as scraper:` реально проходит через __aenter__ (city_sweep/ +# newbuilding_sweep/run_avito_pipeline строят shared BrowserFetcher вручную и переопределяют +# scraper._browser напрямую, минуя __aenter__ — там proxy_provider проброшен для +# консистентности, но не load-bearing). Здесь proxy_provider ДЕЙСТВИТЕЛЬНО обязан долететь +# до конструктора AvitoScraper, иначе build_browser_fetcher(config, "avito") в __aenter__ +# строит BrowserFetcher без пула (env-fallback на мёртвый BROWSER_PROXY_AVITO, #2613). + + +@pytest.mark.asyncio +async def test_avito_full_load_passes_proxy_provider_to_scraper() -> None: + """run_avito_full_load(proxy_provider=X) → AvitoScraper(config, proxy_provider=X). + + Falsification: если pipeline.py перестанет прокидывать proxy_provider в + AvitoScraper(...), mock.call_args.kwargs['proxy_provider'] не будет `sentinel` + (либо ключа не будет вовсе) — assert падает на VALUE, не на TypeError (Mock + принимает любые kwargs, сигнатуру не проверяет). + """ + recorder = _RunsRecorder() + db = MagicMock() + buckets = [("2к:0-5m", [MagicMock(source_id="a1")])] + scraper = _full_load_scraper(buckets) + sentinel = object() + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper) as mock_cls, + patch(f"{PFX}.save_listings", MagicMock(side_effect=[(1, 0)])), + patch(f"{PFX}.runs", recorder), + ): + await run_avito_full_load( + db, + run_id=1, + config=_config(), + matcher=MagicMock(), + proxy_provider=sentinel, + ) + + mock_cls.assert_called_once() + assert mock_cls.call_args.kwargs.get("proxy_provider") is sentinel + + +@pytest.mark.asyncio +async def test_avito_full_load_default_proxy_provider_is_none() -> None: + """Без proxy_provider= — AvitoScraper(config, proxy_provider=None), поведение прежнее.""" + recorder = _RunsRecorder() + db = MagicMock() + buckets = [("2к:0-5m", [MagicMock(source_id="a1")])] + scraper = _full_load_scraper(buckets) + with ( + patch(f"{PFX}.AvitoScraper", return_value=scraper) as mock_cls, + patch(f"{PFX}.save_listings", MagicMock(side_effect=[(1, 0)])), + patch(f"{PFX}.runs", recorder), + ): + await run_avito_full_load(db, run_id=1, config=_config(), matcher=MagicMock()) + + mock_cls.assert_called_once() + assert mock_cls.call_args.kwargs.get("proxy_provider") is None + + # ── #2594: listings.city проставляется из контекста развёртки ──────────────── # # Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не diff --git a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py index ceb107ce..ca9f0aab 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_scheduler_parity.py @@ -35,6 +35,8 @@ from scraper_kit.orchestration.scheduler import ( _claim_run, _dispatch, _job_avito_city_sweep, + _job_avito_full_load, + _job_avito_full_load_exhaustive, _job_cian_city_sweep, _job_yandex_city_sweep, build_registry, @@ -517,6 +519,56 @@ async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None: assert kwargs["anchors"] is None +# ── #2616: _job_avito_full_load(_exhaustive) прокидывает ctx.proxy_provider ────── +# +# run_avito_full_load — единственный avito sweep, где было НЕ прокинуто proxy_provider +# вообще (ни в сигнатуре run_avito_full_load, ни на call-site здесь) — asymmetric +# с _job_avito_city_sweep/_job_avito_newbuilding_sweep (proxy_provider=ctx.proxy_provider +# уже были) и с _job_cian_full_load (уже пробрасывал). Root cause 13/14 banned avito_full_load +# прогонов за 14 дней (issue #2613/#2616). + + +def _proxy_ctx(sentinel: object) -> SchedulerContext: + return SchedulerContext( + config=MagicMock(), + matcher=MagicMock(), + enrichment=MagicMock(), + session_factory=MagicMock(), + runs=MagicMock(), + proxy_provider=sentinel, + ) + + +async def test_job_avito_full_load_passes_proxy_provider() -> None: + """_job_avito_full_load(ctx) → run_avito_full_load(..., proxy_provider=ctx.proxy_provider). + + Falsification: если scheduler.py перестанет прокидывать proxy_provider=ctx.proxy_provider + в run_avito_full_load(...), kwargs['proxy_provider'] не будет `sentinel` (либо ключа не + будет вовсе) — assert падает на VALUE, не на TypeError (AsyncMock не проверяет сигнатуру). + """ + sentinel = object() + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load(MagicMock(), 1, {}, _proxy_ctx(sentinel)) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs.get("proxy_provider") is sentinel + + +async def test_job_avito_full_load_exhaustive_passes_proxy_provider() -> None: + """_job_avito_full_load_exhaustive(ctx) → + + run_avito_full_load(..., proxy_provider=ctx.proxy_provider). + """ + sentinel = object() + with patch.object(kit_sched, "run_avito_full_load", AsyncMock()) as mock_run: + await _job_avito_full_load_exhaustive(MagicMock(), 1, {}, _proxy_ctx(sentinel)) + + mock_run.assert_awaited_once() + _args, kwargs = mock_run.call_args + assert kwargs.get("proxy_provider") is sentinel + + def test_wildcard_resolves_oblast_city_sweep_sources() -> None: """resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*".""" registry = build_registry() diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py index beb03b03..baea420d 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/pipeline.py @@ -552,7 +552,13 @@ async def run_avito_pipeline( own_session = False own_browser = False - scraper = AvitoScraper(config) + # proxy_provider прокинут в конструктор для консистентности с cian/yandex (#2616); + # НЕ load-bearing здесь — browser_mode ниже всегда переопределяет scraper._browser + # напрямую (shared_browser либо свежепостроенный BrowserFetcher с proxy_provider= + # proxy_provider), минуя AvitoScraper.__aenter__ целиком (own_browser/shared_session + # pattern, см. docstring выше). Оставлено для defense-in-depth на случай будущего + # рефакторинга, который начнёт полагаться на __aenter__. + scraper = AvitoScraper(config, proxy_provider=proxy_provider) if browser_mode: browser_fetcher = shared_browser if browser_fetcher is None: @@ -1109,7 +1115,12 @@ async def run_avito_city_sweep( # #2487: target_city_slug → _parse_html оставляет карточки # города-цели (oblast slug). #12: _avito_slug (может отличаться # от city_slug) идёт в путь URL — не хардкод /ekaterinburg/. None → ЕКБ. - scraper = AvitoScraper(config, target_city_slug=_avito_slug) + # proxy_provider прокинут для консистентности (#2616) — не load-bearing, + # shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда + # переопределяет scraper._browser напрямую, минуя __aenter__. + scraper = AvitoScraper( + config, target_city_slug=_avito_slug, proxy_provider=proxy_provider + ) if browser_mode: scraper._browser = shared_bf # Shared-browser режим: _cffi=None → curl_cffi-fallback на @@ -1751,7 +1762,10 @@ async def run_avito_newbuilding_sweep( runs.mark_done(db, run_id, counters.to_dict()) return counters - scraper = AvitoScraper(config) + # proxy_provider прокинут для консистентности (#2616) — не load-bearing, + # shared_bf (уже построен с proxy_provider=proxy_provider выше) всегда + # переопределяет scraper._browser напрямую, минуя __aenter__. + scraper = AvitoScraper(config, proxy_provider=proxy_provider) if browser_mode: scraper._browser = shared_bf # Shared-browser режим: _cffi=None → curl_cffi-fallback на firewall @@ -3364,6 +3378,7 @@ async def run_avito_full_load( config: ScraperConfig, matcher: HouseMatcher, shutdown_requested: Callable[[], bool] = lambda: False, + proxy_provider: ProxyProvider | None = None, price_cap_per_bucket: int = 1400, request_delay_sec: float = 7.0, concurrency: int = 5, @@ -3381,6 +3396,12 @@ async def run_avito_full_load( Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо прямых импортов app.* (см. scraper_kit.contracts). + proxy_provider (#2616): в отличие от run_avito_city_sweep/run_avito_newbuilding_sweep + (shared-browser, ручной override scraper._browser), здесь `AvitoScraper` реально + проходит через свой `__aenter__` (`async with AvitoScraper(...)`) — proxy_provider + здесь LOAD-BEARING: без него build_browser_fetcher(config, "avito") строит + BrowserFetcher без пула (env-fallback), ровно как run_cian_full_load передаёт + proxy_provider в CianScraper. Cooperative cancel: runs.is_cancelled проверяется per-bucket. AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned'). """ @@ -3466,7 +3487,7 @@ async def run_avito_full_load( runs.update_heartbeat(db, run_id, counters.to_dict()) try: - async with AvitoScraper(config) as scraper: + async with AvitoScraper(config, proxy_provider=proxy_provider) as scraper: scraper.request_delay_sec = request_delay_sec await scraper.fetch_all_secondary( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py index 46e52841..a88976f3 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/orchestration/scheduler.py @@ -492,6 +492,7 @@ async def _job_avito_full_load( config=ctx.config, matcher=ctx.matcher, shutdown_requested=ctx.shutdown_requested, + proxy_provider=ctx.proxy_provider, price_cap_per_bucket=int(params.get("price_cap_per_bucket", 1400)), concurrency=int(params.get("concurrency", 5)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), @@ -511,6 +512,7 @@ async def _job_avito_full_load_exhaustive( config=ctx.config, matcher=ctx.matcher, shutdown_requested=ctx.shutdown_requested, + proxy_provider=ctx.proxy_provider, price_cap_per_bucket=int(params.get("price_cap_per_bucket", 1400)), concurrency=int(params.get("concurrency", 5)), request_delay_sec=float(params.get("request_delay_sec", 7.0)), diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py index 510f0119..7d8edd90 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/serp.py @@ -51,7 +51,7 @@ from scraper_kit.providers._base import build_browser_fetcher, build_document_se from scraper_kit.providers.avito.shared import _unix_to_date if TYPE_CHECKING: - from scraper_kit.contracts import ScraperConfig + from scraper_kit.contracts import ProxyProvider, ScraperConfig logger = logging.getLogger(__name__) @@ -351,6 +351,7 @@ class AvitoScraper(BaseScraper): *, delay_provider: Callable[[str], float] | None = None, target_city_slug: str | None = None, + proxy_provider: ProxyProvider | None = None, ) -> None: super().__init__() # Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи @@ -360,6 +361,11 @@ class AvitoScraper(BaseScraper): self._config = config if delay_provider is not None: self.request_delay_sec = delay_provider(self.name) + # #2616: пул прокси для camoufox-браузера (за флагом use_proxy_pool_browser), + # ровно тот же паттерн, что CianScraper/YandexRealtyScraper (#2160/#2164 P4). + # None → build_browser_fetcher передаёт use_pool=False эффективно (BrowserFetcher + # env-fallback) — ship-dark, поведение прежнее. + self._proxy_provider = proxy_provider self._cffi: AsyncSession | None = None self._browser: BrowserFetcher | None = None # #823: счётчик карточек, которые не удалось распарсить из-за неожиданной структуры DOM. @@ -392,7 +398,9 @@ class AvitoScraper(BaseScraper): # FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает # один retry через curl_cffi. _build_cffi_session самодостаточен # (отдельный AsyncSession, не зависит от BaseScraper.__aenter__). - self._browser = build_browser_fetcher(self._config, "avito") + self._browser = build_browser_fetcher( + self._config, "avito", proxy_provider=self._proxy_provider + ) await self._browser.__aenter__() self._cffi = self._build_cffi_session() logger.info(