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:
|
for source in payload.sources:
|
||||||
scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper
|
scraper_ctx: AvitoScraper | CianScraper | YandexRealtyScraper
|
||||||
if source == "avito":
|
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":
|
elif source == "cian":
|
||||||
scraper_ctx = CianScraper(
|
scraper_ctx = CianScraper(
|
||||||
config, delay_provider=get_scraper_delay, proxy_provider=proxy_provider
|
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
|
Изначально (#2164 P4) пул раздавался только в avito-путях, а yandex/cian SERP создавали
|
||||||
SERP создавали BrowserFetcher сами в __aenter__ без пула → весь трафик шёл через
|
BrowserFetcher сами в __aenter__ без пула → их трафик шёл через env-прокси браузера —
|
||||||
env-прокси браузера. Здесь проверяем, что YandexRealtyScraper / CianScraper прокидывают
|
покрыто ниже (секции 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.
|
proxy_provider + use_pool=config.use_proxy_pool_browser в BrowserFetcher.
|
||||||
|
|
||||||
Инвариант ship-dark:
|
Инвариант ship-dark:
|
||||||
|
|
@ -33,6 +40,7 @@ from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from scraper_kit.providers import _base as kit_base
|
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.cian import serp as cian_serp
|
||||||
from scraper_kit.providers.yandex import serp as yandex_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 len(calls) == 1
|
||||||
assert calls[0]["proxy_provider"] is None
|
assert calls[0]["proxy_provider"] is None
|
||||||
assert calls[0]["use_pool"] is False
|
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
|
return sess
|
||||||
|
|
||||||
|
|
||||||
async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) -> _DriveResult:
|
async def _drive(
|
||||||
"""capture: опциональный dict — если передан, кладём туда save_mock (#2594) для
|
scenario: _Scenario,
|
||||||
инспекции call_args (city=...) без изменения возвращаемого _DriveResult (backward-compat
|
*,
|
||||||
для всех существующих вызовов _drive без capture)."""
|
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()
|
recorder = _RunsRecorder()
|
||||||
db = _make_db(scenario)
|
db = _make_db(scenario)
|
||||||
scraper = _make_scraper(scenario, AvitoBlockedError)
|
scraper = _make_scraper(scenario, AvitoBlockedError)
|
||||||
save_mock = MagicMock(side_effect=scenario._save_side_effects())
|
save_mock = MagicMock(side_effect=scenario._save_side_effects())
|
||||||
|
avito_scraper_cls = MagicMock(return_value=scraper)
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
capture["save_mock"] = save_mock
|
capture["save_mock"] = save_mock
|
||||||
|
capture["avito_scraper_cls"] = avito_scraper_cls
|
||||||
|
|
||||||
imv_res = None
|
imv_res = None
|
||||||
if scenario.imv_result is not 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"
|
pfx = "scraper_kit.orchestration.pipeline"
|
||||||
with (
|
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}.save_listings", save_mock),
|
||||||
patch(f"{pfx}.fetch_house_catalog", AsyncMock(return_value=MagicMock())),
|
patch(f"{pfx}.fetch_house_catalog", AsyncMock(return_value=MagicMock())),
|
||||||
patch(f"{pfx}.save_house_catalog_enrichment", return_value={"house_id": 1}),
|
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(),
|
matcher=MagicMock(),
|
||||||
enrichment=enrichment,
|
enrichment=enrichment,
|
||||||
shutdown_requested=lambda: False,
|
shutdown_requested=lambda: False,
|
||||||
|
proxy_provider=proxy_provider,
|
||||||
radius_m=1000,
|
radius_m=1000,
|
||||||
anchors=scenario.anchors,
|
anchors=scenario.anchors,
|
||||||
city_slug=scenario.city_slug,
|
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"]
|
save_mock = capture["save_mock"]
|
||||||
assert save_mock.call_args.kwargs["city_anchor"] is None
|
assert save_mock.call_args.kwargs["city_anchor"] is None
|
||||||
assert save_mock.call_args.kwargs["city_radius_km"] 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 ───────────────────────────────────────────────────
|
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveResult:
|
async def _drive_nb_sweep(
|
||||||
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
*, 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()
|
recorder = _RunsRecorder()
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
lots = [MagicMock() for _ in range(6)]
|
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._browser = None
|
||||||
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
|
scraper.fetch_newbuildings = AsyncMock(return_value=lots)
|
||||||
save_mock = MagicMock(side_effect=[(5, 1)])
|
save_mock = MagicMock(side_effect=[(5, 1)])
|
||||||
|
avito_scraper_cls = MagicMock(return_value=scraper)
|
||||||
if capture is not None:
|
if capture is not None:
|
||||||
capture["save_mock"] = save_mock
|
capture["save_mock"] = save_mock
|
||||||
|
capture["avito_scraper_cls"] = avito_scraper_cls
|
||||||
cfg = _config()
|
cfg = _config()
|
||||||
with (
|
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}.save_listings", save_mock),
|
||||||
patch(f"{PFX}.runs", recorder),
|
patch(f"{PFX}.runs", recorder),
|
||||||
patch(f"{PFX}.AsyncSession", return_value=_async_session_cm()),
|
patch(f"{PFX}.AsyncSession", return_value=_async_session_cm()),
|
||||||
):
|
):
|
||||||
counters = await run_avito_newbuilding_sweep(
|
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)
|
return counters.to_dict(), _normalize(recorder.calls)
|
||||||
|
|
||||||
|
|
@ -324,6 +336,23 @@ async def test_avito_newbuilding_sweep() -> None:
|
||||||
assert calls[-1][0] == "mark_done"
|
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) ─────────────────────────────────────────
|
# ── Full loads (smoke через on_bucket) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -390,6 +419,66 @@ async def test_full_load_smoke(source: str) -> None:
|
||||||
assert calls[-1][0] == "mark_done"
|
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 проставляется из контекста развёртки ────────────────
|
# ── #2594: listings.city проставляется из контекста развёртки ────────────────
|
||||||
#
|
#
|
||||||
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
|
# Критичный дефект: развёртка ЗНАЕТ город (city_slug), но раньше НИКУДА его не
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ from scraper_kit.orchestration.scheduler import (
|
||||||
_claim_run,
|
_claim_run,
|
||||||
_dispatch,
|
_dispatch,
|
||||||
_job_avito_city_sweep,
|
_job_avito_city_sweep,
|
||||||
|
_job_avito_full_load,
|
||||||
|
_job_avito_full_load_exhaustive,
|
||||||
_job_cian_city_sweep,
|
_job_cian_city_sweep,
|
||||||
_job_yandex_city_sweep,
|
_job_yandex_city_sweep,
|
||||||
build_registry,
|
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
|
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:
|
def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
|
||||||
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
|
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
|
||||||
registry = build_registry()
|
registry = build_registry()
|
||||||
|
|
|
||||||
|
|
@ -552,7 +552,13 @@ async def run_avito_pipeline(
|
||||||
own_session = False
|
own_session = False
|
||||||
own_browser = 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:
|
if browser_mode:
|
||||||
browser_fetcher = shared_browser
|
browser_fetcher = shared_browser
|
||||||
if browser_fetcher is None:
|
if browser_fetcher is None:
|
||||||
|
|
@ -1109,7 +1115,12 @@ async def run_avito_city_sweep(
|
||||||
# #2487: target_city_slug → _parse_html оставляет карточки
|
# #2487: target_city_slug → _parse_html оставляет карточки
|
||||||
# города-цели (oblast slug). #12: _avito_slug (может отличаться
|
# города-цели (oblast slug). #12: _avito_slug (может отличаться
|
||||||
# от city_slug) идёт в путь URL — не хардкод /ekaterinburg/. None → ЕКБ.
|
# от 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:
|
if browser_mode:
|
||||||
scraper._browser = shared_bf
|
scraper._browser = shared_bf
|
||||||
# Shared-browser режим: _cffi=None → curl_cffi-fallback на
|
# 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())
|
runs.mark_done(db, run_id, counters.to_dict())
|
||||||
return counters
|
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:
|
if browser_mode:
|
||||||
scraper._browser = shared_bf
|
scraper._browser = shared_bf
|
||||||
# Shared-browser режим: _cffi=None → curl_cffi-fallback на firewall
|
# Shared-browser режим: _cffi=None → curl_cffi-fallback на firewall
|
||||||
|
|
@ -3364,6 +3378,7 @@ async def run_avito_full_load(
|
||||||
config: ScraperConfig,
|
config: ScraperConfig,
|
||||||
matcher: HouseMatcher,
|
matcher: HouseMatcher,
|
||||||
shutdown_requested: Callable[[], bool] = lambda: False,
|
shutdown_requested: Callable[[], bool] = lambda: False,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
price_cap_per_bucket: int = 1400,
|
price_cap_per_bucket: int = 1400,
|
||||||
request_delay_sec: float = 7.0,
|
request_delay_sec: float = 7.0,
|
||||||
concurrency: int = 5,
|
concurrency: int = 5,
|
||||||
|
|
@ -3381,6 +3396,12 @@ async def run_avito_full_load(
|
||||||
|
|
||||||
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
Инжекция (#2135 F2): config/matcher/shutdown_requested приходят снаружи вместо
|
||||||
прямых импортов app.* (см. scraper_kit.contracts).
|
прямых импортов 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.
|
Cooperative cancel: runs.is_cancelled проверяется per-bucket.
|
||||||
AvitoBlockedError/AvitoRateLimitedError → mark_banned (status='banned').
|
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())
|
runs.update_heartbeat(db, run_id, counters.to_dict())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with AvitoScraper(config) as scraper:
|
async with AvitoScraper(config, proxy_provider=proxy_provider) as scraper:
|
||||||
scraper.request_delay_sec = request_delay_sec
|
scraper.request_delay_sec = request_delay_sec
|
||||||
|
|
||||||
await scraper.fetch_all_secondary(
|
await scraper.fetch_all_secondary(
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,7 @@ async def _job_avito_full_load(
|
||||||
config=ctx.config,
|
config=ctx.config,
|
||||||
matcher=ctx.matcher,
|
matcher=ctx.matcher,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
|
proxy_provider=ctx.proxy_provider,
|
||||||
price_cap_per_bucket=int(params.get("price_cap_per_bucket", 1400)),
|
price_cap_per_bucket=int(params.get("price_cap_per_bucket", 1400)),
|
||||||
concurrency=int(params.get("concurrency", 5)),
|
concurrency=int(params.get("concurrency", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
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,
|
config=ctx.config,
|
||||||
matcher=ctx.matcher,
|
matcher=ctx.matcher,
|
||||||
shutdown_requested=ctx.shutdown_requested,
|
shutdown_requested=ctx.shutdown_requested,
|
||||||
|
proxy_provider=ctx.proxy_provider,
|
||||||
price_cap_per_bucket=int(params.get("price_cap_per_bucket", 1400)),
|
price_cap_per_bucket=int(params.get("price_cap_per_bucket", 1400)),
|
||||||
concurrency=int(params.get("concurrency", 5)),
|
concurrency=int(params.get("concurrency", 5)),
|
||||||
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
|
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
|
from scraper_kit.providers.avito.shared import _unix_to_date
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scraper_kit.contracts import ScraperConfig
|
from scraper_kit.contracts import ProxyProvider, ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -351,6 +351,7 @@ class AvitoScraper(BaseScraper):
|
||||||
*,
|
*,
|
||||||
delay_provider: Callable[[str], float] | None = None,
|
delay_provider: Callable[[str], float] | None = None,
|
||||||
target_city_slug: str | None = None,
|
target_city_slug: str | None = None,
|
||||||
|
proxy_provider: ProxyProvider | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
# Strangler-инжекция (#2133): конфиг и провайдер задержки приходят снаружи
|
||||||
|
|
@ -360,6 +361,11 @@ class AvitoScraper(BaseScraper):
|
||||||
self._config = config
|
self._config = config
|
||||||
if delay_provider is not None:
|
if delay_provider is not None:
|
||||||
self.request_delay_sec = delay_provider(self.name)
|
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._cffi: AsyncSession | None = None
|
||||||
self._browser: BrowserFetcher | None = None
|
self._browser: BrowserFetcher | None = None
|
||||||
# #823: счётчик карточек, которые не удалось распарсить из-за неожиданной структуры DOM.
|
# #823: счётчик карточек, которые не удалось распарсить из-за неожиданной структуры DOM.
|
||||||
|
|
@ -392,7 +398,9 @@ class AvitoScraper(BaseScraper):
|
||||||
# FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает
|
# FALLBACK — если браузер отдаёт firewall/пусто, _fetch_serp_html делает
|
||||||
# один retry через curl_cffi. _build_cffi_session самодостаточен
|
# один retry через curl_cffi. _build_cffi_session самодостаточен
|
||||||
# (отдельный AsyncSession, не зависит от BaseScraper.__aenter__).
|
# (отдельный 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__()
|
await self._browser.__aenter__()
|
||||||
self._cffi = self._build_cffi_session()
|
self._cffi = self._build_cffi_session()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue