gendesign/tradein-mvp/backend/app/services/scraper_adapters.py
bot-backend 9b4cdf8ec6 feat(tradein/scraper-kit): shared base/utils для providers/* (#2358, Foundation)
Аддитивный Foundation-модуль (эпик #2277 Group F4): scraper_kit/providers/_base.py
извлекает 3 реально повторяющихся паттерна, найденных чтением avito/cian/yandex/
domclick providers (~13k строк):

- build_browser_fetcher(config: ScraperConfig, source, ...) — config MANDATORY,
  устраняет footgun-класс #2322/#2330 (BrowserFetcher конструировался вручную в
  6+ местах, endpoint/proxy-pool wiring легко забыть/потерять).
- build_curl_cffi_session / build_document_session + http_proxies() — общий
  curl_cffi AsyncSession(impersonate=chrome120, proxies=..., headers=...)
  boilerplate, дублированный в avito/serp.py, avito/detail.py, avito/imv.py,
  cian/detail.py, cian/newbuilding.py (x2), cian/session.py.
- DOCUMENT_HEADERS — идентичный "document" headers dict, дублированный трижды.

Retry/backoff-циклы (yandex tarpit-retry, avito page-retry, tenacity в
BaseScraper) сознательно НЕ обобщены — разная семантика ошибок/действий между
попытками, обобщение дало бы leaky abstraction (см. docstring модуля).

Existing providers/{avito,cian,yandex,domclick}/*.py НЕ тронуты — миграция
call-site'ов на новый модуль это отдельные future issues (F4a-d).

Заодно: актуализирован устаревший докстринг app/services/scraper_adapters.py
("Пока НИКТО их не использует") — адаптеры wired в прод с #2192 через
app.scheduler_main._run_kit_scheduler.
2026-07-04 03:50:55 +03:00

353 lines
12 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.

"""Продуктовые адаптеры над `app.services.*`, удовлетворяющие scraper_kit.contracts.
Тонкие врапперы, инжектируемые в скрапперы (после переноса в scraper_kit, шаги C-G
#2131) вместо прямых импортов `app.*`. Каждый класс структурно удовлетворяет
соответствующему Protocol'у из `scraper_kit.contracts`:
RealMatcherAdapter → HouseMatcher
RealScraperConfig → ScraperConfig
RealSessionFactory → SessionFactory
RealEnrichmentJobs → EnrichmentJobs
Wired в прод с #2192: `app.scheduler_main._run_kit_scheduler` конструирует
`SchedulerContext` из этих адаптеров (`RealScraperConfig`/`RealMatcherAdapter`/
`RealEnrichmentJobs`/`RealSessionFactory`/`RealProxyProvider`) и передаёт в
`scraper_kit.orchestration.scheduler.scheduler_loop` — kit-native sweep-registry
крутится через эту инжекцию, а не через прямые импорты `app.*`. Держим сигнатуры
в точном соответствии с исходными функциями, чтобы Protocol'ы `scraper_kit.contracts`
не расходились с боевой реализацией.
"""
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 import proxy_pool as _proxy_pool
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.house_imv_backfill import (
process_houses_imv_batch as _process_houses_imv_batch,
)
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 (
_RE_HAS_HOUSE_NUMBER,
_extract_address_from_title,
)
from app.services.yandex_address_backfill import (
backfill_yandex_addresses as _backfill_yandex_addresses,
)
from app.services.yandex_price_history import (
record_yandex_price_history as _record_yandex_price_history,
)
if TYPE_CHECKING:
from scraper_kit.contracts import ProxyLease
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 | None, float, str]:
# house_id is None when the matcher refuses a numberless address without a
# cadastral number (method 'no_house_number', P1). Callers must tolerate None.
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 cookie_encryption_key(self) -> str:
return _settings.cookie_encryption_key
@property
def glitchtip_dsn(self) -> str | None:
return _settings.glitchtip_dsn
# ── Оркестрация sweep-pipeline (#2135) ───────────────────────────────
@property
def avito_serp_ok_not_banned(self) -> bool:
return _settings.avito_serp_ok_not_banned
@property
def avito_proxy_rotate_settle_s(self) -> float:
return _settings.avito_proxy_rotate_settle_s
@property
def proxy_rotate_attempts(self) -> int:
return _settings.proxy_rotate_attempts
@property
def proxy_rotate_attempt_timeout_s(self) -> float:
return _settings.proxy_rotate_attempt_timeout_s
@property
def cian_proxy_rotate_url(self) -> str | None:
return _settings.cian_proxy_rotate_url
@property
def cian_proxy_max_rotations(self) -> int:
return _settings.cian_proxy_max_rotations
@property
def yandex_proxy_max_rotations(self) -> int:
return _settings.yandex_proxy_max_rotations
@property
def scraper_skip_seen_today(self) -> bool:
return _settings.scraper_skip_seen_today
@property
def cian_full_load_per_fetch_timeout_s(self) -> float:
return _settings.cian_full_load_per_fetch_timeout_s
# ── Proxy-pool (#2163) ────────────────────────────────────────────────
@property
def use_proxy_pool_curl(self) -> bool:
return _settings.use_proxy_pool_curl
# ── Proxy-pool browser (#2164 P4) ─────────────────────────────────────
@property
def use_proxy_pool_browser(self) -> bool:
return _settings.use_proxy_pool_browser
class RealProxyProvider:
"""ProxyProvider-адаптер над `app.services.proxy_pool` (#2163).
Прячет `Session` от kit'а: открывает короткую сессию на каждую операцию
(acquire/release/mark_health), проксирует в модульные функции proxy_pool и
коммитит внутри них. Возвращает kit-`ProxyLease` (структурно совпадает с
`proxy_pool.ProxyLease`), чтобы провайдеры не импортировали `app.*`.
Не-run вызовы (curl-путь эстиматора вне scrape_run) лизят под
NON_RUN_LEASE_MARKER — reap_stale_leases освободит, если release не дошёл.
"""
def acquire(self, provider: str) -> ProxyLease | None:
from scraper_kit.contracts import ProxyLease as _KitProxyLease
db = _SessionLocal()
try:
lease = _proxy_pool.acquire(db, provider)
finally:
db.close()
if lease is None:
return None
return _KitProxyLease(
id=lease.id, url=lease.url, kind=lease.kind, rotate_url=lease.rotate_url
)
def release(self, lease: ProxyLease) -> None:
db = _SessionLocal()
try:
_proxy_pool.release(db, lease.id)
finally:
db.close()
def mark_health(
self,
lease: ProxyLease,
ok: bool,
*,
exit_ip: str | None = None,
latency_ms: int | None = None,
) -> None:
db = _SessionLocal()
try:
_proxy_pool.mark_health(db, lease.id, ok, exit_ip=exit_ip, latency_ms=latency_ms)
finally:
db.close()
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,
)
async def process_houses_imv_batch(
self,
db: Session,
house_ids: set[int],
*,
request_delay_sec: float = 5.0,
heartbeat: Callable[[], None] | None = None,
) -> HouseIMVBackfillResult:
return await _process_houses_imv_batch(
db,
house_ids,
request_delay_sec=request_delay_sec,
heartbeat=heartbeat,
)
def record_yandex_price_history(self, db: Session, lots: list[Any]) -> int:
return _record_yandex_price_history(db, lots)
def extract_address_from_title(self, html: str) -> str | None:
return _extract_address_from_title(html)
def address_has_house_number(self, address: str) -> bool:
return bool(_RE_HAS_HOUSE_NUMBER.search(address))
__all__ = [
"RealEnrichmentJobs",
"RealMatcherAdapter",
"RealProxyProvider",
"RealScraperConfig",
"RealSessionFactory",
]