gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/contracts.py
lekss361 ca473a0012
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
feat(proxy-pool): P4 — browser dynamic pool-proxy (acquire→/fetch→relaunch) + orchestration wiring, ship-dark (#2164)
2026-07-02 19:20:29 +00:00

318 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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",
]