Merge pull request 'fix(tradein/scraper): подключить браузерный путь Авито к пулу прокси' (#2637) from fix/tradein-avito-browser-proxy-pool into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 1m31s
Deploy Trade-In / deploy (push) Successful in 1m52s
All checks were successful
Deploy Trade-In / changes (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m35s
Deploy Trade-In / build-backend (push) Successful in 1m31s
Deploy Trade-In / deploy (push) Successful in 1m52s
This commit is contained in:
commit
8c0511e0bd
9 changed files with 438 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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), но раньше НИКУДА его не
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue