diff --git a/tradein-mvp/backend/app/services/scraper_adapters.py b/tradein-mvp/backend/app/services/scraper_adapters.py new file mode 100644 index 00000000..f1917638 --- /dev/null +++ b/tradein-mvp/backend/app/services/scraper_adapters.py @@ -0,0 +1,211 @@ +"""Продуктовые адаптеры над `app.services.*`, удовлетворяющие scraper_kit.contracts. + +Тонкие врапперы, инжектируемые в скрапперы (после переноса в scraper_kit, шаги C-G +#2131) вместо прямых импортов `app.*`. Каждый класс структурно удовлетворяет +соответствующему Protocol'у из `scraper_kit.contracts`: + + RealMatcherAdapter → HouseMatcher + RealScraperConfig → ScraperConfig + RealSessionFactory → SessionFactory + RealEnrichmentJobs → EnrichmentJobs + +Пока НИКТО их не использует — скрапперы всё ещё на прямых импортах. Это подготовка: +переключение вызовов на инжекцию — отдельный шаг (C). Держим сигнатуры в точном +соответствии с исходными функциями, чтобы шаг C состыковался без правок контрактов. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from app.core.config import settings as _settings +from app.core.db import SessionLocal as _SessionLocal +from app.services.house_dedup_merge import run_house_dedup_merge as _run_house_dedup_merge +from app.services.house_imv_backfill import backfill_house_imv as _backfill_house_imv +from app.services.matching import match_or_create_house as _match_or_create_house +from app.services.matching import upsert_listing_source as _upsert_listing_source +from app.services.yandex_address_backfill import ( + backfill_yandex_addresses as _backfill_yandex_addresses, +) + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + from app.services.house_imv_backfill import HouseIMVBackfillResult + from app.services.yandex_address_backfill import YandexAddressBackfillResult + + +class RealMatcherAdapter: + """HouseMatcher-адаптер над `app.services.matching`.""" + + def match_or_create_house( + self, + db: Session, + ext_source: str, + ext_id: str, + address: str | None = None, + lat: float | None = None, + lon: float | None = None, + *, + year_built: int | None = None, + building_cadastral_number: str | None = None, + cadastral_number: str | None = None, + source_url: str | None = None, + ) -> tuple[int, float, str]: + return _match_or_create_house( + db, + ext_source, + ext_id, + address, + lat, + lon, + year_built=year_built, + building_cadastral_number=building_cadastral_number, + cadastral_number=cadastral_number, + source_url=source_url, + ) + + def upsert_listing_source( + self, + db: Session, + *, + listing_id: int, + ext_source: str, + ext_id: str, + method: str, + confidence: float, + price_rub: float | None = None, + area_m2: float | None = None, + floor: int | None = None, + rooms_count: int | None = None, + source_url: str | None = None, + source_data: dict[str, Any] | None = None, + ) -> None: + _upsert_listing_source( + db, + listing_id=listing_id, + ext_source=ext_source, + ext_id=ext_id, + method=method, + confidence=confidence, + price_rub=price_rub, + area_m2=area_m2, + floor=floor, + rooms_count=rooms_count, + source_url=source_url, + source_data=source_data, + ) + + +class RealScraperConfig: + """ScraperConfig-адаптер: read-only проекция singleton'а `settings`. + + Свойства проксируют в `app.core.config.settings`, включая computed-properties + (`scraper_proxy_url`, `cian_proxy_url`), чтобы скрапперы читали ровно те же + значения, что и сейчас через прямой `settings.`. + """ + + @property + def scraper_fetch_mode(self) -> str: + return _settings.scraper_fetch_mode + + @property + def browser_http_endpoint(self) -> str: + return _settings.browser_http_endpoint + + @property + def scraper_proxy_url(self) -> str | None: + return _settings.scraper_proxy_url + + @property + def avito_proxy_rotate_url(self) -> str | None: + return _settings.avito_proxy_rotate_url + + @property + def avito_proxy_max_rotations(self) -> int: + return _settings.avito_proxy_max_rotations + + @property + def avito_serp_ekb_only(self) -> bool: + return _settings.avito_serp_ekb_only + + @property + def yandex_proxy_rotate_url(self) -> str | None: + return _settings.yandex_proxy_rotate_url + + @property + def cian_proxy_url(self) -> str | None: + return _settings.cian_proxy_url + + @property + def cian_valuation_min_rub(self) -> float: + return _settings.cian_valuation_min_rub + + @property + def cian_valuation_max_rub(self) -> float: + return _settings.cian_valuation_max_rub + + @property + def glitchtip_dsn(self) -> str | None: + return _settings.glitchtip_dsn + + +class RealSessionFactory: + """SessionFactory-адаптер над `app.core.db.SessionLocal`.""" + + def __call__(self) -> Session: + return _SessionLocal() + + +class RealEnrichmentJobs: + """EnrichmentJobs-адаптер над enrichment-сервисами `app.services.*`.""" + + async def house_imv_backfill( + self, + db: Session, + *, + batch_size: int = 50, + request_delay_sec: float = 5.0, + only_status: str = "pending", + house_id: int | None = None, + heartbeat: Callable[[], None] | None = None, + ) -> HouseIMVBackfillResult: + return await _backfill_house_imv( + db, + batch_size=batch_size, + request_delay_sec=request_delay_sec, + only_status=only_status, + house_id=house_id, + heartbeat=heartbeat, + ) + + def house_dedup_merge( + self, + db: Session, + *, + run_id: int, + params: dict[str, Any], + ) -> dict[str, int]: + return _run_house_dedup_merge(db, run_id=run_id, params=params) + + async def yandex_address_backfill( + self, + db: Session, + *, + limit: int = 200, + request_delay_sec: float = 3.0, + ) -> YandexAddressBackfillResult: + return await _backfill_yandex_addresses( + db, + limit=limit, + request_delay_sec=request_delay_sec, + ) + + +__all__ = [ + "RealEnrichmentJobs", + "RealMatcherAdapter", + "RealScraperConfig", + "RealSessionFactory", +] diff --git a/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py b/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py new file mode 100644 index 00000000..93352f8b --- /dev/null +++ b/tradein-mvp/backend/tests/test_scraper_adapters_contracts.py @@ -0,0 +1,88 @@ +"""Проверка, что backend-адаптеры структурно удовлетворяют scraper_kit.contracts. + +Двойная проверка: + 1. Статическая — `_check_*` функции с аннотацией Protocol-типа. mypy проверит, + что каждый адаптер присваивается соответствующему Protocol'у (структурно). + 2. Рантайм — `isinstance` против `@runtime_checkable` Protocol'ов (проверяет + наличие методов/атрибутов). Ловит рассинхрон имён без запуска mypy. + +Границы контрактов зафиксированы для #2131 (шаг B); переключение скрапперов на +инжекцию — шаг C. +""" + +from __future__ import annotations + +from scraper_kit.contracts import ( + EnrichmentJobs, + HouseMatcher, + ScraperConfig, + SessionFactory, +) + +from app.services.scraper_adapters import ( + RealEnrichmentJobs, + RealMatcherAdapter, + RealScraperConfig, + RealSessionFactory, +) + + +def _check_matcher(m: HouseMatcher) -> None: + """Статическая проверка: RealMatcherAdapter ⊑ HouseMatcher.""" + + +def _check_config(c: ScraperConfig) -> None: + """Статическая проверка: RealScraperConfig ⊑ ScraperConfig.""" + + +def _check_session_factory(f: SessionFactory) -> None: + """Статическая проверка: RealSessionFactory ⊑ SessionFactory.""" + + +def _check_enrichment(j: EnrichmentJobs) -> None: + """Статическая проверка: RealEnrichmentJobs ⊑ EnrichmentJobs.""" + + +def test_matcher_adapter_satisfies_protocol() -> None: + adapter = RealMatcherAdapter() + _check_matcher(adapter) + assert isinstance(adapter, HouseMatcher) + assert callable(adapter.match_or_create_house) + assert callable(adapter.upsert_listing_source) + + +def test_scraper_config_satisfies_protocol() -> None: + config = RealScraperConfig() + _check_config(config) + assert isinstance(config, ScraperConfig) + # Ключевые поля читаются без ошибок (проксируют в singleton settings). + assert isinstance(config.scraper_fetch_mode, str) + assert isinstance(config.browser_http_endpoint, str) + assert isinstance(config.avito_proxy_max_rotations, int) + assert isinstance(config.avito_serp_ekb_only, bool) + + +def test_session_factory_satisfies_protocol() -> None: + factory = RealSessionFactory() + _check_session_factory(factory) + assert isinstance(factory, SessionFactory) + + +def test_enrichment_jobs_satisfies_protocol() -> None: + jobs = RealEnrichmentJobs() + _check_enrichment(jobs) + assert isinstance(jobs, EnrichmentJobs) + assert callable(jobs.house_imv_backfill) + assert callable(jobs.house_dedup_merge) + assert callable(jobs.yandex_address_backfill) + + +def test_contracts_module_has_no_app_imports() -> None: + """contracts.py не должен импортировать app.* (иначе circular / coupling).""" + import inspect + + import scraper_kit.contracts as contracts_mod + + source = inspect.getsource(contracts_mod) + assert "from app" not in source + assert "import app" not in source diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py new file mode 100644 index 00000000..eb04dc5e --- /dev/null +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py @@ -0,0 +1,178 @@ +"""Protocol-контракты (границы) между scraper_kit и продуктовым backend'ом. + +Скрапперы (после переноса в scraper_kit, шаги C-G #2131) не должны напрямую +импортировать `app.*`. Вместо этого они принимают инжектируемые зависимости, +описанные здесь через `typing.Protocol` (structural typing): продуктовая сторона +предоставляет конкретные реализации (см. `app.services.scraper_adapters`), +которые структурно удовлетворяют этим Protocol'ам. + +ВАЖНО: этот модуль НЕ импортирует `app.*` и не тянет тяжёлых зависимостей в +рантайме. Тип `Session` (SQLAlchemy) используется только в аннотациях под +`TYPE_CHECKING` — благодаря `from __future__ import annotations` аннотации не +вычисляются при импорте, поэтому пакет остаётся standalone-импортируемым. + +Сигнатуры Protocol'ов зафиксированы по факту из backend-кода: + - HouseMatcher ← app.services.matching.houses.match_or_create_house + + app.services.matching.listings.upsert_listing_source + - ScraperConfig ← поля app.core.config.Settings, читаемые скрапперами + - SessionFactory ← app.core.db.SessionLocal (sessionmaker → Session) + - EnrichmentJobs ← app.services.house_imv_backfill / house_dedup_merge / + yandex_address_backfill +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + +@runtime_checkable +class HouseMatcher(Protocol): + """Кросс-источниковый матчинг домов/листингов (продуктовая логика matching-сервиса). + + Реализуется `app.services.scraper_adapters.RealMatcherAdapter`, который проксирует + в `app.services.matching`. Скрапперы вызывают методы вместо прямого импорта. + """ + + def match_or_create_house( + self, + db: Session, + ext_source: str, + ext_id: str, + address: str | None = ..., + lat: float | None = ..., + lon: float | None = ..., + *, + year_built: int | None = ..., + building_cadastral_number: str | None = ..., + cadastral_number: str | None = ..., + source_url: str | None = ..., + ) -> tuple[int, float, str]: + """Найти или создать канонический дом. + + Returns: + (house_id, confidence ∈ [0.0, 1.0], method), где method ∈ { + 'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new' + }. + """ + ... + + def upsert_listing_source( + self, + db: Session, + *, + listing_id: int, + ext_source: str, + ext_id: str, + method: str, + confidence: float, + price_rub: float | None = ..., + area_m2: float | None = ..., + floor: int | None = ..., + rooms_count: int | None = ..., + source_url: str | None = ..., + source_data: dict[str, Any] | None = ..., + ) -> None: + """Зарегистрировать/обновить строку listing_sources для (ext_source, ext_id).""" + ... + + +@runtime_checkable +class ScraperConfig(Protocol): + """Конфигурация, читаемая скрапперами из `app.core.config.settings`. + + Поля собраны grep'ом `settings.` по `app/services/scrapers/`. Типы — + как в `Settings` (proxy-свойства возвращают `str | None`). Реализуется + `app.services.scraper_adapters.RealScraperConfig` (проксирует в singleton + `settings`); pydantic-`Settings` также структурно удовлетворяет этому Protocol. + """ + + # Режим загрузки: "curl_cffi" | "browser". + scraper_fetch_mode: str + # HTTP-эндпоинт headless-браузера (tradein-browser). + browser_http_endpoint: str + # Backconnect-прокси общий (Avito/Cian fallback). + scraper_proxy_url: str | None + # Avito rotate-прокси + лимит ротаций на прогон. + avito_proxy_rotate_url: str | None + avito_proxy_max_rotations: int + # Ограничивать Avito SERP только ЕКБ. + avito_serp_ekb_only: bool + # Yandex rotate-прокси. + yandex_proxy_rotate_url: str | None + # Cian-прокси (CIAN_PROXY_URL > scraper_proxy_url fallback). + cian_proxy_url: str | None + # Границы валидности cian-оценки, руб. + cian_valuation_min_rub: float + cian_valuation_max_rub: float + # Sentry/GlitchTip DSN (опционально). + glitchtip_dsn: str | None + + +@runtime_checkable +class SessionFactory(Protocol): + """Фабрика SQLAlchemy-сессии (аналог `app.core.db.SessionLocal`). + + Вызов без аргументов возвращает новую `Session`. Реализуется + `app.services.scraper_adapters.RealSessionFactory`; сам `sessionmaker` + (SessionLocal) также структурно удовлетворяет. + """ + + def __call__(self) -> Session: + """Открыть новую сессию БД.""" + ... + + +@runtime_checkable +class EnrichmentJobs(Protocol): + """Реестр enrichment-джоб, дёргаемых после сохранения скрапом. + + Callback'и проксируют в соответствующие `app.services.*`. Возвращаемые + доменные результат-типы (`HouseIMVBackfillResult`, `YandexAddressBackfillResult`) + остаются `Any`, чтобы не тянуть `app.*` в контракты. Реализуется + `app.services.scraper_adapters.RealEnrichmentJobs`. + """ + + async def house_imv_backfill( + self, + db: Session, + *, + batch_size: int = ..., + request_delay_sec: float = ..., + only_status: str = ..., + house_id: int | None = ..., + heartbeat: Callable[[], None] | None = ..., + ) -> Any: + """Avito IMV-оценка для домов в scope. Проксирует backfill_house_imv.""" + ... + + def house_dedup_merge( + self, + db: Session, + *, + run_id: int, + params: dict[str, Any], + ) -> dict[str, int]: + """Слить дубли домов. Проксирует run_house_dedup_merge. Returns счётчики.""" + ... + + async def yandex_address_backfill( + self, + db: Session, + *, + limit: int = ..., + request_delay_sec: float = ..., + ) -> Any: + """Догрузить точные адреса Yandex. Проксирует backfill_yandex_addresses.""" + ... + + +__all__ = [ + "EnrichmentJobs", + "HouseMatcher", + "ScraperConfig", + "SessionFactory", +]