From 9b4cdf8ec6d9bb166b7b2dc81f40734f106e8dc3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 03:39:43 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(tradein/scraper-kit):=20shared=20base/?= =?UTF-8?q?utils=20=D0=B4=D0=BB=D1=8F=20providers/*=20(#2358,=20Foundation?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аддитивный Foundation-модуль (эпик #2277 Group F4): scraper_kit/providers/_base.py извлекает 3 реально повторяющихся паттерна, найденных чтением avito/cian/yandex/ domclick providers (~13k строк): - build_browser_fetcher(config: ScraperConfig, source, ...) — config MANDATORY, устраняет footgun-класс #2322/#2330 (BrowserFetcher конструировался вручную в 6+ местах, endpoint/proxy-pool wiring легко забыть/потерять). - build_curl_cffi_session / build_document_session + http_proxies() — общий curl_cffi AsyncSession(impersonate=chrome120, proxies=..., headers=...) boilerplate, дублированный в avito/serp.py, avito/detail.py, avito/imv.py, cian/detail.py, cian/newbuilding.py (x2), cian/session.py. - DOCUMENT_HEADERS — идентичный "document" headers dict, дублированный трижды. Retry/backoff-циклы (yandex tarpit-retry, avito page-retry, tenacity в BaseScraper) сознательно НЕ обобщены — разная семантика ошибок/действий между попытками, обобщение дало бы leaky abstraction (см. docstring модуля). Existing providers/{avito,cian,yandex,domclick}/*.py НЕ тронуты — миграция call-site'ов на новый модуль это отдельные future issues (F4a-d). Заодно: актуализирован устаревший докстринг app/services/scraper_adapters.py ("Пока НИКТО их не использует") — адаптеры wired в прод с #2192 через app.scheduler_main._run_kit_scheduler. --- .../backend/app/services/scraper_adapters.py | 10 +- .../tests/test_scraper_kit_providers_base.py | 168 +++++++++++++++ .../src/scraper_kit/providers/_base.py | 203 ++++++++++++++++++ 3 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 tradein-mvp/backend/tests/test_scraper_kit_providers_base.py create mode 100644 tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py index 32fc9755..2d2d9b4a 100644 --- a/tradein-mvp/backend/app/services/scraper_adapters.py +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -9,9 +9,13 @@ RealSessionFactory → SessionFactory RealEnrichmentJobs → EnrichmentJobs -Пока НИКТО их не использует — скрапперы всё ещё на прямых импортах. Это подготовка: -переключение вызовов на инжекцию — отдельный шаг (C). Держим сигнатуры в точном -соответствии с исходными функциями, чтобы шаг C состыковался без правок контрактов. +Wired в прод с #2192: `app.scheduler_main._run_kit_scheduler` конструирует +`SchedulerContext` из этих адаптеров (`RealScraperConfig`/`RealMatcherAdapter`/ +`RealEnrichmentJobs`/`RealSessionFactory`/`RealProxyProvider`) и передаёт в +`scraper_kit.orchestration.scheduler.scheduler_loop` — kit-native sweep-registry +крутится через эту инжекцию, а не через прямые импорты `app.*`. Держим сигнатуры +в точном соответствии с исходными функциями, чтобы Protocol'ы `scraper_kit.contracts` +не расходились с боевой реализацией. """ from __future__ import annotations diff --git a/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py new file mode 100644 index 00000000..532090ba --- /dev/null +++ b/tradein-mvp/backend/tests/test_scraper_kit_providers_base.py @@ -0,0 +1,168 @@ +"""Тесты shared-фундамента `scraper_kit.providers._base` (#2358, Foundation). + +Покрывает: + - `http_proxies()` — url → dict / None passthrough. + - `build_curl_cffi_session()` / `build_document_session()` — корректные kwargs + в curl_cffi `AsyncSession` (impersonate/timeout/proxies/headers/cookies). + - `build_browser_fetcher()` — `config` MANDATORY (нет default в сигнатуре); + endpoint/use_pool/proxy_provider корректно прокинуты; `fetch_timeout_s` + опционален (дефолт `BrowserFetcher`, если не передан явно). + +Изолированные — никакой интеграции с existing providers/avito|cian|yandex|domclick. +""" + +from __future__ import annotations + +import inspect +from typing import Any + +from scraper_kit.browser_fetcher import BrowserFetcher +from scraper_kit.providers._base import ( + DEFAULT_IMPERSONATE, + DOCUMENT_HEADERS, + build_browser_fetcher, + build_curl_cffi_session, + build_document_session, + http_proxies, +) + + +class _FakeConfig: + """Минимальная ScraperConfig-заглушка — только поля, читаемые build_browser_fetcher.""" + + def __init__(self, endpoint: str, use_pool: bool = False) -> None: + self.browser_http_endpoint = endpoint + self.use_proxy_pool_browser = use_pool + + +class _FakeProxyProvider: + """ProxyProvider-заглушка — сюда достаточно структурного соответствия, вызовы не нужны.""" + + def acquire(self, provider: str) -> Any: + raise NotImplementedError + + def release(self, lease: Any) -> None: + raise NotImplementedError + + def mark_health(self, lease: Any, ok: bool, **_: Any) -> None: + raise NotImplementedError + + +# ── http_proxies ────────────────────────────────────────────────────────────── + + +def test_http_proxies_none_passthrough() -> None: + assert http_proxies(None) is None + + +def test_http_proxies_empty_string_is_none() -> None: + assert http_proxies("") is None + + +def test_http_proxies_builds_dict_for_both_schemes() -> None: + result = http_proxies("http://user:pass@proxy:8080") + assert result == { + "http": "http://user:pass@proxy:8080", + "https": "http://user:pass@proxy:8080", + } + + +# ── build_curl_cffi_session / build_document_session ────────────────────────── + + +async def test_build_curl_cffi_session_wires_impersonate_timeout_proxies() -> None: + session = build_curl_cffi_session(proxy_url="http://proxy:8080", timeout=15.0) + try: + assert session.impersonate == DEFAULT_IMPERSONATE + assert session.timeout == 15.0 + assert session.proxies == {"http": "http://proxy:8080", "https": "http://proxy:8080"} + finally: + await session.close() + + +async def test_build_curl_cffi_session_no_proxy_direct_connection() -> None: + session = build_curl_cffi_session(proxy_url=None) + try: + # curl_cffi нормализует proxies=None в {} (falsy) — прямое подключение. + assert not session.proxies + finally: + await session.close() + + +async def test_build_curl_cffi_session_passes_headers_and_cookies() -> None: + session = build_curl_cffi_session( + proxy_url=None, headers={"X-Test": "1"}, cookies={"sid": "abc"} + ) + try: + assert dict(session.headers).get("x-test") == "1" + assert session.cookies.get("sid") == "abc" + finally: + await session.close() + + +async def test_build_document_session_uses_document_headers() -> None: + session = build_document_session(proxy_url=None) + try: + headers = dict(session.headers) + for key, value in DOCUMENT_HEADERS.items(): + assert headers.get(key.lower()) == value + finally: + await session.close() + + +async def test_build_document_session_custom_timeout_and_proxy() -> None: + session = build_document_session(proxy_url="http://proxy:8080", timeout=10.0) + try: + assert session.timeout == 10.0 + assert session.proxies == {"http": "http://proxy:8080", "https": "http://proxy:8080"} + finally: + await session.close() + + +# ── build_browser_fetcher ────────────────────────────────────────────────────── + + +def test_build_browser_fetcher_config_is_mandatory_in_signature() -> None: + """config — параметр БЕЗ default в сигнатуре: пропустить его невозможно (footgun-guard).""" + sig = inspect.signature(build_browser_fetcher) + config_param = sig.parameters["config"] + assert config_param.default is inspect.Parameter.empty + + +def test_build_browser_fetcher_wires_endpoint_and_use_pool() -> None: + config = _FakeConfig(endpoint="http://browser:3000", use_pool=True) + provider = _FakeProxyProvider() + + fetcher = build_browser_fetcher(config, "avito", proxy_provider=provider) # type: ignore[arg-type] + + assert isinstance(fetcher, BrowserFetcher) + assert fetcher._endpoint == "http://browser:3000" + assert fetcher._use_pool is True + assert fetcher._proxy_provider is provider + assert fetcher._source == "avito" + + +def test_build_browser_fetcher_use_pool_false_by_default_config() -> None: + config = _FakeConfig(endpoint="http://browser:3000", use_pool=False) + + fetcher = build_browser_fetcher(config, "cian") # type: ignore[arg-type] + + assert fetcher._use_pool is False + assert fetcher._proxy_provider is None + + +def test_build_browser_fetcher_default_timeout_when_not_specified() -> None: + config = _FakeConfig(endpoint="http://browser:3000") + + fetcher = build_browser_fetcher(config, "yandex") # type: ignore[arg-type] + + # Дефолт BrowserFetcher (_HTTP_TIMEOUT_S = 120.0) — не переопределён. + assert fetcher._fetch_timeout_s == 120.0 + + +def test_build_browser_fetcher_explicit_timeout_override() -> None: + config = _FakeConfig(endpoint="http://browser:3000") + + fetcher = build_browser_fetcher(config, "yandex", fetch_timeout_s=30.0) # type: ignore[arg-type] + + assert fetcher._fetch_timeout_s == 30.0 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py new file mode 100644 index 00000000..bcc3354c --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -0,0 +1,203 @@ +"""Общий фундамент для `providers/{avito,cian,yandex,domclick}/*` (#2358, Foundation). + +Извлечено по факту чтения ~13k строк providers/* (issue #2358, эпик #2277 F4). +Три РЕАЛЬНО повторяющихся паттерна: + +1. **`BrowserFetcher(...)` конструировался независимо в 6+ местах** + (`avito/serp.py:389`, `cian/serp.py:142`, `cian/newbuilding.py:147`, + `yandex/serp.py:510`, `yandex/newbuilding.py:189,354`, `domclick/serp.py:319`), + каждый раз вручную собирая `endpoint=config.browser_http_endpoint` + + `proxy_provider=`/`use_pool=config.use_proxy_pool_browser`. Это ИМЕННО источник + class-бага #2322/#2330: `endpoint=`/`config=` — либо optional и молча теряет + поведение (proxy pool не подключается), либо забыт целиком (`TypeError` на + вызове, как было в `yandex/cian.newbuilding` до #2322). `build_browser_fetcher()` + принимает `config: ScraperConfig` **mandatory** (без default) — пропустить + endpoint/use_pool технически невозможно, баг-класс закрыт структурно, а не + точечным патчем. + +2. **curl_cffi `AsyncSession(impersonate="chrome120", proxies=..., headers=...)` + дублировался почти дословно** в `avito/serp.py::_build_cffi_session` (404-428), + `avito/detail.py::_build_detail_session` (256-283, ИДЕНТИЧНЫЙ headers-dict), + `avito/imv.py` (headers=_DOC_HEADERS, тот же dict третий раз), `cian/detail.py` + (115-127), `cian/newbuilding.py` (дважды: 651-663 и 757-769), `cian/session.py` + (117-128). Общий кусок вынесен в `build_curl_cffi_session` / + `build_document_session`; идиома `{"http": url, "https": url} if url else None` + (тот же 6+ раз) — в `http_proxies()`. + +3. **"Document" browser-headers** (`Accept`/`Accept-Language`/`Cache-Control`/ + `Sec-Fetch-*`/`Upgrade-Insecure-Requests`) — идентичный dict в `avito/serp.py`, + `avito/detail.py`, `avito/imv.py` (там уже был назван `_DOC_HEADERS`). Вынесен + в `DOCUMENT_HEADERS` — единая точка правды. + +**Осознанно НЕ извлечено (см. issue #2358 отчёт):** retry/backoff-циклы +(`yandex/serp.py::fetch_around` tarpit-retry — rotate_ip+sleep(2) на status==0/ +JSON-error; `avito/serp.py::_fetch_serp_html_with_retry` — page-level backoff +на generic Exception, кроме `AvitoBlockedError`/`AvitoRateLimitedError`; +`BaseScraper._http_get` — tenacity-декоратор). Все три семантически РАЗНЫЕ: +разные множества исключений, разное действие между попытками (IP-ротация vs +голый sleep vs tenacity), разная политика "что не ретраить". Общий helper +проглотил бы эти различия (leaky abstraction) — риск больше выгоды при текущей +похожести. Кандидат на пересмотр, если появится 4-й похожий цикл. + +**Scope:** этот модуль — НОВЫЙ, аддитивный. Existing `providers/{avito,cian, +yandex,domclick}/*.py` НЕ переведены на него (миграция call-site'ов — отдельные +future issues F4a-d). Здесь только новый shared-слой + тесты, 0 изменений в +провайдерах. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from curl_cffi.requests import AsyncSession + +from scraper_kit.browser_fetcher import BrowserFetcher + +if TYPE_CHECKING: + from scraper_kit.contracts import ProxyProvider, ScraperConfig + +logger = logging.getLogger(__name__) + +# curl_cffi impersonate-профиль, используемый ВСЕМИ providers без исключения +# (TLS ClientHello настоящего Chrome — обход fingerprint-детекта). +DEFAULT_IMPERSONATE = "chrome120" + +# "Document"-style browser headers для curl_cffi GET верхнеуровневых HTML-страниц +# (SERP/detail — НЕ XHR/JSON API-запросы, для них headers не нужны/другие). +# Идентичен ранее продублированному `_DOC_HEADERS` (avito/imv.py) и inline-dict +# в avito/serp.py::_build_cffi_session / avito/detail.py::_build_detail_session. +DOCUMENT_HEADERS: dict[str, str] = { + "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", +} + + +def http_proxies(proxy_url: str | None) -> dict[str, str] | None: + """curl_cffi/httpx-совместимый `proxies=` dict из одного url. + + `None`/пустая строка → `None` (прямое подключение — без прокси). + + Извлечено из идентичной идиомы `{"http": url, "https": url} if url else None`, + продублированной в avito/serp.py, avito/detail.py, avito/imv.py, cian/detail.py, + cian/newbuilding.py (дважды), cian/session.py. + """ + return {"http": proxy_url, "https": proxy_url} if proxy_url else None + + +def build_curl_cffi_session( + *, + proxy_url: str | None, + timeout: float = 25.0, + headers: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + impersonate: str = DEFAULT_IMPERSONATE, +) -> AsyncSession: + """Собрать curl_cffi `AsyncSession` с TLS-fingerprint impersonation. + + `proxy_url` — уже РЕЗОЛВЛЕННЫЙ url, не `ScraperConfig`. Разные providers + достают proxy-url по-разному (`config.scraper_proxy_url` для avito, + `config.cian_proxy_url` для cian, pool-aware `providers._proxy.curl_proxy_url()` + контекст-менеджер для yandex/#2163) — эта функция намеренно НЕ решает, откуда + взять url, только собирает сессию из уже готового значения. Заставлять её + принимать `ScraperConfig` было бы overengineering: пришлось бы либо гадать имя + атрибута по provider-строке (магия), либо всё равно требовать явный proxy_url + параметр — тогда мандатори-`config` ничего не даёт (в отличие от + `build_browser_fetcher`, где `ScraperConfig` содержит ОДНОЗНАЧНЫЙ единый + `browser_http_endpoint`/`use_proxy_pool_browser` независимо от provider). + + `headers=None` → curl_cffi дефолтные заголовки (caller решает нужны ли + document-style headers; см. `build_document_session` для явного варианта). + """ + return AsyncSession( + impersonate=impersonate, + timeout=timeout, + proxies=http_proxies(proxy_url), + headers=headers, + cookies=cookies, + ) + + +def build_document_session( + *, + proxy_url: str | None, + timeout: float = 25.0, + impersonate: str = DEFAULT_IMPERSONATE, +) -> AsyncSession: + """`build_curl_cffi_session` с `DOCUMENT_HEADERS` — document-like GET (SERP/detail HTML). + + Это ровно та форма, что дублирована в `avito/serp.py::_build_cffi_session` и + `avito/detail.py::_build_detail_session` (identical headers, timeout=25, + impersonate=chrome120) — единственная разница между сайтами вызова была + источник `proxy_url` (`config.scraper_proxy_url` в обоих случаях, но + прокидывалось разными путями). + """ + return build_curl_cffi_session( + proxy_url=proxy_url, + timeout=timeout, + headers=DOCUMENT_HEADERS, + impersonate=impersonate, + ) + + +def build_browser_fetcher( + config: ScraperConfig, + source: str, + *, + proxy_provider: ProxyProvider | None = None, + fetch_timeout_s: float | None = None, +) -> BrowserFetcher: + """Собрать `BrowserFetcher` с `config: ScraperConfig` **mandatory**. + + Устраняет footgun-класс #2322/#2330: до этой фабрики каждый provider + конструировал `BrowserFetcher(...)` вручную и был обязан не забыть + `endpoint=config.browser_http_endpoint` — если забывал (или `config` был + optional где-то выше по стеку), браузерный fetch либо падал `TypeError` + (endpoint — required kwarg без default в `BrowserFetcher.__init__`), либо + молча не подключал proxy-pool (`use_pool` не проставлен → всегда False). + + Здесь `config` не имеет default — вызвать эту функцию без реального + `ScraperConfig` невозможно на уровне сигнатуры (не полагаемся на + code-review чтобы поймать пропущенный параметр). + + `proxy_provider=None` (дефолт) — валидно: часть providers (avito legacy + curl-only путь, domclick, cian/yandex newbuilding) пока не подключены к + browser-proxy-пулу (#2160 P4 wiring gap, отдельный issue) — `use_pool` в + этом случае эффективно игнорируется `BrowserFetcher` (`proxy_provider is + None` → env-fallback, см. `browser_fetcher.py::_pool_proxy`). + + `fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher` + (120s). Yandex-провайдер передаёt здесь 30s явно (см. вызовы в + `yandex/serp.py`/`yandex/newbuilding.py`) — единственный provider с + отличным от дефолта таймаутом. + """ + if fetch_timeout_s is None: + return BrowserFetcher( + source=source, + endpoint=config.browser_http_endpoint, + proxy_provider=proxy_provider, + use_pool=config.use_proxy_pool_browser, + ) + return BrowserFetcher( + source=source, + fetch_timeout_s=fetch_timeout_s, + endpoint=config.browser_http_endpoint, + proxy_provider=proxy_provider, + use_pool=config.use_proxy_pool_browser, + ) + + +__all__ = [ + "DEFAULT_IMPERSONATE", + "DOCUMENT_HEADERS", + "build_browser_fetcher", + "build_curl_cffi_session", + "build_document_session", + "http_proxies", +] From 51e2e545b7b9e2c6f229a31d540064e8c927e882 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 4 Jul 2026 03:50:42 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(tradein/scraper-kit):=20=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B4=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BD=D0=B3=D0=B0=20http=5Fproxies()=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=20httpx-=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit code-reviewer отметил: httpx>=0.28 больше не принимает dict в proxies= (только одиночный proxy= строкой). Docstring ошибочно называл эту функцию "httpx-совместимой" — уточнено что это только для curl_cffi, чтобы будущий мигратор F4a-d не словил TypeError. --- .../packages/scraper-kit/src/scraper_kit/providers/_base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index bcc3354c..c39b79b8 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -80,13 +80,17 @@ DOCUMENT_HEADERS: dict[str, str] = { def http_proxies(proxy_url: str | None) -> dict[str, str] | None: - """curl_cffi/httpx-совместимый `proxies=` dict из одного url. + """curl_cffi-совместимый `proxies=` dict из одного url. `None`/пустая строка → `None` (прямое подключение — без прокси). Извлечено из идентичной идиомы `{"http": url, "https": url} if url else None`, продублированной в avito/serp.py, avito/detail.py, avito/imv.py, cian/detail.py, cian/newbuilding.py (дважды), cian/session.py. + + ВАЖНО: не для httpx — начиная с httpx 0.28 `Client`/`AsyncClient` больше не + принимают dict в `proxies=` (только одиночный `proxy=` строкой). Не передавай + этот dict в httpx-клиент — `TypeError`. """ return {"http": proxy_url, "https": proxy_url} if proxy_url else None