feat(tradein): Stage 4a — API endpoints for Avito enrichment + IMV benchmark UI

3 NEW admin endpoints (debug + manual triggers):
- POST /api/v1/admin/scrape/avito-house?house_url=... — Houses Catalog enrichment (Stage 2c)
- POST /api/v1/admin/scrape/avito-detail?item_url=... — listing detail enrichment (Stage 2b)
- POST /api/v1/admin/scrape/avito-imv?address=...&rooms=... — IMV debug (Stage 2d, no cache)

2 NEW trade-in endpoints (UI):
- GET /api/v1/trade-in/estimate/{id}/houses — top-5 ближайших houses к target (Stage 2c data)
- GET /api/v1/trade-in/estimate/{id}/imv-benchmark — Avito IMV для UI badge (Stage 3 cache
  lookup + fallback to address-match within 24h; includes our_median_price + diff_pct vs IMV)

2 NEW Pydantic schemas: HouseInfoForEstimate, IMVBenchmarkResponse
5 pytest offline tests (FastAPI TestClient + mocked DB + weasyprint stub)

Refs: AvitoScraper_v2_Implementation_Plan Stage 4a.
This commit is contained in:
lekss361 2026-05-23 16:19:36 +03:00
parent 5e90797a9f
commit a425f4eef0
4 changed files with 431 additions and 1 deletions

View file

@ -19,6 +19,14 @@ from app.core.db import get_db
from app.services import cian_session as cian_session_svc
from app.services.geocoder import geocode
from app.services.scrapers.avito import AvitoScraper
from app.services.scrapers.avito_detail import fetch_detail, save_detail_enrichment
from app.services.scrapers.avito_houses import fetch_house_catalog, save_house_catalog_enrichment
from app.services.scrapers.avito_imv import (
IMVAddressNotFoundError,
evaluate_via_imv,
save_imv_evaluation,
save_imv_placement_history,
)
from app.services.scrapers.base import save_listings
from app.services.scrapers.cian import CianScraper
from app.services.scrapers.n1 import N1Scraper
@ -303,3 +311,130 @@ async def test_cian_auth(
user_id = state.get("user", {}).get("userId")
return {"authenticated": True, "userId": user_id, "reason": None}
# ── Stage 4a: Avito enrichment endpoints (single-shot debug + manual triggers) ──
@router.post("/scrape/avito-house", dependencies=[Depends(require_admin)])
async def scrape_avito_house(
db: Annotated[Session, Depends(get_db)],
house_url: str,
) -> dict[str, object]:
"""Enrichment ОДНОГО дома Avito Houses Catalog.
Body params (query): `house_url=/catalog/houses/<slug>/<id>` (absolute или relative path).
Flow: fetch_house_catalog save_house_catalog_enrichment.
Returns counters {'house_id', 'reviews', 'sellers', 'listings_linked', 'placement_history'}.
"""
try:
enrichment = await fetch_house_catalog(house_url)
except Exception as e:
logger.exception("avito-house: fetch failed for %s", house_url)
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
try:
counters = save_house_catalog_enrichment(db, enrichment)
except Exception as e:
logger.exception("avito-house: save failed for %s", house_url)
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
logger.info("avito-house ok: %s%s", house_url, counters)
return {"ok": True, "house_url": house_url, "counters": counters}
@router.post("/scrape/avito-detail", dependencies=[Depends(require_admin)])
async def scrape_avito_detail(
db: Annotated[Session, Depends(get_db)],
item_url: str,
) -> dict[str, object]:
"""Detail enrichment ОДНОГО listing.
Body params (query): `item_url=/ekaterinburg/kvartiry/...` (absolute или relative).
Flow: fetch_detail save_detail_enrichment (UPDATE listings WHERE source='avito').
Returns {'ok', 'item_id', 'updated'}.
"""
try:
enrichment = await fetch_detail(item_url)
except Exception as e:
logger.exception("avito-detail: fetch failed for %s", item_url)
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
try:
updated = save_detail_enrichment(db, enrichment)
except Exception as e:
logger.exception("avito-detail: save failed for %s", item_url)
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
if not updated:
# Listing с этим source_id ещё не существует в БД — был bypass через
# /admin/scrape/avito-detail для несуществующего listing.
logger.warning("avito-detail: no listing matched source_id=%s", enrichment.item_id)
logger.info("avito-detail ok: %s item_id=%s updated=%s", item_url, enrichment.item_id, updated)
return {"ok": True, "item_id": enrichment.item_id, "updated": updated}
@router.post("/scrape/avito-imv", dependencies=[Depends(require_admin)])
async def scrape_avito_imv(
db: Annotated[Session, Depends(get_db)],
address: str,
rooms: int,
area_m2: float,
floor: int,
floor_at_home: int,
house_type: str,
renovation_type: str,
has_balcony: bool = False,
has_loggia: bool = False,
) -> dict[str, object]:
"""Debug-trigger Avito IMV evaluation. БЕЗ cache — всегда свежий fetch.
Production IMV вызывается из estimator (Stage 3, on-demand с 24h cache).
Этот endpoint для ручной отладки / data-pipeline tools.
Returns {'ok', 'cache_key', 'recommended_price', 'range', 'market_count',
'evaluation_id', 'history_saved'}.
"""
try:
result = await evaluate_via_imv(
address=address,
rooms=rooms,
area_m2=area_m2,
floor=floor,
floor_at_home=floor_at_home,
house_type=house_type,
renovation_type=renovation_type,
has_balcony=has_balcony,
has_loggia=has_loggia,
)
except IMVAddressNotFoundError as e:
raise HTTPException(status_code=404, detail=f"IMV address not found: {e}") from e
except Exception as e:
logger.exception("avito-imv: fetch failed for %s", address)
raise HTTPException(status_code=502, detail=f"fetch failed: {e}") from e
try:
eval_id = save_imv_evaluation(db, result)
history_saved = save_imv_placement_history(db, eval_id, result.placement_history)
except Exception as e:
logger.exception("avito-imv: save failed for %s", address)
raise HTTPException(status_code=500, detail=f"save failed: {e}") from e
logger.info(
"avito-imv ok: addr=%s recommended=%d evaluation_id=%d history=%d",
address[:60],
result.recommended_price,
eval_id,
history_saved,
)
return {
"ok": True,
"cache_key": result.cache_key,
"recommended_price": result.recommended_price,
"range": [result.lower_price, result.higher_price],
"market_count": result.market_count,
"evaluation_id": eval_id,
"history_saved": history_saved,
}

View file

@ -19,7 +19,14 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.trade_in import AggregatedEstimate, AnalogLot, PhotoMeta, TradeInEstimateInput
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
HouseInfoForEstimate,
IMVBenchmarkResponse,
PhotoMeta,
TradeInEstimateInput,
)
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
logger = logging.getLogger(__name__)
@ -584,3 +591,149 @@ def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
)
).mappings().fetchone()
return dict(row) if row else {}
# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────
@router.get("/estimate/{estimate_id}/houses", response_model=list[HouseInfoForEstimate])
def get_estimate_houses(
estimate_id: UUID,
db: Annotated[Session, Depends(get_db)],
) -> list[HouseInfoForEstimate]:
"""House(s) информация для estimate.
Логика: estimate сохранён с target_address + lat/lon. Ищем houses которые:
- либо лежат в радиусе 500м от target (PostGIS)
- либо linked через listings.house_id_fk у аналогов оценки
Возвращаем top-5 ближайших домов с их characteristics из houses table.
Пустой список если нет matches.
"""
# Сначала достанем target_lat/lon из estimate
target = db.execute(
text(
"""
SELECT lat, lon FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
),
{"id": str(estimate_id)},
).fetchone()
if target is None:
raise HTTPException(status_code=404, detail="estimate not found")
if target.lat is None or target.lon is None:
return [] # нет координат → не можем искать дома
rows = db.execute(
text(
"""
SELECT
id AS house_id, ext_house_id, address, short_address,
lat, lon, year_built, total_floors, house_type,
passenger_elevators, cargo_elevators,
has_concierge, closed_yard, has_playground, parking_type,
developer_name, rating, reviews_count,
COALESCE(raw_characteristics, '[]'::jsonb) AS raw_characteristics,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
FROM houses
WHERE source = 'avito'
AND geom IS NOT NULL
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, 500)
ORDER BY distance_m
LIMIT 5
"""
),
{"lat": target.lat, "lon": target.lon},
).mappings().all()
return [
HouseInfoForEstimate(**{k: v for k, v in row.items() if k != "distance_m"})
for row in rows
]
@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
def get_estimate_imv_benchmark(
estimate_id: UUID,
db: Annotated[Session, Depends(get_db)],
) -> IMVBenchmarkResponse:
"""Avito IMV benchmark для estimate (для UI badge «наша 6.4М · Avito 6.29М»).
Источники lookup:
1. avito_imv_evaluations WHERE estimate_id = :id (если linked в estimator)
2. Если не linked fallback: most recent IMV для same address (TTL 24h)
"""
# Сначала пытаемся найти directly linked
row = db.execute(
text(
"""
SELECT cache_key, recommended_price, lower_price, higher_price,
market_count, fetched_at
FROM avito_imv_evaluations
WHERE estimate_id = CAST(:id AS uuid)
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"id": str(estimate_id)},
).fetchone()
if row is None:
# Fallback: same address за 24h (на случай если link не успел)
est = db.execute(
text(
"""
SELECT address FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
),
{"id": str(estimate_id)},
).fetchone()
if est is None:
raise HTTPException(status_code=404, detail="estimate not found")
if est.address:
row = db.execute(
text(
"""
SELECT cache_key, recommended_price, lower_price, higher_price,
market_count, fetched_at
FROM avito_imv_evaluations
WHERE address = :address
AND fetched_at > NOW() - INTERVAL '24 hours'
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"address": est.address},
).fetchone()
if row is None:
return IMVBenchmarkResponse(available=False)
# Get our_median_price для compare
our = db.execute(
text(
"""
SELECT median_price FROM trade_in_estimates
WHERE id = CAST(:id AS uuid)
"""
),
{"id": str(estimate_id)},
).fetchone()
our_median = our.median_price if our else None
diff_pct = None
if our_median and row.recommended_price:
diff_pct = round((our_median - row.recommended_price) / row.recommended_price * 100, 1)
return IMVBenchmarkResponse(
available=True,
cache_key=row.cache_key,
recommended_price=row.recommended_price,
lower_price=row.lower_price,
higher_price=row.higher_price,
market_count=row.market_count,
fetched_at=row.fetched_at,
our_median_price=our_median,
diff_pct=diff_pct,
)

View file

@ -86,3 +86,45 @@ class PhotoMeta(BaseModel):
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

View file

@ -0,0 +1,100 @@
"""Offline smoke for Stage 4a API endpoints. Mocks scrapers + uses FastAPI TestClient.
Тестируем routing/schema только, реальные scrape/save мокаем.
"""
import os
import sys
from unittest.mock import MagicMock
# Используем psycopg (v3) driver — psycopg2 не установлен в этом проекте
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint требует GTK-библиотеки (нет на Windows/CI без GTK).
# trade_in.py импортирует generate_trade_in_pdf на уровне модуля —
# заглушаем weasyprint до любого импорта из app.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@pytest.fixture
def app() -> FastAPI:
"""Mount только нужные роутеры с overridden DB dep."""
from app.api.v1 import admin as admin_module
from app.api.v1 import trade_in as trade_in_module
from app.core.db import get_db
application = FastAPI()
application.include_router(admin_module.router, prefix="/api/v1/admin")
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
def override_db():
mock_db = MagicMock()
yield mock_db
application.dependency_overrides[get_db] = override_db
return application
def test_scrape_avito_house_endpoint_exists(app):
"""Endpoint mounted на /api/v1/admin/scrape/avito-house — POST + query param."""
client = TestClient(app)
# Без mocks scraper упадёт — но мы тестим только что 422 (missing query) != 404.
r = client.post("/api/v1/admin/scrape/avito-house")
assert r.status_code == 422 # missing 'house_url' query → validation error, не 404
def test_scrape_avito_detail_endpoint_exists(app):
client = TestClient(app)
r = client.post("/api/v1/admin/scrape/avito-detail")
assert r.status_code == 422
def test_scrape_avito_imv_endpoint_exists(app):
client = TestClient(app)
r = client.post("/api/v1/admin/scrape/avito-imv")
assert r.status_code == 422 # missing required query params
def test_get_estimate_houses_returns_empty_when_no_coords(app):
"""Estimate без lat/lon → пустой массив."""
from app.core.db import get_db
fake_target = MagicMock()
fake_target.lat = None
fake_target.lon = None
def override_db_no_coords():
m = MagicMock()
m.execute.return_value.fetchone.return_value = fake_target
yield m
app.dependency_overrides[get_db] = override_db_no_coords
client = TestClient(app)
r = client.get("/api/v1/trade-in/estimate/00000000-0000-0000-0000-000000000000/houses")
assert r.status_code == 200
assert r.json() == []
def test_get_imv_benchmark_unavailable(app):
"""Если IMV нет в БД для estimate — возвращаем available=False или 404 (no estimate)."""
from app.core.db import get_db
def override_db_empty():
m = MagicMock()
# Linked SELECT возвращает None, fallback SELECT тоже None
m.execute.return_value.fetchone.return_value = None
yield m
app.dependency_overrides[get_db] = override_db_empty
client = TestClient(app)
r = client.get("/api/v1/trade-in/estimate/00000000-0000-0000-0000-000000000000/imv-benchmark")
# endpoint вернёт 404 потому что fallback тоже не находит address → нужен estimate row
# Этот тест проверяет что эндпоинт mounted; 404 ОК
assert r.status_code in (200, 404)