refactor(tradein): IMV Авито строит curl-сессию общей фабрикой, а не своей копией (#2386)
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m55s
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
All checks were successful
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 5m55s
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
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 <noreply@anthropic.com>
This commit is contained in:
parent
eade88bc66
commit
808a4fb4cb
3 changed files with 31 additions and 68 deletions
|
|
@ -36,6 +36,7 @@ import pytest
|
||||||
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
|
||||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
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 (
|
from scraper_kit.providers.avito.imv import (
|
||||||
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
IMVAddressNotFoundError as KitIMVAddressNotFoundError,
|
||||||
)
|
)
|
||||||
|
|
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
|
||||||
captured.update(kwargs)
|
captured.update(kwargs)
|
||||||
return mock_session
|
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))
|
asyncio.run(_call_kit_own_session(config=None))
|
||||||
|
|
||||||
assert captured.get("proxies") is 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)
|
captured.update(kwargs)
|
||||||
return mock_session
|
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()))
|
asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
|
||||||
|
|
||||||
assert captured.get("proxies") == {
|
assert captured.get("proxies") == {
|
||||||
"http": "http://test-proxy.local:8080",
|
"http": "http://test-proxy.local:8080",
|
||||||
"https": "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
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv(
|
||||||
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
|
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
|
||||||
# оставило бы тест зелёным против фантома.
|
# оставило бы тест зелёным против фантома.
|
||||||
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider),
|
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")
|
result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
|
||||||
|
|
||||||
|
|
@ -213,7 +213,7 @@ def _run_with_pool(
|
||||||
with (
|
with (
|
||||||
patch.object(estimator, "RealProxyProvider", lambda: provider),
|
patch.object(estimator, "RealProxyProvider", lambda: provider),
|
||||||
patch.object(estimator, "save_imv_evaluation", return_value=1),
|
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 вокруг него.
|
# Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
|
||||||
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
|
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
|
||||||
patch(
|
patch(
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ from uuid import UUID
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
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._proxy import acurl_proxy_url
|
||||||
from scraper_kit.providers.avito.shared import _unix_to_date
|
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.
|
# Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR.
|
||||||
WARMUP_URL = f"{AVITO_BASE}/evaluation/realty"
|
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). Имитируют
|
# Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют
|
||||||
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
|
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
|
||||||
_COMMON_HEADERS = {
|
_COMMON_HEADERS = {
|
||||||
|
|
@ -486,8 +473,7 @@ async def evaluate_via_imv(
|
||||||
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
|
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
|
||||||
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
|
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
|
||||||
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
|
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
|
||||||
# браузер принадлежит caller'у — backfill открывает один на батч). Этот путь
|
# браузер принадлежит caller'у — backfill открывает один на батч).
|
||||||
# НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке.
|
|
||||||
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
|
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
|
||||||
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
|
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
|
||||||
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
|
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
|
||||||
|
|
@ -499,53 +485,24 @@ async def evaluate_via_imv(
|
||||||
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
|
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
|
||||||
_own_session = True
|
_own_session = True
|
||||||
else:
|
else:
|
||||||
# Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети
|
|
||||||
try:
|
|
||||||
from curl_cffi.requests import AsyncSession as CffiAsyncSession
|
|
||||||
|
|
||||||
_own_session = False
|
_own_session = False
|
||||||
if cffi_session is None:
|
if cffi_session is None:
|
||||||
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162):
|
# Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS +
|
||||||
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout.
|
# DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies —
|
||||||
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito
|
# bare-session XHR Avito банит на server-IP.
|
||||||
# банит на server-IP.
|
|
||||||
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
|
||||||
# scraper_proxy_url. proxy=None → прямое подключение (dev).
|
# scraper_proxy_url. proxy=None → прямое подключение (dev).
|
||||||
#
|
# Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`.
|
||||||
# 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
|
_env = config.scraper_proxy_url if config is not None else None
|
||||||
# acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
|
# acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
|
||||||
# стоял ДО первого await и держал event loop `/estimate`.
|
# стоял ДО первого await и держал event loop `/estimate`.
|
||||||
_proxy_url = await _proxy_stack.enter_async_context(
|
_proxy_url = await _proxy_stack.enter_async_context(
|
||||||
acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
|
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 = build_document_session(
|
||||||
cffi_session = CffiAsyncSession(
|
proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
|
||||||
impersonate=DEFAULT_IMPERSONATE,
|
|
||||||
timeout=_HTTP_TIMEOUT_SEC,
|
|
||||||
proxies=_proxies,
|
|
||||||
headers=_DOC_HEADERS,
|
|
||||||
)
|
)
|
||||||
_own_session = True
|
_own_session = True
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError(
|
|
||||||
"curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if _own_session:
|
if _own_session:
|
||||||
|
|
@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None:
|
||||||
всё равно попробуют и дадут типизированную ошибку.
|
всё равно попробуют и дадут типизированную ошибку.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную
|
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue