Files
AR-VINchecker/main.py
T
alro65 df09627ccb feat: 7-module VINchecker v2 — NICB, auction history, Florida DMV, theft stats, odometer fraud, safety ratings, risk score expansion
New modules: nicb.py, auction_history.py, dmv_florida.py, theft_stats.py, odometer_validator.py
Expanded risk.py with 8 new factors (NICB alert, safety rating, auction flags, odo validation, theft level, odo fraud, DMV lien, open VIN recalls)
PDF now has 11 sections including NICB check, theft stats, odometer fraud, odometer validation, auction history, Florida HSMV
AI prompt enriched with all new data fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 13:54:16 -04:00

328 lines
14 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,
fetch_vin_recalls, fetch_safety_ratings, check_odometer_complaints,
)
from src.api.nicb import check_nicb
from src.api.auction_history import search_auction_history
from src.api.dmv_florida import check_florida_dmv
from src.api.theft_stats import get_theft_risk
from src.report.pdf import generate_pdf
from src.utils.risk import calculate_risk, compute_extended_fields
from src.utils.odometer_validator import validate_odometer
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 — NHTSA + EPA + NICB + Subastas + DMV (todo en paralelo)
yield evt("⏳ Consultando NHTSA, EPA, NICB, historial web, Florida DMV...", "progress")
(
recalls, complaints, invests,
vin_recalls, safety_ratings, epa,
nicb, auction_hist, dmv_fl,
) = await asyncio.gather(
fetch_recalls(make, model, year),
fetch_complaints(make, model, year),
fetch_investigations(make, model, year),
fetch_vin_recalls(vin_clean),
fetch_safety_ratings(year, make, model),
fetch_epa_data(year, make, model),
check_nicb(vin_clean),
search_auction_history(vin_clean),
check_florida_dmv(vin_clean),
)
# Local enrichment (no I/O)
theft_info = get_theft_risk(make, model)
odo_check = validate_odometer(year, odometer or "0")
odo_fraud = check_odometer_complaints(complaints)
open_vin_recalls = len([
r for r in vin_recalls
if not (r.get("RemedyAvailability") or "").lower().startswith("remedy")
])
stars = safety_ratings.get("overall", "")
nicb_status = nicb.get("status", "NO_DISPONIBLE")
yield evt(
f"✅ {len(recalls)} recalls · {len(complaints)} quejas · "
f"NICB: {nicb_status} · "
f"{stars+'★' if stars and stars != 'NR' else 'seg. N/A'}",
"success",
)
await asyncio.sleep(0.05)
# 4 — 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",
nicb_status=nicb_status,
safety_overall=stars,
auction_flags=auction_hist.get("flags", []),
auction_pts=auction_hist.get("extra_points", 0),
odo_extra_pts=odo_check.get("extra_points", 0),
theft_level=theft_info.get("level", ""),
odo_fraud_count=odo_fraud.get("count", 0),
dmv_has_lien=dmv_fl.get("has_lien", False),
open_vin_recalls=open_vin_recalls,
)
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,
"recalls": recalls,
"complaints": complaints,
"vin_recalls": vin_recalls,
"safety_ratings": safety_ratings,
"nicb": nicb,
"auction_hist": auction_hist,
"dmv_fl": dmv_fl,
"theft_info": theft_info,
"odo_check": odo_check,
"odo_fraud": odo_fraud,
"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,
"vin_recalls": vin_recalls,
"safety_ratings": safety_ratings,
"nicb": nicb,
"auction_hist": auction_hist,
"dmv_fl": dmv_fl,
"theft_info": theft_info,
"odo_check": odo_check,
"odo_fraud": odo_fraud,
"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)