All checks were successful
Deploy Trade-In / build-browser (push) Successful in 2m13s
Deploy Trade-In / build-backend (push) Successful in 1m18s
Deploy Trade-In / deploy (push) Successful in 2m29s
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
318 lines
15 KiB
Python
318 lines
15 KiB
Python
"""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 dataclasses import dataclass
|
||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||
|
||
if TYPE_CHECKING:
|
||
from sqlalchemy.orm import Session
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProxyLease:
|
||
"""Арендованный из пула прокси (лёгкий транспорт между kit и backend).
|
||
|
||
Структурно совпадает с `app.services.proxy_pool.ProxyLease` (id/url/kind/rotate_url),
|
||
но живёт в контрактах, чтобы kit не импортировал `app.*`. Провайдеры читают только
|
||
`url` (готов для curl_cffi `proxies=`); `id`/`rotate_url` нужны backend-адаптеру для
|
||
release / mark_health / (будущей) ротации.
|
||
"""
|
||
|
||
id: int
|
||
url: str
|
||
kind: str
|
||
rotate_url: str | None = None
|
||
|
||
|
||
@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 | None, float, str]:
|
||
"""Найти или создать канонический дом.
|
||
|
||
Returns:
|
||
(house_id, confidence ∈ [0.0, 1.0], method), где method ∈ {
|
||
'cadastr_exact', 'source_exact', 'fingerprint', 'geo_proximity', 'new',
|
||
'no_house_number'
|
||
}.
|
||
house_id=None только при method='no_house_number' — matcher отказался
|
||
создавать дом из безномерного адреса без кадастра (P1). Вызывающий обязан
|
||
обработать None.
|
||
"""
|
||
...
|
||
|
||
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
|
||
# Секрет pgp_sym_encrypt для cian_session_cookies (read в session.py).
|
||
cookie_encryption_key: str
|
||
# Sentry/GlitchTip DSN (опционально).
|
||
glitchtip_dsn: str | None
|
||
# ── Оркестрация sweep-pipeline (#2135) ───────────────────────────────────
|
||
# Поля, читаемые слоем оркестрации (scraper_kit.orchestration.pipeline).
|
||
# Собраны grep'ом `settings.<name>` по app/services/scrape_pipeline.py.
|
||
# Проксируются той же продуктовой реализацией (RealScraperConfig).
|
||
#
|
||
# Partial-ban intake: SERP уже собрал лоты, а detail/houses заблокировало —
|
||
# run помечается 'done' (не 'banned'), intake сохранён (#1950).
|
||
avito_serp_ok_not_banned: bool
|
||
# changeip settle-пауза (секунды) после ротации IP.
|
||
avito_proxy_rotate_settle_s: float
|
||
# Ретраи changeip-GET: N попыток по proxy_rotate_attempt_timeout_s каждая (#1950).
|
||
proxy_rotate_attempts: int
|
||
proxy_rotate_attempt_timeout_s: float
|
||
# Cian rotate-прокси + лимит ротаций.
|
||
cian_proxy_rotate_url: str | None
|
||
cian_proxy_max_rotations: int
|
||
# Yandex лимит ротаций (rotate_url уже выше).
|
||
yandex_proxy_max_rotations: int
|
||
# Пропускать листинги, уже обновлённые сегодня по МСК (full-load дедуп).
|
||
scraper_skip_seen_today: bool
|
||
# Per-fetch watchdog-таймаут Cian full-load (секунды).
|
||
cian_full_load_per_fetch_timeout_s: float
|
||
# ── Proxy-pool (#2163) ────────────────────────────────────────────────────
|
||
# Feature-флаг: брать прокси из пула (scrape_proxies) на curl_cffi-путях вместо
|
||
# env-прокси (config.*_proxy_url). Ship-dark: дефолт False → curl-пути ходят как
|
||
# сейчас. True + пустой пул → fallback на env (сбор не ломается). См. ProxyProvider.
|
||
use_proxy_pool_curl: bool
|
||
# Feature-флаг (#2164 P4): брать прокси из пула на browser-путях (BrowserFetcher →
|
||
# POST /fetch{proxy}). Scraper-сторона делает acquire(source) и передаёт lease.url в
|
||
# теле запроса; tradein-browser релончит camoufox с этим прокси (relaunch ТОЛЬКО при
|
||
# реальной смене). Ship-dark: дефолт False → браузер берёт прокси из env
|
||
# (BROWSER_PROXY_*), прод не меняется. True + пустой пул → fallback на env.
|
||
use_proxy_pool_browser: bool
|
||
|
||
|
||
@runtime_checkable
|
||
class ProxyProvider(Protocol):
|
||
"""Источник прокси из пула для curl-путей (#2163), за флагом use_proxy_pool_curl.
|
||
|
||
Инкапсулирует lease/health-механику `app.services.proxy_pool` (которая требует
|
||
`Session`), пряча БД от kit'а. Реализуется
|
||
`app.services.scraper_adapters.RealProxyProvider` (открывает короткую сессию на
|
||
операцию). Провайдеры вызывают `acquire` → используют `lease.url` → на выходе
|
||
`mark_health` + `release` (в finally — lease не должен течь).
|
||
|
||
Контракт ship-dark/fallback: `acquire` возвращает None если свободных здоровых
|
||
прокси нет — caller обязан откатиться на env-прокси, а НЕ падать.
|
||
"""
|
||
|
||
def acquire(self, provider: str) -> ProxyLease | None:
|
||
"""Взять прокси под провайдера (avito/cian/yandex/generic/any).
|
||
|
||
Returns ProxyLease или None если пул пуст/выключен → caller делает env-fallback.
|
||
"""
|
||
...
|
||
|
||
def release(self, lease: ProxyLease) -> None:
|
||
"""Освободить lease (идемпотентно). Вызывать в finally — иначе прокси течёт."""
|
||
...
|
||
|
||
def mark_health(
|
||
self,
|
||
lease: ProxyLease,
|
||
ok: bool,
|
||
*,
|
||
exit_ip: str | None = ...,
|
||
latency_ms: int | None = ...,
|
||
) -> None:
|
||
"""Записать исход использования прокси (ok=True успех, False бан/ошибка)."""
|
||
...
|
||
|
||
|
||
@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."""
|
||
...
|
||
|
||
async def process_houses_imv_batch(
|
||
self,
|
||
db: Session,
|
||
house_ids: set[int],
|
||
*,
|
||
request_delay_sec: float = ...,
|
||
heartbeat: Callable[[], None] | None = ...,
|
||
) -> Any:
|
||
"""Avito IMV-оценка явного набора house_id (финальная IMV-фаза city-sweep).
|
||
|
||
Проксирует process_houses_imv_batch. Возвращаемый результат имеет поля
|
||
.checked/.saved/.errors (тип остаётся Any — не тянем app.* в контракты).
|
||
"""
|
||
...
|
||
|
||
# ── Yandex sweep-хелперы (#2135 F2) ──────────────────────────────────────
|
||
# Продуктовая логика, дёргаемая yandex city/full sweep-оркестраторами напрямую
|
||
# (не через отдельные backfill-джобы). Инжектируются вместо прямых импортов
|
||
# app.services.yandex_price_history / yandex_address_backfill.
|
||
|
||
def record_yandex_price_history(self, db: Session, lots: list[Any]) -> int:
|
||
"""Записать price-history из gate price.previous/trend. Проксирует
|
||
record_yandex_price_history. Returns число вставленных строк истории.
|
||
|
||
`lots` — список kit-`ScrapedLot` (тип оставлен `Any`, чтобы не тянуть
|
||
доменный тип в контракт). Читается duck-typing'ом (source_id/price/…).
|
||
"""
|
||
...
|
||
|
||
def extract_address_from_title(self, html: str) -> str | None:
|
||
"""Извлечь полный адрес из <title> detail-страницы Yandex.
|
||
|
||
Проксирует `_extract_address_from_title` (yandex_address_backfill).
|
||
Returns адрес с номером дома или None если не распарсилось.
|
||
"""
|
||
...
|
||
|
||
def address_has_house_number(self, address: str) -> bool:
|
||
"""True если адрес содержит номер дома (`,\\s*\\d+`).
|
||
|
||
Проксирует regex `_RE_HAS_HOUSE_NUMBER` из yandex_address_backfill —
|
||
gate для решения, обновлять ли address на обогащённый.
|
||
"""
|
||
...
|
||
|
||
|
||
__all__ = [
|
||
"EnrichmentJobs",
|
||
"HouseMatcher",
|
||
"ProxyLease",
|
||
"ProxyProvider",
|
||
"ScraperConfig",
|
||
"SessionFactory",
|
||
]
|