fix(tradein/scraper): подключить браузерный путь Авито к пулу прокси #2637

Merged
lekss361 merged 1 commit from fix/tradein-avito-browser-proxy-pool into main 2026-08-02 14:37:40 +00:00
9 changed files with 438 additions and 20 deletions

View file

@ -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

View file

@ -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=<sentinel>).
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

View file

@ -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

View file

@ -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

View file

@ -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), но раньше НИКУДА его не

View file

@ -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()

View file

@ -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(

View file

@ -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)),

View file

@ -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(