Compare commits
No commits in common. "8c0511e0bdf369fb9e470bd3f2a2a0cdabbbec2f" and "864c551c0d967ba67b2b866319012555b6c2525f" have entirely different histories.
8c0511e0bd
...
864c551c0d
9 changed files with 20 additions and 438 deletions
|
|
@ -198,9 +198,7 @@ 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(
|
scraper_ctx = AvitoScraper(config, delay_provider=get_scraper_delay)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"""#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,15 +1,8 @@
|
||||||
"""Тесты проброса browser-пула в SERP-скраперы avito/yandex/cian (#2160, #2616).
|
"""Тесты проброса browser-пула в SERP-скраперы yandex/cian (#2160).
|
||||||
|
|
||||||
Изначально (#2164 P4) пул раздавался только в avito-путях, а yandex/cian SERP создавали
|
Зеркало P4 (#2164) для avito: там пул раздавался только в avito-путях, а yandex/cian
|
||||||
BrowserFetcher сами в __aenter__ без пула → их трафик шёл через env-прокси браузера —
|
SERP создавали BrowserFetcher сами в __aenter__ без пула → весь трафик шёл через
|
||||||
покрыто ниже (секции Yandex/Cian). #2616 закрывает СИММЕТРИЧНЫЙ пробел: сам Avito SERP
|
env-прокси браузера. Здесь проверяем, что YandexRealtyScraper / CianScraper прокидывают
|
||||||
(`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:
|
||||||
|
|
@ -40,7 +33,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -185,95 +177,3 @@ 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,25 +174,16 @@ def _async_session_cm() -> MagicMock:
|
||||||
return sess
|
return sess
|
||||||
|
|
||||||
|
|
||||||
async def _drive(
|
async def _drive(scenario: _Scenario, *, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||||
scenario: _Scenario,
|
"""capture: опциональный dict — если передан, кладём туда save_mock (#2594) для
|
||||||
*,
|
инспекции call_args (city=...) без изменения возвращаемого _DriveResult (backward-compat
|
||||||
capture: dict[str, Any] | None = None,
|
для всех существующих вызовов _drive без capture)."""
|
||||||
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:
|
||||||
|
|
@ -204,7 +195,7 @@ async def _drive(
|
||||||
|
|
||||||
pfx = "scraper_kit.orchestration.pipeline"
|
pfx = "scraper_kit.orchestration.pipeline"
|
||||||
with (
|
with (
|
||||||
patch(f"{pfx}.AvitoScraper", avito_scraper_cls),
|
patch(f"{pfx}.AvitoScraper", return_value=scraper),
|
||||||
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}),
|
||||||
|
|
@ -224,7 +215,6 @@ async def _drive(
|
||||||
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,
|
||||||
|
|
@ -415,33 +405,3 @@ 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,12 +289,8 @@ async def test_domclick_city_sweep_blocked_failed() -> None:
|
||||||
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
|
# ── Avito newbuilding sweep ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
async def _drive_nb_sweep(
|
async def _drive_nb_sweep(*, capture: dict[str, Any] | None = None) -> _DriveResult:
|
||||||
*, capture: dict[str, Any] | None = None, proxy_provider: Any = None
|
"""capture: опционально — если передан, кладём save_mock (#2594, инспекция city=...)."""
|
||||||
) -> _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)]
|
||||||
|
|
@ -303,25 +299,17 @@ async def _drive_nb_sweep(
|
||||||
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", avito_scraper_cls),
|
patch(f"{PFX}.AvitoScraper", return_value=scraper),
|
||||||
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,
|
db, config=cfg, matcher=MagicMock(), run_id=1, pages=2, request_delay_sec=0.0
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -336,23 +324,6 @@ 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) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -419,66 +390,6 @@ 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,8 +35,6 @@ 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,
|
||||||
|
|
@ -519,56 +517,6 @@ 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,13 +552,7 @@ async def run_avito_pipeline(
|
||||||
own_session = False
|
own_session = False
|
||||||
own_browser = False
|
own_browser = False
|
||||||
|
|
||||||
# proxy_provider прокинут в конструктор для консистентности с cian/yandex (#2616);
|
scraper = AvitoScraper(config)
|
||||||
# НЕ 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:
|
||||||
|
|
@ -1115,12 +1109,7 @@ 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 → ЕКБ.
|
||||||
# proxy_provider прокинут для консистентности (#2616) — не load-bearing,
|
scraper = AvitoScraper(config, target_city_slug=_avito_slug)
|
||||||
# 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 на
|
||||||
|
|
@ -1762,10 +1751,7 @@ 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
|
||||||
|
|
||||||
# proxy_provider прокинут для консистентности (#2616) — не load-bearing,
|
scraper = AvitoScraper(config)
|
||||||
# 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
|
||||||
|
|
@ -3378,7 +3364,6 @@ 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,
|
||||||
|
|
@ -3396,12 +3381,6 @@ 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').
|
||||||
"""
|
"""
|
||||||
|
|
@ -3487,7 +3466,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, proxy_provider=proxy_provider) as scraper:
|
async with AvitoScraper(config) 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,7 +492,6 @@ 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)),
|
||||||
|
|
@ -512,7 +511,6 @@ 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 ProxyProvider, ScraperConfig
|
from scraper_kit.contracts import ScraperConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -351,7 +351,6 @@ 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): конфиг и провайдер задержки приходят снаружи
|
||||||
|
|
@ -361,11 +360,6 @@ 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.
|
||||||
|
|
@ -398,9 +392,7 @@ 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._browser = build_browser_fetcher(self._config, "avito")
|
||||||
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