refactor(tradein/scraper-kit): cian providers on shared _base.py (#2362)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 15s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m41s

Мигрирует call-site'ы cian/{serp,detail,newbuilding,session}.py на shared
фабрики Foundation-модуля (#2358): build_browser_fetcher() (serp.py __aenter__)
и build_curl_cffi_session() (detail.py own-session path, newbuilding.py
resolve_cian_zhk_url[_via_search], session.py verify_session). Headers/timeout/
proxy-resolution сохранены байт-в-байт — чистый call-site swap, без изменения
поведения.

Caveat (config Optional footgun): newbuilding.py::fetch_newbuilding() НЕ
переведена на build_browser_fetcher() — build_browser_fetcher() требует
ScraperConfig mandatory (весь смысл фабрики, закрывает класс багов #2322/#2330),
но здесь config: ScraperConfig | None = None легитимно Optional (admin.py
debug-роут и test_scraper_kit_newbuilding_endpoint.py вызывают функцию без
config= и ожидают graceful endpoint=None). Подмена уронила бы AttributeError
на config=None вместо текущего degrade — реальная регрессия, не call-site swap.
Оставлено как есть с комментарием в коде.

Companion test updates (не ослабление, retarget под рефактор): CianScraper
теперь строит BrowserFetcher внутри _base.py, а не в serp.py — два теста в
test_kit_serp_proxy_pool.py, патчившие cian_serp.BrowserFetcher напрямую,
переключены на scraper_kit.providers._base.BrowserFetcher (тот же инвариант,
другая точка патча). Аналогично test_scraper_kit_pricehistory_session_parity.py
::test_fetch_detail_own_session_proxy_wiring_parity — патч AsyncSession
переключён с cian.detail на _base (тот же assert на итоговый proxies=dict).

Tests: full backend suite 3278 passed, 1 pre-existing flake
(test_search_api.py::test_search_cache_hit, #2208), 6 skipped. Все
parity/regression тесты (test_scraper_kit_pipeline_parity2.py,
test_scraper_kit_newbuilding_endpoint.py, test_scraper_kit_cian_golden_parity.py,
test_kit_serp_proxy_pool.py, test_scraper_kit_pricehistory_session_parity.py,
test_cian_serp_scraper.py, test_cian_session.py, test_cian_detail.py) зелёные.
ruff check + format --check чисто на всех изменённых файлах.
This commit is contained in:
bot-backend 2026-07-04 09:25:11 +03:00
parent 53e7834f19
commit 96dd27c4ae
6 changed files with 50 additions and 31 deletions

View file

@ -10,8 +10,14 @@ proxy_provider + use_pool=config.use_proxy_pool_browser в BrowserFetcher.
т.е. env-прокси браузера, поведение байт-в-байт как до фикса.
- proxy_provider задан + флаг on BrowserFetcher получает провайдер + use_pool=True.
BrowserFetcher монки-патчится на recording-заглушку в модуле каждого скрапера, так что
реального HTTP к tradein-browser не происходит.
BrowserFetcher монки-патчится на recording-заглушку, так что реального HTTP к
tradein-browser не происходит. Yandex SERP всё ещё конструирует BrowserFetcher
напрямую в своём модуле патчим `yandex_serp.BrowserFetcher`. Cian SERP
(#2362, F4b) переведён на общую фабрику `scraper_kit.providers._base.
build_browser_fetcher()` конструктор теперь вызывается ИЗ _base.py, поэтому
патчим `BrowserFetcher` там, а не в `cian_serp` (там больше нет своей ссылки на
класс). Проверяемый инвариант (какие kwargs долетают до BrowserFetcher)
не меняется меняется только точка патча, отражающая реальное место вызова.
"""
from __future__ import annotations
@ -20,6 +26,7 @@ import types
from typing import Any
import pytest
from scraper_kit.providers import _base as kit_base
from scraper_kit.providers.cian import serp as cian_serp
from scraper_kit.providers.yandex import serp as yandex_serp
@ -136,7 +143,9 @@ async def test_yandex_serp_provider_without_flag_stays_dark(
async def test_cian_serp_wires_pool_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
"""proxy_provider + флаг on → BrowserFetcher получает провайдер + use_pool=True."""
rec_fetcher, calls = _make_recorder()
monkeypatch.setattr(cian_serp, "BrowserFetcher", rec_fetcher)
# #2362: CianScraper.__aenter__ теперь строит BrowserFetcher через
# _base.build_browser_fetcher() — патчим класс там, не в cian_serp.
monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher)
provider = _FakeProxyProvider()
scraper = cian_serp.CianScraper(_config(use_pool=True), proxy_provider=provider)
@ -153,7 +162,7 @@ async def test_cian_serp_wires_pool_when_flag_on(monkeypatch: pytest.MonkeyPatch
async def test_cian_serp_no_pool_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""Без provider/флага → BrowserFetcher(proxy_provider=None, use_pool=False) — ship-dark."""
rec_fetcher, calls = _make_recorder()
monkeypatch.setattr(cian_serp, "BrowserFetcher", rec_fetcher)
monkeypatch.setattr(kit_base, "BrowserFetcher", rec_fetcher)
scraper = cian_serp.CianScraper(_config(use_pool=False))
async with scraper:

View file

@ -178,6 +178,12 @@ def test_fetch_detail_own_session_proxy_wiring_parity(monkeypatch: Any) -> None:
real behaviour regression risk (#806: datacenter-IP gets banned by Cian without a
proxy). This proves config=RealScraperConfig() (our actual call site) constructs
AsyncSession(proxies=...) identically to the legacy direct-settings path.
#2362 (F4b): kit's own-session branch now builds the session via
`scraper_kit.providers._base.build_curl_cffi_session()` instead of constructing
`AsyncSession` directly in `detail.py` so the spy patches `AsyncSession` where
the constructor call now actually happens (`_base`), not in `detail` anymore.
The asserted kwargs (proxies=...) are unchanged.
"""
monkeypatch.setattr(settings, "cian_proxy_url_env", "http://test-proxy.local:8080")
@ -204,7 +210,7 @@ def test_fetch_detail_own_session_proxy_wiring_parity(monkeypatch: Any) -> None:
with patch("app.services.scrapers.cian_detail.AsyncSession", _make_fake_session("legacy")):
asyncio.run(legacy_fetch_detail(url))
with patch("scraper_kit.providers.cian.detail.AsyncSession", _make_fake_session("kit")):
with patch("scraper_kit.providers._base.AsyncSession", _make_fake_session("kit")):
asyncio.run(kit_fetch_detail(url, config=RealScraperConfig()))
assert captured["legacy"].get("proxies") == captured["kit"].get("proxies")

View file

@ -17,11 +17,11 @@ import logging
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from curl_cffi.requests import AsyncSession
from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.providers._base import build_curl_cffi_session
from scraper_kit.providers._proxy import curl_proxy_url
from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text,
@ -30,6 +30,8 @@ from scraper_kit.repair_state_normalizer import (
from scraper_kit.snapshot_writer import upsert_listing_snapshot
if TYPE_CHECKING:
from curl_cffi.requests import AsyncSession
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ProxyProvider, ScraperConfig
@ -115,11 +117,9 @@ async def fetch_detail(
# Пусто → прямое подключение (dev/no-op). curl_proxy_url: mark_health + release на выходе.
_env = config.cian_proxy_url if config is not None else None
with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
own_session = AsyncSession(
impersonate="chrome120",
own_session = build_curl_cffi_session(
proxy_url=_proxy_url,
timeout=25.0,
proxies=_proxies,
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",

View file

@ -22,12 +22,13 @@ import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.cian_state_parser import extract_all_states, extract_state
from scraper_kit.providers._base import build_curl_cffi_session
if TYPE_CHECKING:
from curl_cffi.requests import AsyncSession # type: ignore[import-untyped]
from scraper_kit.contracts import ScraperConfig
logger = logging.getLogger(__name__)
@ -143,6 +144,15 @@ async def fetch_newbuilding(
Returns: NewbuildingEnrichment, or None если fetch / parse failed.
"""
# #2362 (F4b): НЕ переведено на _base.build_browser_fetcher() намеренно.
# build_browser_fetcher() требует ScraperConfig mandatory (без default) — это
# весь смысл фабрики (footgun-класс #2322/#2330). Но `config` здесь ЗАКОННО
# Optional: admin.py debug-роут и test_scraper_kit_newbuilding_endpoint.py
# (test_cian_fetch_newbuilding_endpoint_none_without_config) вызывают
# fetch_newbuilding() БЕЗ config= и ожидают graceful endpoint=None (не крash).
# Подмена на build_browser_fetcher(config, ...) уронила бы AttributeError на
# config=None вместо текущего degrade-to-None — реальная регрессия поведения,
# а не чистый call-site swap. Оставлено как есть; см. PR #2362 caveat notes.
endpoint = config.browser_http_endpoint if config is not None else None
async with BrowserFetcher(source="cian", endpoint=endpoint) as browser:
html = await browser.fetch(zhk_url)
@ -651,11 +661,9 @@ async def resolve_cian_zhk_url(
if session is None:
# Mobile proxy wiring (#806 follow-up): resolve ЖК URL через мобильный прокси.
_proxy_url = config.cian_proxy_url if config is not None else None
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
session = AsyncSession(
impersonate="chrome120",
session = build_curl_cffi_session(
proxy_url=_proxy_url,
timeout=15.0,
proxies=_proxies,
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",
@ -757,11 +765,9 @@ async def resolve_cian_zhk_url_via_search(
# Mobile proxy wiring (#806): Cian блокирует datacenter-IP. proxy=None → прямое
# подключение (dev / proxy-down fallback — single fetches survive direct).
_proxy_url = config.cian_proxy_url if config is not None else None
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
session = AsyncSession(
impersonate="chrome120",
session = build_curl_cffi_session(
proxy_url=_proxy_url,
timeout=30.0,
proxies=_proxies,
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",

View file

@ -35,17 +35,18 @@ from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
from scraper_kit.base import BaseScraper, ScrapedLot
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.cian_state_parser import extract_state
from scraper_kit.house_type_normalizer import normalize_house_type
from scraper_kit.price_brackets import get_price_seed_brackets
from scraper_kit.pricing import BisectionConfig, ProbeFailPolicy, ProbeResult, walk_price_range
from scraper_kit.providers._base import build_browser_fetcher
from scraper_kit.repair_state_normalizer import (
infer_repair_state_from_text,
normalize_repair_state,
)
if TYPE_CHECKING:
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.contracts import ProxyProvider, ScraperConfig
logger = logging.getLogger(__name__)
@ -139,11 +140,10 @@ class CianScraper(BaseScraper):
# fingerprint через tradein-browser сервис (#1806). Сервер роутит /fetch на
# отдельный браузер+мобильный прокси для cian. Прокси ротирует IP сам, поэтому
# code-side warm-up cookies и changeip-ротация больше не нужны.
self._browser = BrowserFetcher(
source="cian",
endpoint=self._config.browser_http_endpoint,
self._browser = build_browser_fetcher(
self._config,
"cian",
proxy_provider=self._proxy_provider,
use_pool=self._config.use_proxy_pool_browser,
)
await self._browser.__aenter__()
return self

View file

@ -10,11 +10,11 @@ import json
import logging
from typing import TYPE_CHECKING, Any
from curl_cffi.requests import AsyncSession
from sqlalchemy import text
from sqlalchemy.orm import Session
from scraper_kit.cian_state_parser import extract_state
from scraper_kit.providers._base import build_curl_cffi_session
if TYPE_CHECKING:
from scraper_kit.contracts import ScraperConfig
@ -119,12 +119,10 @@ async def verify_session(
# при валидных DMIR_AUTH cookies. Без прокси verify всегда вернёт 403.
# Пусто (env не задан) → прямое подключение (dev/no-op).
_proxy_url = config.cian_proxy_url if config is not None else None
_proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None
async with AsyncSession(
impersonate="chrome120",
cookies=cookies,
async with build_curl_cffi_session(
proxy_url=_proxy_url,
timeout=20.0,
proxies=_proxies,
cookies=cookies,
) as session:
resp = await session.get(_VALUATION_TEST_URL, allow_redirects=True)
status = resp.status_code