feat: AR-VINchecker v1.0 — VIN report generator with AI analysis
FastAPI server querying NHTSA/EPA APIs, generating PDF reports with risk scoring, and local Ollama AI analysis via DealAnalyzer. Security hardened: XSS fix, SSRF protection, CORS restricted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from src.ai.ollama_analyzer import analyze_vehicle
|
||||
from src.api.epa import fetch_epa_data
|
||||
from src.api.nhtsa import decode_vin, fetch_complaints, fetch_investigations, fetch_recalls
|
||||
from src.report.pdf import generate_pdf
|
||||
from src.utils.risk import calculate_risk, compute_extended_fields
|
||||
from src.utils.validator import validate_vin
|
||||
|
||||
Path("output").mkdir(exist_ok=True)
|
||||
|
||||
_PRIVATE_NETS = [
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
]
|
||||
|
||||
|
||||
def _is_safe_photo_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
host = parsed.hostname or ""
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
return not any(addr in net for net in _PRIVATE_NETS)
|
||||
except ValueError:
|
||||
blocked = ("localhost", "internal", "metadata", "169.254")
|
||||
return not any(b in host.lower() for b in blocked)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
app = FastAPI(title="AR-VinReport", version="1.0.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3050", "http://127.0.0.1:3050"],
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
app.mount("/output", StaticFiles(directory="output"), name="output")
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
_store: dict[str, dict] = {}
|
||||
|
||||
|
||||
# ── Pages ─────────────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def index(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
# ── SSE fetch endpoint ────────────────────────────────────────────────────────
|
||||
@app.get("/api/fetch")
|
||||
async def fetch(
|
||||
vin: str = Query(...),
|
||||
odometer: Optional[str] = Query(None),
|
||||
primary_damage: Optional[str] = Query(None),
|
||||
secondary_damage: Optional[str] = Query(None),
|
||||
title: Optional[str] = Query(None),
|
||||
bid: Optional[str] = Query(None),
|
||||
auction: Optional[str] = Query(None),
|
||||
photo_url: Optional[str] = Query(None),
|
||||
):
|
||||
async def stream():
|
||||
def evt(step: str, status: str = "progress", **extra) -> str:
|
||||
payload = {"step": step, "status": status, **extra}
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
try:
|
||||
# 1 — Validate VIN
|
||||
vin_clean = vin.strip().upper()
|
||||
ok, err = validate_vin(vin_clean)
|
||||
if not ok:
|
||||
yield evt(f"❌ VIN inválido: {err}", "error")
|
||||
return
|
||||
yield evt(f"✅ VIN válido: {vin_clean}", "success")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 2 — Decode VIN
|
||||
yield evt("⏳ Consultando NHTSA VPIC...", "progress")
|
||||
vpic = await decode_vin(vin_clean)
|
||||
if not vpic:
|
||||
yield evt("⚠️ VPIC sin datos — continuando de todas formas", "warning")
|
||||
make = vpic.get("Make", "")
|
||||
model = vpic.get("Model", "")
|
||||
year = vpic.get("ModelYear", "")
|
||||
yield evt(f"✅ Vehículo: {year} {make} {model}", "success")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 3 — Recalls + Complaints + Investigations (paralelo)
|
||||
yield evt("⏳ Consultando recalls, quejas e investigaciones...", "progress")
|
||||
recalls, complaints, invests = await asyncio.gather(
|
||||
fetch_recalls(make, model, year),
|
||||
fetch_complaints(make, model, year),
|
||||
fetch_investigations(make, model, year),
|
||||
)
|
||||
yield evt(
|
||||
f"✅ {len(recalls)} recalls · {len(complaints)} quejas · {len(invests)} investigaciones",
|
||||
"success",
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 4 — EPA
|
||||
yield evt("⏳ Consultando EPA FuelEconomy...", "progress")
|
||||
epa = await fetch_epa_data(year, make, model)
|
||||
yield evt(f"✅ Eficiencia: {epa.get('mpg_combined', 'N/A')}", "success")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 5 — Photo
|
||||
photo_bytes = None
|
||||
if photo_url and photo_url.strip():
|
||||
yield evt("⏳ Descargando foto del vehículo...", "progress")
|
||||
if not _is_safe_photo_url(photo_url.strip()):
|
||||
yield evt("⚠️ URL de foto no permitida (dominio bloqueado)", "warning")
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(photo_url.strip())
|
||||
if r.status_code == 200:
|
||||
photo_bytes = r.content
|
||||
yield evt("✅ Foto descargada", "success")
|
||||
else:
|
||||
yield evt("⚠️ No se pudo descargar la foto (HTTP error)", "warning")
|
||||
except Exception:
|
||||
yield evt("⚠️ Error al descargar la foto", "warning")
|
||||
|
||||
# 6 — Risk score + extended fields
|
||||
risk = calculate_risk(
|
||||
title=title or "",
|
||||
recalls=recalls,
|
||||
secondary_damage=secondary_damage or "",
|
||||
complaints=complaints,
|
||||
odometer=odometer or "0",
|
||||
)
|
||||
extended = compute_extended_fields(
|
||||
title=title or "",
|
||||
recalls=recalls,
|
||||
complaints=complaints,
|
||||
odometer=odometer or "0",
|
||||
secondary_damage=secondary_damage or "",
|
||||
)
|
||||
score = risk["score"]
|
||||
level = risk["level"]
|
||||
emoji = risk["emoji"]
|
||||
yield evt(f"{emoji} Score: {score}/100 — {level}", "success")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# 7 — Ollama AI analysis
|
||||
yield evt("⏳ Consultando IA local (DealAnalyzer)...", "progress")
|
||||
vehicle_data_for_ai = {
|
||||
"vin": vin_clean,
|
||||
"odometer": odometer or "—",
|
||||
"primary_damage": primary_damage or "—",
|
||||
"secondary_damage": secondary_damage or "—",
|
||||
"title": title or "—",
|
||||
"bid": bid or "—",
|
||||
"vpic": vpic,
|
||||
"risk": risk,
|
||||
"critical_recall_count": extended["critical_recall_count"],
|
||||
"engine_complaints": extended["engine_complaints"],
|
||||
"transmission_complaints": extended["transmission_complaints"],
|
||||
"alerts": extended["alerts"],
|
||||
}
|
||||
ai_analysis = await analyze_vehicle(vehicle_data_for_ai)
|
||||
if ai_analysis:
|
||||
yield evt("✅ Análisis IA generado", "success")
|
||||
else:
|
||||
yield evt("⚠️ Ollama no disponible — sección IA omitida", "warning")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# Store for PDF generation
|
||||
_store[vin_clean] = {
|
||||
"vin": vin_clean,
|
||||
"odometer": odometer or "—",
|
||||
"primary_damage": primary_damage or "—",
|
||||
"secondary_damage": secondary_damage or "—",
|
||||
"title": title or "—",
|
||||
"bid": bid or "—",
|
||||
"auction": auction or "—",
|
||||
"photo_url": photo_url or "",
|
||||
"photo_bytes": base64.b64encode(photo_bytes).decode() if photo_bytes else None,
|
||||
"vpic": vpic,
|
||||
"recalls": recalls,
|
||||
"complaints": complaints,
|
||||
"investigations": invests,
|
||||
"epa": epa,
|
||||
"risk": risk,
|
||||
"ai_analysis": ai_analysis,
|
||||
**extended,
|
||||
}
|
||||
|
||||
vehicle_label = f"{year} {make} {model}".strip() or vin_clean
|
||||
yield evt(
|
||||
"✅ Datos listos — puedes generar el reporte PDF",
|
||||
"done",
|
||||
vin=vin_clean,
|
||||
vehicle=vehicle_label,
|
||||
risk=risk,
|
||||
)
|
||||
|
||||
except Exception:
|
||||
yield evt("❌ Error inesperado procesando la solicitud", "error")
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ── Generate PDF ───────────────────────────────────────────────────────────────
|
||||
@app.post("/api/generate")
|
||||
async def generate(request: Request):
|
||||
body = await request.json()
|
||||
vin = (body.get("vin") or "").strip().upper()
|
||||
|
||||
if vin not in _store:
|
||||
return {"error": "No hay datos para este VIN. Haz fetch primero."}
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
filename = await loop.run_in_executor(_executor, generate_pdf, _store[vin])
|
||||
except Exception:
|
||||
return {"error": "Error generando el PDF"}
|
||||
|
||||
return {"filename": filename, "url": f"/output/{filename}"}
|
||||
|
||||
|
||||
# ── Open file locally (Windows only) ──────────────────────────────────────────
|
||||
@app.post("/api/open")
|
||||
async def open_file(request: Request):
|
||||
body = await request.json()
|
||||
filename = (body.get("filename") or "").replace("..", "").replace("/", "").replace("\\", "")
|
||||
path = Path("output") / filename
|
||||
|
||||
if not path.exists():
|
||||
return {"error": "Archivo no encontrado"}
|
||||
if sys.platform != "win32":
|
||||
return {"error": "Auto-open solo disponible en Windows"}
|
||||
|
||||
os.startfile(str(path.resolve()))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=3050, reload=True)
|
||||
Reference in New Issue
Block a user