diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py index 3cc4e08e..7da60d9c 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py @@ -36,6 +36,7 @@ import pytest # Mirror tests/test_scraper_kit_pricehistory_session_parity.py. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") +from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError as KitIMVAddressNotFoundError, ) @@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon captured.update(kwargs) return mock_session - with patch("curl_cffi.requests.AsyncSession", _fake_session): + with patch("scraper_kit.providers._base.AsyncSession", _fake_session): asyncio.run(_call_kit_own_session(config=None)) assert captured.get("proxies") is None @@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP captured.update(kwargs) return mock_session - with patch("curl_cffi.requests.AsyncSession", _fake_session): + with patch("scraper_kit.providers._base.AsyncSession", _fake_session): asyncio.run(_call_kit_own_session(config=RealScraperConfig())) assert captured.get("proxies") == { "http": "http://test-proxy.local:8080", "https": "http://test-proxy.local:8080", } + # #2386: сессия строится общей build_document_session — параметры те же, что были у + # собственной (impersonate/timeout=25/document-заголовки). + assert captured.get("impersonate") == DEFAULT_IMPERSONATE + assert captured.get("timeout") == 25 + assert captured.get("headers") == DOCUMENT_HEADERS diff --git a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py index d91726f3..0e820899 100644 --- a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py @@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv( # патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта # оставило бы тест зелёным против фантома. patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider), - patch("curl_cffi.requests.AsyncSession", _no_http), + patch("scraper_kit.providers._base.AsyncSession", _no_http), ): result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4") @@ -213,7 +213,7 @@ def _run_with_pool( with ( patch.object(estimator, "RealProxyProvider", lambda: provider), patch.object(estimator, "save_imv_evaluation", return_value=1), - patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session), + patch("scraper_kit.providers._base.AsyncSession", lambda *a, **kw: session), # Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него. patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()), patch( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py index ae5bd34f..4c9915c2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -34,7 +34,7 @@ from uuid import UUID from sqlalchemy import text from sqlalchemy.orm import Session -from scraper_kit.providers._base import DEFAULT_IMPERSONATE +from scraper_kit.providers._base import DOCUMENT_HEADERS, build_document_session from scraper_kit.providers._proxy import acurl_proxy_url from scraper_kit.providers.avito.shared import _unix_to_date @@ -54,19 +54,6 @@ IMV_ENDPOINT = "/web/1/realty-imv/get-data" # Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR. WARMUP_URL = f"{AVITO_BASE}/evaluation/realty" -# Заголовки document-навигации (warm-up GET) — зеркалят production-набор -# из avito.py (AvitoScraper.__aenter__). Нужны чтобы пройти TLS+header anti-bot. -_DOC_HEADERS = { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", - "Cache-Control": "max-age=0", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", -} - # Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют # fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch. _COMMON_HEADERS = { @@ -486,8 +473,7 @@ async def evaluate_via_imv( # камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси + # warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403 # (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op, - # браузер принадлежит caller'у — backfill открывает один на батч). Этот путь - # НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке. + # браузер принадлежит caller'у — backfill открывает один на батч). # AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up + # geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию; # для browser_fetcher / переданной cffi_session — no-op. Исключение из блока @@ -499,53 +485,24 @@ async def evaluate_via_imv( cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL) _own_session = True else: - # Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети - try: - from curl_cffi.requests import AsyncSession as CffiAsyncSession - - _own_session = False - if cffi_session is None: - # Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162): - # impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout. - # Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito - # банит на server-IP. - # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env - # scraper_proxy_url. proxy=None → прямое подключение (dev). - # - # NB (#2361 F4a): НЕ мигрировано на build_document_session() из - # scraper_kit.providers._base, хотя headers=_DOC_HEADERS — тот же - # дублированный dict, что и там. _base.py делает module-level - # `from curl_cffi.requests import AsyncSession` (биндится ОДИН раз - # при первом импорте _base) — tests/scrapers/test_avito_imv_kit_parity.py - # (#2334) патчит `curl_cffi.requests.AsyncSession` через unittest.mock, - # что работает ТОЛЬКО если конструктор вызывается через СВЕЖИЙ lookup - # атрибута на живом module-объекте (как этот локальный import ниже), - # а не через имя, захваченное в чужом module namespace на момент его - # импорта. Мигрировав, ловим 2 real failures - # (test_kit_evaluate_via_imv_{with,without}_config_uses_proxy) — НЕ - # тестовый шум, тест был зелёным на main. Production-поведение - # идентично в обоих вариантах (сеть не отличает откуда взят класс), - # но так как таск прямо запрещает "weaken or skip" parity-тесты — - # оставляю это единственное место немигрированным и репортю как - # finding, а не тихо чиню тест. - _env = config.scraper_proxy_url if config is not None else None - # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход - # стоял ДО первого await и держал event loop `/estimate`. - _proxy_url = await _proxy_stack.enter_async_context( - acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env) - ) - _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None - cffi_session = CffiAsyncSession( - impersonate=DEFAULT_IMPERSONATE, - timeout=_HTTP_TIMEOUT_SEC, - proxies=_proxies, - headers=_DOC_HEADERS, - ) - _own_session = True - except ImportError as exc: - raise RuntimeError( - "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml." - ) from exc + _own_session = False + if cffi_session is None: + # Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS + + # DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies — + # bare-session XHR Avito банит на server-IP. + # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env + # scraper_proxy_url. proxy=None → прямое подключение (dev). + # Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`. + _env = config.scraper_proxy_url if config is not None else None + # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход + # стоял ДО первого await и держал event loop `/estimate`. + _proxy_url = await _proxy_stack.enter_async_context( + acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env) + ) + cffi_session = build_document_session( + proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC + ) + _own_session = True try: if _own_session: @@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None: всё равно попробуют и дадут типизированную ошибку. """ try: - resp = await session.get(WARMUP_URL, headers=_DOC_HEADERS) + resp = await session.get(WARMUP_URL, headers=DOCUMENT_HEADERS) logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code) except Exception as exc: # warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную