From 808a4fb4cbb3d9e7208c90362b75cc2c0057080f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:57:29 +0500 Subject: [PATCH] =?UTF-8?q?refactor(tradein):=20IMV=20=D0=90=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=20=D1=81=D1=82=D1=80=D0=BE=D0=B8=D1=82=20curl-?= =?UTF-8?q?=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8E=20=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=84=D0=B0=D0=B1=D1=80=D0=B8=D0=BA=D0=BE=D0=B9,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=B8=D0=B5=D0=B9=20(#2386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avito/imv.py сам собирал curl_cffi AsyncSession с третьей копией document-заголовков (_DOC_HEADERS) — из-за того, что parity-тесты патчили curl_cffi.requests.AsyncSession, а _base импортирует класс на уровне модуля и такой патч до него не долетает. Взят вариант B из issue: в кодовой базе уже принят патч scraper_kit.providers._base.AsyncSession (test_scraper_proxy.py, test_pipeline_browser_routing.py, test_kit_serp_proxy_pool.py), а вариант A (живой lookup в _base) сломал бы эти тесты — атрибута _base.AsyncSession не стало бы. Сессия теперь build_document_session(proxy_url, timeout=25); _DOC_HEADERS удалён (идентичен DOCUMENT_HEADERS, проверено до правки), мёртвый try/except ImportError убран — curl_cffi и так импортируется через _base. Тесты патчат _base.AsyncSession; ассерт с config дополнительно фиксирует impersonate, timeout=25 и заголовки — параметры сессии не изменились. Co-Authored-By: Claude Opus 5 --- .../scrapers/test_avito_imv_kit_parity.py | 10 ++- .../test_3386_estimator_imv_proxy_pool.py | 4 +- .../src/scraper_kit/providers/avito/imv.py | 85 +++++-------------- 3 files changed, 31 insertions(+), 68 deletions(-) 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 опционален: логируем и продолжаем — шаги ниже дадут типизированную