Background scheduler в FastAPI lifespan: each tick (60s) checks scrape_schedules
table, triggers city sweep run если due (next_run_at <= NOW() + enabled + no
running run), recomputes next_run_at для следующих суток в random время в окне.
- 052_scrape_schedules.sql: NEW table (source, enabled, window_start/end_hour UTC,
default_params jsonb, last_run_id, last_run_at, next_run_at, updated_at).
Seed: 'avito_city_sweep' enabled, window 2-5 UTC (05:00-08:00 МСК).
- scheduler.py NEW:
* compute_next_run_at(start, end) — random datetime в окне для +1 day. Cross-midnight OK.
* scheduler_loop() — tick каждые 60s
* get_due_schedules / has_running_run / trigger_avito_city_sweep_run
* reap_zombies() — runs running > 6h без heartbeat → status='zombie'
- main.py: FastAPI lifespan launches scheduler_task; clean shutdown через cancel
- admin.py: 2 endpoints
* GET /admin/scrape/schedules — list all (now single row, extensible)
* PUT /admin/scrape/schedules/{source} — update config (enabled, window, params)
+ auto-recompute next_run_at
- schemas/trade_in.py: ScheduleConfig + ScheduleConfigUpdate
- tests/test_scheduler.py: 6 offline tests (normal window, cross-midnight, 1h window,
future check, timezone-aware check)
UI: frontend PR добавит секцию 6 на /scrapers/avito (parallel PR).
Cron-script deploy/avito-city-sweep.sh остаётся как backup, opt-in.
157 lines
6.2 KiB
Python
157 lines
6.2 KiB
Python
"""Pydantic schemas for Trade-In Estimator.
|
||
|
||
POST /api/v1/trade-in/estimate → AggregatedEstimate
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from typing import Any, Literal
|
||
from uuid import UUID
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class TradeInEstimateInput(BaseModel):
|
||
address: str = Field(min_length=3, max_length=500)
|
||
area_m2: float = Field(gt=10, lt=500)
|
||
rooms: int = Field(ge=0, le=10) # 0 = студия
|
||
floor: int = Field(ge=1, le=100)
|
||
total_floors: int = Field(ge=1, le=100)
|
||
year_built: int | None = Field(default=None, ge=1800, le=2100)
|
||
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
|
||
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
||
has_balcony: bool | None = None
|
||
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
|
||
ownership_type: str | None = Field(default=None, max_length=100)
|
||
has_mortgage: bool | None = None
|
||
client_name: str | None = Field(default=None, max_length=200)
|
||
client_phone: str | None = Field(default=None, max_length=50)
|
||
|
||
|
||
class AnalogLot(BaseModel):
|
||
address: str
|
||
area_m2: float
|
||
rooms: int
|
||
floor: int | None
|
||
total_floors: int | None
|
||
price_rub: int
|
||
price_per_m2: int
|
||
listing_date: date | None
|
||
days_on_market: int | None
|
||
photo_url: str | None = None
|
||
# ── Новые поля (Слой 5.2 — clickable links) ──
|
||
source: str | None = None # 'avito' / 'cian' / 'domklik' / 'rosreestr'
|
||
source_url: str | None = None # ссылка на оригинальное объявление / сделку
|
||
distance_m: int | None = None # расстояние до целевой квартиры в метрах
|
||
|
||
|
||
class AggregatedEstimate(BaseModel):
|
||
estimate_id: UUID
|
||
median_price_rub: int
|
||
range_low_rub: int
|
||
range_high_rub: int
|
||
median_price_per_m2: int
|
||
confidence: Literal["low", "medium", "high"]
|
||
confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%»
|
||
n_analogs: int
|
||
period_months: int # 24
|
||
analogs: list[AnalogLot] # top 5-10 listings
|
||
actual_deals: list[AnalogLot] # реальные продажи last 12 mo
|
||
expires_at: datetime
|
||
# ── Дополнительные метаданные ──
|
||
target_address: str | None = None # geocoded full address
|
||
target_lat: float | None = None
|
||
target_lon: float | None = None
|
||
sources_used: list[str] = Field(default_factory=list) # ['avito', 'cian', 'rosreestr']
|
||
data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг
|
||
est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам)
|
||
# ── Параметры оценённой квартиры — нужны, чтобы восстановить карточку
|
||
# при открытии оценки по ссылке (?id=), когда формы-инпута уже нет ──
|
||
area_m2: float | None = None
|
||
rooms: int | None = None
|
||
floor: int | None = None
|
||
total_floors: int | None = None
|
||
year_built: int | None = None
|
||
house_type: str | None = None
|
||
repair_state: str | None = None
|
||
has_balcony: bool | None = None
|
||
|
||
|
||
class PhotoMeta(BaseModel):
|
||
"""Метаданные фото квартиры (#394). Содержимое отдаётся отдельным эндпоинтом."""
|
||
|
||
id: UUID
|
||
filename: str | None = None
|
||
content_type: str
|
||
size_bytes: int | None = None
|
||
uploaded_at: datetime
|
||
|
||
|
||
# ── Stage 4a response schemas ────────────────────────────────────────────────
|
||
|
||
|
||
class HouseInfoForEstimate(BaseModel):
|
||
"""Summary информации о доме целевой квартиры (для GET /estimate/{id}/houses)."""
|
||
|
||
house_id: int | None = None
|
||
ext_house_id: str | None = None
|
||
address: str | None = None
|
||
short_address: str | None = None
|
||
lat: float | None = None
|
||
lon: float | None = None
|
||
year_built: int | None = None
|
||
total_floors: int | None = None
|
||
house_type: str | None = None
|
||
passenger_elevators: int | None = None
|
||
cargo_elevators: int | None = None
|
||
has_concierge: bool | None = None
|
||
closed_yard: bool | None = None
|
||
has_playground: bool | None = None
|
||
parking_type: str | None = None
|
||
developer_name: str | None = None
|
||
rating: float | None = None
|
||
reviews_count: int | None = None
|
||
raw_characteristics: list[dict] = Field(default_factory=list)
|
||
|
||
|
||
class IMVBenchmarkResponse(BaseModel):
|
||
"""Avito IMV benchmark для UI (GET /estimate/{id}/imv-benchmark)."""
|
||
|
||
available: bool # есть ли IMV для этого estimate
|
||
cache_key: str | None = None
|
||
recommended_price: int | None = None
|
||
lower_price: int | None = None
|
||
higher_price: int | None = None
|
||
market_count: int | None = None
|
||
fetched_at: datetime | None = None
|
||
# comparison vs our estimate
|
||
our_median_price: int | None = None
|
||
diff_pct: float | None = None # (our - imv) / imv * 100
|
||
|
||
|
||
# ── In-app scheduler (Stage 4e) ─────────────────────────────────────────────
|
||
|
||
|
||
class ScheduleConfig(BaseModel):
|
||
"""Текущая конфигурация schedule для source."""
|
||
|
||
id: int
|
||
source: str
|
||
enabled: bool
|
||
window_start_hour: int = Field(ge=0, le=23)
|
||
window_end_hour: int = Field(ge=0, le=23)
|
||
default_params: dict[str, Any] = Field(default_factory=dict)
|
||
last_run_id: int | None = None
|
||
last_run_at: str | None = None # ISO
|
||
next_run_at: str | None = None # ISO
|
||
updated_at: str | None = None # ISO
|
||
|
||
|
||
class ScheduleConfigUpdate(BaseModel):
|
||
"""PUT body для update_schedule endpoint."""
|
||
|
||
enabled: bool = True
|
||
window_start_hour: int = Field(default=2, ge=0, le=23)
|
||
window_end_hour: int = Field(default=5, ge=0, le=23)
|
||
default_params: dict[str, Any] = Field(default_factory=dict)
|