feat(tradein): прогноз срока продажи + rate-limit (#9,#16) #387

Merged
lekss361 merged 1 commit from feat/tradein-dom-ratelimit into main 2026-05-21 17:13:09 +00:00
4 changed files with 92 additions and 0 deletions

View file

@ -0,0 +1,68 @@
"""Простой in-memory rate-limiter для публичного API.
Per-IP sliding window. Достаточно для одного backend-инстанса (MVP).
Защищает от абуза `/api/v1/*` скрейп-эндпоинты уже за admin-токеном,
но estimate/geocode/suggest публичны.
Лимиты намеренно щедрые обычный пользователь их не достигнет, режутся боты.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
# Окно и лимит: не более RATE_LIMIT запросов за RATE_WINDOW секунд с одного IP.
RATE_WINDOW = 60.0
RATE_LIMIT = 90
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Sliding-window rate limit на /api/v1/*. Health и статика — без лимита."""
def __init__(self, app) -> None: # type: ignore[no-untyped-def]
super().__init__(app)
self._hits: dict[str, deque[float]] = defaultdict(deque)
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
path = request.url.path
# Лимитируем только API; health и прочее — пропускаем.
if not path.startswith("/api/"):
return await call_next(request)
ip = _client_ip(request)
now = time.monotonic()
bucket = self._hits[ip]
# Выкидываем устаревшие отметки за пределами окна.
cutoff = now - RATE_WINDOW
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= RATE_LIMIT:
retry = int(RATE_WINDOW - (now - bucket[0])) + 1
return JSONResponse(
status_code=429,
content={"detail": "Слишком много запросов. Попробуйте позже."},
headers={"Retry-After": str(retry)},
)
bucket.append(now)
# Лёгкая защита от утечки памяти — чистим пустые корзины изредка.
if len(self._hits) > 10000:
for k in [k for k, v in self._hits.items() if not v]:
del self._hits[k]
return await call_next(request)
def _client_ip(request: Request) -> str:
"""Реальный IP за реверс-прокси (Caddy/nginx ставят X-Forwarded-For)."""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
return request.client.host if request.client else "unknown"

View file

@ -13,6 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import admin, brand, geocode, trade_in
from app.core.config import settings
from app.core.ratelimit import RateLimitMiddleware
logging.basicConfig(
level=logging.INFO,
@ -32,6 +33,8 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
# Rate-limit публичного API (per-IP sliding window) — защита от абуза.
app.add_middleware(RateLimitMiddleware)
@app.get("/health")

View file

@ -60,3 +60,4 @@ class AggregatedEstimate(BaseModel):
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 # прогноз срока продажи (медиана по аналогам)

View file

@ -266,9 +266,29 @@ async def estimate_quality(
target_lon=geo.lon,
sources_used=sources_used,
data_freshness_minutes=freshness_min,
est_days_on_market=_estimate_days_on_market(listings_clean, deals),
)
def _estimate_days_on_market(
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
) -> int | None:
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
(наши парсеры не всегда его отдают честно показываем «нет данных»).
"""
values = [
int(lot["days_on_market"])
for lot in (*listings, *deals)
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
]
if len(values) < 3:
return None
values.sort()
return values[len(values) // 2]
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
"""Минут с последнего парсинга — для UI «обновлено N мин назад»."""
if not lots: