Files
AR-VINchecker/main.py
T
alro65 dc3ec90109 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>
2026-07-03 11:40:35 -04:00

275 lines
11 KiB
Python

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)