feat(scraper-kit): protocol boundaries (matcher/config/session/jobs) + backend adapters (#2131)
This commit is contained in:
parent
abef3e6e02
commit
0b99882034
3 changed files with 477 additions and 0 deletions
211
tradein-mvp/backend/app/services/scraper_adapters.py
Normal file
211
tradein-mvp/backend/app/services/scraper_adapters.py
Normal 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",
|
||||
]
|
||||
88
tradein-mvp/backend/tests/test_scraper_adapters_contracts.py
Normal file
88
tradein-mvp/backend/tests/test_scraper_adapters_contracts.py
Normal 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
|
||||
178
tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py
Normal file
178
tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py
Normal 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",
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue