feat(scraper-kit): protocol boundaries (matcher/config/session/jobs) + backend adapters (#2131) #2151

Merged
lekss361 merged 1 commit from feat/scraper-kit-contracts into main 2026-07-02 15:25:39 +00:00
3 changed files with 477 additions and 0 deletions

View file

@ -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.<name>`.
"""
@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",
]

View file

@ -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

View file

@ -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.<name>` по `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",
]