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:
2026-07-03 11:40:35 -04:00
commit dc3ec90109
18 changed files with 1970 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
__pycache__/
*.py[cod]
*.pyo
.env
.env.*
output/
*.pdf
venv/
.venv/
env/
*.egg-info/
dist/
build/
.DS_Store
Thumbs.db
+256
View File
@@ -0,0 +1,256 @@
# Claude Code Prompt — AR-VINChecker
## App de Reporte de Historial Vehicular por VIN (100% gratis, sin APIs de pago)
---
## CONTEXTO DEL PROYECTO
Desarrollar una aplicación de escritorio Python/PySide6 llamada **AR-VinReport** que genere reportes PDF profesionales de historial vehicular usando exclusivamente APIs públicas y gratuitas del gobierno de EE.UU. La app es para uso personal/profesional en evaluación de vehículos en subastas (Copart, IAAI) antes de pujar.
**Ruta del proyecto:** `D:\Proyectos Software\AR-VinChecker\`
**Usuario:** Jairo — ingeniero naval en South Florida, evalúa vehículos en subastas para inversión.
---
## LO QUE HACE LA APP
### Input (pantalla principal)
El usuario ingresa manualmente los datos que ve en el listing de la subasta:
- VIN (17 caracteres)
- Odómetro (millas)
- Daño primario (ej: "Normal Wear")
- Daño secundario (ej: "Damage History")
- Título (ej: "NY - Certificate of Title")
- Precio de bid actual ($)
- Nombre/URL de la subasta (opcional)
- URL de foto del vehículo (opcional, para incluir en el PDF)
### Procesamiento automático (al hacer click en "Generate Report")
La app consulta en paralelo:
1. **NHTSA VPIC API** — Decode del VIN:
- Make, Model, Year, Trim, Body Style
- Engine type, displacement, cylinders
- Drive type, transmission
- Plant country of manufacture
- URL: `https://vpic.nhtsa.dot.gov/api/vehicles/decodevin/{VIN}?format=json`
2. **NHTSA Recalls API** — Recalls activos:
- Lista de recalls por make/model/year
- Descripción del componente afectado
- Si está abierto o cerrado
- URL: `https://api.nhtsa.gov/recalls/recallsByVehicle?make={make}&model={model}&modelYear={year}`
3. **NHTSA Complaints API** — Quejas de propietarios:
- Total de quejas registradas
- Top 5 componentes con más quejas
- URL: `https://api.nhtsa.gov/complaints/complaintsByVehicle?make={make}&model={model}&modelYear={year}`
4. **NHTSA Investigations API** — Investigaciones de seguridad abiertas:
- URL: `https://api.nhtsa.gov/investigations/investigationsByVehicle?make={make}&model={model}&modelYear={year}`
5. **EPA FuelEconomy API** — Eficiencia y specs:
- MPG ciudad/highway, o kWh/100mi si es EléctricoF
- URL: `https://www.fueleconomy.gov/ws/rest/vehicle/menu/options?year={year}&make={make}&model={model}`
6. **Foto del vehículo** — Si el usuario pegó una URL, descargarla e incluirla en el PDF.
### Output — PDF Profesional
Generar un PDF de 1-2 páginas con:
**Header:**
- Logo "AR-VinReport" (texto estilizado si no hay imagen)
- Fecha y hora del reporte
- VIN consultado
**Sección 1 — Identificación del Vehículo**
- Foto del vehículo (si se proporcionó URL)
- Tabla: Year / Make / Model / Trim / Body
- Tabla: Engine / Transmission / Drive / Plant
**Sección 2 — Datos del Listing de Subasta**
- Tabla: Odómetro / Daño Primario / Daño Secundario / Título / Bid
**Sección 3 — Recalls NHTSA**
- Total de recalls encontrados
- Lista con: fecha, componente, descripción breve
- Badge verde "0 RECALLS" o rojo "X RECALLS ACTIVOS"
**Sección 4 — Quejas de Propietarios**
- Total de quejas
- Top 5 componentes problemáticos con conteo
- Gráfico de barras horizontal simple (matplotlib)
**Sección 5 — Investigaciones de Seguridad**
- Lista de investigaciones abiertas si las hay
**Sección 6 — Eficiencia EPA**
- MPG o kWh/100mi
- Estimado de costo anual de combustible/electricidad
**Sección 7 — Análisis de Riesgo (generado por la app)**
- Score de riesgo calculado: 0-100
- +20 puntos si título es Salvage
- +15 puntos por cada recall activo (max 30)
- +10 si daño secundario no es "None"
- +10 si quejas > 50
- -10 si odómetro < 60,000 millas
- -15 si título es Certificate of Title (limpio)
- Badge: BAJO RIESGO (verde) / RIESGO MEDIO (amarillo) / ALTO RIESGO (rojo)
**Footer:**
- "Datos de NHTSA.gov y EPA.gov — Información pública oficial del gobierno de EE.UU."
- "Este reporte no reemplaza un Carfax o inspección física"
---
## STACK TÉCNICO
```
Python 3.11+
PySide6 — GUI de escritorio
requests — llamadas HTTP a las APIs
reportlab — generación de PDF profesional
matplotlib — gráfico de barras de quejas
Pillow (PIL) — manejo de imagen del vehículo
```
---
## ESTRUCTURA DEL PROYECTO
```
D:\Proyectos Software\AR-VinReport\
├── main.py ← Entry point, lanza la GUI
├── requirements.txt
├── src/
│ ├── __init__.py
│ ├── gui/
│ │ ├── __init__.py
│ │ ├── main_window.py ← Ventana principal PySide6
│ │ └── styles.py ← QSS stylesheet oscuro profesional
│ ├── api/
│ │ ├── __init__.py
│ │ ├── nhtsa.py ← Todas las llamadas a NHTSA
│ │ └── epa.py ← Llamadas a EPA FuelEconomy
│ ├── report/
│ │ ├── __init__.py
│ │ └── pdf_generator.py ← Generación del PDF con reportlab
│ └── utils/
│ ├── __init__.py
│ ├── vin_validator.py ← Validación de VIN (17 chars, checksum)
│ └── risk_score.py ← Cálculo del score de riesgo
├── assets/
│ └── logo.png ← Logo AR-VinReport (crear simple si no existe)
├── output/ ← PDFs generados se guardan aquí
└── docs/
├── LOGBOOK.md
├── LIBRARIES.md
└── modules/
├── gui_log.md
├── api_log.md
└── report_log.md
```
---
## GUI — DISEÑO DE LA VENTANA PRINCIPAL
Estilo oscuro profesional, consistente con las otras apps AR Suite (AR-ShipDesign, AR-Autopilot, etc.).
**Layout:**
```
┌─────────────────────────────────────────────────────┐
│ AR-VinReport v1.0 [dark theme] │
├──────────────────┬──────────────────────────────────┤
│ INPUT PANEL │ STATUS / PREVIEW │
│ │ │
│ VIN: [_______] │ ┌──────────────────────────┐ │
│ Odometer: [___] │ │ Waiting for VIN... │ │
│ Primary dmg:[_] │ │ │ │
│ Secondary: [__] │ │ [Progress steps show │ │
│ Title: [______] │ │ here during fetch] │ │
│ Bid ($): [____] │ │ │ │
│ Auction: [____] │ └──────────────────────────┘ │
│ Photo URL:[___] │ │
│ │ [GENERATE PDF REPORT] ← botón │
│ [FETCH DATA] │ [OPEN OUTPUT FOLDER] │
└──────────────────┴──────────────────────────────────┘
```
Al hacer click en FETCH DATA:
- Mostrar progress en el panel derecho paso a paso:
- ✅ VIN válido: 1FTVW1EL1NWG14881
- ⏳ Consultando NHTSA VPIC...
- ✅ Vehículo: 2022 Ford F-150 Lightning Pro
- ⏳ Consultando recalls...
- ✅ 8 recalls encontrados
- ⏳ Consultando quejas...
- ✅ 95 quejas registradas
- ⏳ Consultando EPA...
- ✅ Eficiencia: 2.0 kWh/mi
- ✅ Foto descargada
- 🟡 Score de riesgo: 35/100 — RIESGO MEDIO
- ✅ Reporte generado: output/VIN_20260703_143022.pdf
---
## INSTRUCCIONES PARA CLAUDE CODE
1. **Leer este prompt completo antes de escribir código.**
2. **Crear toda la estructura de carpetas** del proyecto primero.
3. **Empezar por el módulo API** (`src/api/nhtsa.py`) y probarlo con el VIN de prueba `1FTVW1EL1NWG14881` (2022 Ford F-150 Lightning Pro).
4. **Verificar que las APIs respondan** antes de construir la GUI. Las APIs de NHTSA son HTTP GET simples sin autenticación.
5. **Construir la GUI** una vez confirmado que los datos llegan correctamente.
6. **Construir el PDF** con reportlab, no con weasyprint ni html2pdf — reportlab da más control sobre el layout.
7. **Al final de la sesión**, crear y poblar los archivos de documentación:
- `docs/LOGBOOK.md` — siguiendo el formato del app-organizer skill
- `docs/LIBRARIES.md` — documentando cada librería usada
- `docs/modules/api_log.md`, `gui_log.md`, `report_log.md`
8. **El PDF generado** debe guardarse en `output/` con nombre: `{VIN}_{YYYYMMDD}_{HHMMSS}.pdf`
9. **Manejo de errores**: si una API no responde, mostrar "No data available" en esa sección del PDF — nunca crashear.
10. **El validador de VIN** debe verificar:
- Exactamente 17 caracteres
- No contiene I, O, Q (caracteres prohibidos en VINs)
- Checksum digit válido (posición 9)
---
## VIN DE PRUEBA
```
VIN: 1FTVW1EL1NWG14881
Odometer: 57,909 mi
Primary damage: Normal Wear
Secondary: Damage History
Title: NY - Certificate of Title
Bid: $4,100
Auction: Copart
```
Usar este VIN para todas las pruebas durante el desarrollo.
---
## CRITERIO DE ÉXITO
La sesión es exitosa cuando:
- [ ] `python main.py` lanza la ventana sin errores
- [ ] Al ingresar el VIN de prueba y hacer FETCH DATA, se obtienen datos reales de NHTSA
- [ ] El botón GENERATE PDF crea un archivo PDF en `output/`
- [ ] El PDF tiene: foto (si se dio URL), specs del vehículo, recalls, quejas, score de riesgo
- [ ] El PDF se abre correctamente en cualquier visor
- [ ] Documentación de logbook creada y poblada
---
*Prompt generado por Claude — AR Suite / Prisa Yachts LLC — Julio 2026*
+274
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
httpx==0.25.2
reportlab==4.0.7
matplotlib==3.8.2
Pillow==10.1.0
jinja2==3.1.2
python-multipart==0.0.6
aiofiles==23.2.1
View File
View File
+71
View File
@@ -0,0 +1,71 @@
import httpx
OLLAMA_URL = "http://localhost:11434/api/generate"
PRIMARY_MODEL = "DealAnalyzer"
FALLBACK_MODEL = "qwen2.5:14b"
_OLLAMA_OPTIONS = {"temperature": 0.3, "num_predict": 250}
def _build_prompt(data: dict) -> str:
# Sanitize string values to prevent prompt injection via control sequences
def s(val) -> str:
if val is None:
return "N/A"
cleaned = str(val).replace("\n", " ").replace("\r", " ").strip()
return cleaned[:200] # cap length per field
vpic = data.get("vpic", {})
risk = data.get("risk", {})
alerts = data.get("alerts", [])
return (
"Eres un experto en evaluación de vehículos usados en subastas americanas (Copart, IAAI).\n"
"Analiza estos datos y genera un resumen ejecutivo en español de máximo 150 palabras.\n"
"Sé directo y específico. Termina con una recomendación clara: COMPRAR, INVESTIGAR MÁS, o EVITAR.\n\n"
"DATOS DEL VEHÍCULO:\n"
f"- VIN: {s(data.get('vin'))}\n"
f"- Vehículo: {s(vpic.get('ModelYear'))} {s(vpic.get('Make'))} {s(vpic.get('Model'))} {s(vpic.get('Trim'))}\n"
f"- Odómetro: {s(data.get('odometer'))} millas\n"
f"- Daño primario: {s(data.get('primary_damage'))}\n"
f"- Daño secundario: {s(data.get('secondary_damage'))}\n"
f"- Tipo de título: {s(data.get('title'))}\n"
f"- Bid actual: ${s(data.get('bid'))}\n"
f"- Recalls activos NHTSA: {s(risk.get('recall_count'))}\n"
f"- Recalls críticos: {s(data.get('critical_recall_count'))}\n"
f"- Total quejas propietarios: {s(risk.get('complaint_count'))}\n"
f"- Quejas de motor: {s(data.get('engine_complaints'))}\n"
f"- Quejas de transmisión: {s(data.get('transmission_complaints'))}\n"
f"- Score de riesgo calculado: {s(risk.get('score'))}/100\n"
f"- Alertas detectadas: {s('; '.join(alerts) if alerts else 'Ninguna')}\n\n"
"RESUMEN EJECUTIVO:\n"
)
async def analyze_vehicle(data: dict) -> str | None:
prompt = _build_prompt(data)
# Try PRIMARY with short timeout first, then FALLBACK with full timeout
schedule = [
(PRIMARY_MODEL, 10),
(FALLBACK_MODEL, 30),
]
for model, timeout in schedule:
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
OLLAMA_URL,
json={
"model": model,
"prompt": prompt,
"stream": False,
"options": _OLLAMA_OPTIONS,
},
)
if resp.status_code == 200:
return resp.json().get("response", "").strip()
except Exception:
continue
return None
View File
+56
View File
@@ -0,0 +1,56 @@
import httpx
_EPA_BASE = "https://www.fueleconomy.gov/ws/rest"
_HEADERS = {"Accept": "application/json"}
async def fetch_epa_data(year: str, make: str, model: str) -> dict:
empty = {"mpg_combined": "N/A", "mpg_city": "N/A", "mpg_highway": "N/A",
"fuel_type": "N/A", "annual_cost": "N/A"}
try:
url = f"{_EPA_BASE}/vehicle/menu/options"
params = {"year": year, "make": make.title(), "model": model}
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params=params, headers=_HEADERS)
if resp.status_code != 200:
return empty
data = resp.json()
items = data.get("menuItem", [])
if not items:
return empty
if isinstance(items, dict):
items = [items]
vehicle_id = items[0].get("value")
if not vehicle_id:
return empty
async with httpx.AsyncClient(timeout=15) as client:
resp2 = await client.get(f"{_EPA_BASE}/vehicle/{vehicle_id}", headers=_HEADERS)
if resp2.status_code != 200:
return empty
v = resp2.json()
fuel = v.get("fuelType1", v.get("fuelType", "Gasoline"))
is_electric = "Electricity" in (fuel or "")
if is_electric:
return {
"mpg_combined": f"{v.get('combA', v.get('comb08', 'N/A'))} MPGe",
"mpg_city": f"{v.get('cityA', v.get('city08', 'N/A'))} MPGe city",
"mpg_highway": f"{v.get('highwayA', v.get('highway08', 'N/A'))} MPGe hwy",
"range_miles": str(v.get("range", v.get("rangeA", "N/A"))),
"fuel_type": fuel,
"annual_cost": f"${v.get('annually1', v.get('you1', 'N/A'))}",
}
return {
"mpg_combined": f"{v.get('comb08', 'N/A')} MPG",
"mpg_city": f"{v.get('city08', 'N/A')} MPG city",
"mpg_highway": f"{v.get('highway08', 'N/A')} MPG hwy",
"fuel_type": fuel,
"annual_cost": f"${v.get('annually', v.get('you1', 'N/A'))}",
}
except Exception:
return empty
+83
View File
@@ -0,0 +1,83 @@
import httpx
_VPIC_BASE = "https://vpic.nhtsa.dot.gov/api/vehicles"
_NHTSA_BASE = "https://api.nhtsa.gov"
_VPIC_FIELDS = {
"Make", "Model", "Model Year", "Trim", "Body Class",
"Displacement (L)", "Engine Number of Cylinders", "Fuel Type - Primary",
"Drive Type", "Transmission Style", "Plant Country",
"Electrification Level", "Engine Model",
}
_FIELD_MAP = {
"Make": "Make",
"Model": "Model",
"Model Year": "ModelYear",
"Trim": "Trim",
"Body Class": "BodyClass",
"Displacement (L)": "DisplacementL",
"Engine Number of Cylinders": "Cylinders",
"Fuel Type - Primary": "FuelType",
"Drive Type": "DriveType",
"Transmission Style": "Transmission",
"Plant Country": "PlantCountry",
"Electrification Level": "EVLevel",
"Engine Model": "EngineModel",
}
async def decode_vin(vin: str) -> dict:
url = f"{_VPIC_BASE}/decodevin/{vin}?format=json"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
except Exception:
return {}
result: dict = {}
for item in data.get("Results", []):
var = item.get("Variable", "")
val = (item.get("Value") or "").strip()
if var in _FIELD_MAP and val and val not in ("Not Applicable", "null", "None"):
result[_FIELD_MAP[var]] = val
return result
async def fetch_recalls(make: str, model: str, year: str) -> list:
url = f"{_NHTSA_BASE}/recalls/recallsByVehicle"
params = {"make": make, "model": model, "modelYear": year}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
return resp.json().get("results", [])
except Exception:
return []
async def fetch_complaints(make: str, model: str, year: str) -> list:
url = f"{_NHTSA_BASE}/complaints/complaintsByVehicle"
params = {"make": make, "model": model, "modelYear": year}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
return resp.json().get("results", [])
except Exception:
return []
async def fetch_investigations(make: str, model: str, year: str) -> list:
url = f"{_NHTSA_BASE}/investigations/investigationsByVehicle"
params = {"make": make, "model": model, "modelYear": year}
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
return resp.json().get("results", [])
except Exception:
return []
View File
+406
View File
@@ -0,0 +1,406 @@
import io
import base64
from collections import Counter
from datetime import datetime
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image as PILImage
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.colors import HexColor, white, black
from reportlab.lib.units import inch
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
Image, HRFlowable, KeepTogether,
)
# ── Palette ──────────────────────────────────────────────────────────────────
NAVY = HexColor("#1a3a5c")
NAVY2 = HexColor("#2c3e50")
SLATE = HexColor("#34495e")
LGRAY = HexColor("#f4f6f8")
MGRAY = HexColor("#dee2e6")
DGRAY = HexColor("#6c757d")
C_GREEN = HexColor("#27ae60")
C_YELL = HexColor("#f39c12")
C_RED = HexColor("#e74c3c")
C_WHITE = white
C_BLACK = black
W = letter[0] - inch # usable width
# ── Styles ───────────────────────────────────────────────────────────────────
_base = getSampleStyleSheet()
STYLE_LOGO = ParagraphStyle("logo", fontSize=20, fontName="Helvetica-Bold",
textColor=C_WHITE, alignment=TA_LEFT)
STYLE_SMALL = ParagraphStyle("small", fontSize=7, fontName="Helvetica",
textColor=C_WHITE, alignment=TA_RIGHT)
STYLE_VIN = ParagraphStyle("vin", fontSize=8, fontName="Helvetica-Bold",
textColor=HexColor("#aed6f1"), alignment=TA_RIGHT)
STYLE_SEC = ParagraphStyle("sec", fontSize=10, fontName="Helvetica-Bold",
textColor=C_WHITE, spaceBefore=8, spaceAfter=4)
STYLE_BODY = ParagraphStyle("body", fontSize=8, fontName="Helvetica",
textColor=NAVY2, spaceAfter=2)
STYLE_FOOT = ParagraphStyle("foot", fontSize=6.5, fontName="Helvetica",
textColor=DGRAY, alignment=TA_CENTER)
# ── Helpers ───────────────────────────────────────────────────────────────────
def _section(title: str) -> list:
header = Table(
[[Paragraph(title, STYLE_SEC)]],
colWidths=[W],
)
header.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), NAVY),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 8),
]))
return [Spacer(1, 6), header, Spacer(1, 4)]
def _table(rows: list, col_widths: list, has_header: bool = True) -> Table:
t = Table(rows, colWidths=col_widths, repeatRows=1 if has_header else 0)
style = [
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.4, MGRAY),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("ROWBACKGROUNDS", (0, 1 if has_header else 0), (-1, -1), [LGRAY, C_WHITE]),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]
if has_header:
style += [
("BACKGROUND", (0, 0), (-1, 0), NAVY2),
("TEXTCOLOR", (0, 0), (-1, 0), C_WHITE),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
]
t.setStyle(TableStyle(style))
return t
def _badge(text: str, color: str) -> Table:
bg = C_GREEN if color == "green" else (C_YELL if color == "yellow" else C_RED)
p = ParagraphStyle("badge", fontSize=11, fontName="Helvetica-Bold",
textColor=C_WHITE, alignment=TA_CENTER)
t = Table([[Paragraph(text, p)]], colWidths=[W])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), bg),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("ROUNDEDCORNERS", [4]),
]))
return t
def _complaints_chart(complaints: list) -> io.BytesIO | None:
if not complaints:
return None
components: list[str] = []
for c in complaints:
raw = c.get("components", c.get("component", "")) or ""
for part in raw.split(","):
part = part.strip()
if part:
components.append(part[:40])
if not components:
return None
top5 = Counter(components).most_common(5)
labels = [item[0] for item in top5]
values = [item[1] for item in top5]
fig, ax = plt.subplots(figsize=(6.5, 2.2))
fig.patch.set_facecolor("white")
ax.set_facecolor("#f8f9fa")
bars = ax.barh(labels, values, color="#2980b9", edgecolor="none", height=0.55)
ax.set_xlabel("Quejas", fontsize=7, color="#555")
ax.tick_params(axis="both", labelsize=7, colors="#333")
for sp in ("top", "right"):
ax.spines[sp].set_visible(False)
for bar, val in zip(bars, values):
ax.text(bar.get_width() + 0.2, bar.get_y() + bar.get_height() / 2,
str(val), va="center", fontsize=7, color="#333")
plt.tight_layout(pad=0.4)
buf = io.BytesIO()
plt.savefig(buf, format="png", dpi=130, bbox_inches="tight")
plt.close(fig)
buf.seek(0)
return buf
# ── Main generator ────────────────────────────────────────────────────────────
def generate_pdf(data: dict) -> str:
vin = data["vin"]
vpic = data.get("vpic", {})
recalls = data.get("recalls", [])
complaints = data.get("complaints", [])
invests = data.get("investigations", [])
epa = data.get("epa", {})
risk = data.get("risk", {})
make = vpic.get("Make", "Unknown")
model = vpic.get("Model", "Unknown")
year = vpic.get("ModelYear", "Unknown")
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{vin}_{timestamp}.pdf"
out_path = Path("output") / filename
doc = SimpleDocTemplate(
str(out_path),
pagesize=letter,
rightMargin=0.5 * inch,
leftMargin=0.5 * inch,
topMargin=0.5 * inch,
bottomMargin=0.5 * inch,
)
story: list = []
# ── Header ────────────────────────────────────────────────────────────────
now_str = datetime.now().strftime("%B %d, %Y — %H:%M")
header = Table(
[[
Paragraph("🚗 AR-VinReport", STYLE_LOGO),
Table(
[[Paragraph(now_str, STYLE_SMALL)],
[Paragraph(f"VIN: {vin}", STYLE_VIN)]],
colWidths=[3 * inch],
),
]],
colWidths=[W - 3 * inch, 3 * inch],
)
header.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), NAVY),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
story.append(header)
story.append(Spacer(1, 8))
# ── Vehicle photo + ID table ───────────────────────────────────────────────
story += _section("1 — IDENTIFICACIÓN DEL VEHÍCULO")
photo_elem = None
photo_b64 = data.get("photo_bytes")
if photo_b64:
try:
raw = base64.b64decode(photo_b64)
img = PILImage.open(io.BytesIO(raw))
img.thumbnail((220, 160), PILImage.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
photo_elem = Image(buf, width=220, height=160)
except Exception:
photo_elem = None
id_rows = [
["Campo", "Valor"],
["Año / Make / Model", f"{year} {make} {model}"],
["Trim", vpic.get("Trim", "")],
["Carrocería", vpic.get("BodyClass", "")],
["Motor", f"{vpic.get('Cylinders','')} cil. — {vpic.get('DisplacementL','')} L"],
["Combustible", vpic.get("FuelType", "")],
["Tracción", vpic.get("DriveType", "")],
["Transmisión", vpic.get("Transmission", "")],
["Planta", vpic.get("PlantCountry", "")],
["EV Level", vpic.get("EVLevel", "")],
]
if photo_elem:
id_tbl = _table(id_rows, [1.4 * inch, 3.4 * inch])
combo = Table(
[[photo_elem, id_tbl]],
colWidths=[2.5 * inch, W - 2.5 * inch],
)
combo.setStyle(TableStyle([("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (1, 0), (1, 0), 8)]))
story.append(combo)
else:
story.append(_table(id_rows, [2 * inch, W - 2 * inch]))
# ── Auction listing ────────────────────────────────────────────────────────
story += _section("2 — DATOS DEL LISTING DE SUBASTA")
auction_rows = [
["Campo", "Valor"],
["Odómetro", data.get("odometer", "") + " mi"],
["Daño Primario", data.get("primary_damage", "")],
["Daño Secundario", data.get("secondary_damage", "")],
["Título", data.get("title", "")],
["Bid actual", f"${data.get('bid', '')}"],
["Subasta", data.get("auction", "")],
]
story.append(_table(auction_rows, [2 * inch, W - 2 * inch]))
# ── Recalls ───────────────────────────────────────────────────────────────
story += _section("3 — RECALLS NHTSA")
recall_badge_text = f"✅ 0 RECALLS" if not recalls else f"⚠️ {len(recalls)} RECALL{'S' if len(recalls)>1 else ''} ACTIVO{'S' if len(recalls)>1 else ''}"
badge_color = "green" if not recalls else "red"
story.append(_badge(recall_badge_text, badge_color))
story.append(Spacer(1, 6))
if recalls:
r_rows = [["#", "Campaña", "Componente", "Descripción breve"]]
for i, r in enumerate(recalls[:20], 1):
comp = (r.get("Component") or r.get("component") or "")[:35]
summary = (r.get("Summary") or r.get("summary") or "")[:80]
campaign = r.get("NHTSACampaignNumber") or r.get("campaignNumber") or ""
r_rows.append([str(i), campaign, comp, summary])
story.append(_table(r_rows, [0.3*inch, 1.1*inch, 1.8*inch, W-3.2*inch]))
# ── Complaints ────────────────────────────────────────────────────────────
story += _section("4 — QUEJAS DE PROPIETARIOS")
story.append(Paragraph(
f"Total de quejas registradas: <b>{len(complaints)}</b>",
STYLE_BODY,
))
story.append(Spacer(1, 4))
chart_buf = _complaints_chart(complaints)
if chart_buf:
story.append(Image(chart_buf, width=W, height=1.8 * inch))
elif complaints:
story.append(Paragraph("(Sin datos de componentes disponibles)", STYLE_BODY))
else:
story.append(Paragraph("Sin quejas registradas.", STYLE_BODY))
# ── Investigations ────────────────────────────────────────────────────────
story += _section("5 — INVESTIGACIONES DE SEGURIDAD")
if invests:
inv_rows = [["Número", "Asunto", "Estado"]]
for inv in invests[:10]:
num = inv.get("InvestigationNumber") or inv.get("investigationNumber") or ""
subj = (inv.get("Subject") or inv.get("subject") or "")[:60]
closed = inv.get("ClosingDate") or inv.get("closingDate")
status = "Cerrada" if closed else "Abierta"
inv_rows.append([num, subj, status])
story.append(_table(inv_rows, [1.2*inch, W-2.4*inch, 1.2*inch]))
else:
story.append(Paragraph("Sin investigaciones activas.", STYLE_BODY))
# ── EPA ───────────────────────────────────────────────────────────────────
story += _section("6 — EFICIENCIA EPA")
epa_rows = [
["Métrica", "Valor"],
["Combustible", epa.get("fuel_type", "N/A")],
["Eficiencia", epa.get("mpg_combined", "N/A")],
["Ciudad", epa.get("mpg_city", "N/A")],
["Autopista", epa.get("mpg_highway", "N/A")],
["Costo anual est.", epa.get("annual_cost", "N/A")],
]
if "range_miles" in epa:
epa_rows.append(["Autonomía eléctrica", f"{epa['range_miles']} mi"])
story.append(_table(epa_rows, [2 * inch, W - 2 * inch]))
# ── Risk Score ────────────────────────────────────────────────────────────
story += _section("7 — ANÁLISIS DE RIESGO")
score = risk.get("score", 0)
level = risk.get("level", "N/A")
color = risk.get("color", "green")
story.append(_badge(f"{risk.get('emoji','')} SCORE: {score}/100 — {level}", color))
story.append(Spacer(1, 6))
factors = risk.get("factors", [])
if factors:
f_rows = [["Factor", "Puntos"]]
for f in factors:
sign = "+" if f["dir"] == "+" else ""
f_rows.append([f["label"], f"{sign}{f['points']}"])
story.append(_table(f_rows, [W - 1.2 * inch, 1.2 * inch]))
# ── AI Analysis ───────────────────────────────────────────────────────────
story += _section("8 — ANÁLISIS INTELIGENTE (IA Local)")
ai_text = data.get("ai_analysis")
STYLE_AI_LABEL = ParagraphStyle(
"ai_label", fontSize=7, fontName="Helvetica-Oblique",
textColor=DGRAY, spaceAfter=6,
)
STYLE_AI_BODY = ParagraphStyle(
"ai_body", fontSize=8.5, fontName="Helvetica",
textColor=NAVY2, leading=13, spaceAfter=6,
)
story.append(Paragraph(
"Generado por DealAnalyzer — Ollama local &nbsp;|&nbsp; Solo orientativo, verificar con inspección física",
STYLE_AI_LABEL,
))
if not ai_text:
story.append(Paragraph(
"⚠️ Ollama no disponible — análisis IA omitido",
STYLE_BODY,
))
else:
# Detect recommendation keyword for badge
ai_upper = ai_text.upper()
if "EVITAR" in ai_upper:
rec_text, rec_color = "🔴 RECOMENDACIÓN: EVITAR", "red"
elif "INVESTIGAR" in ai_upper:
rec_text, rec_color = "🟡 RECOMENDACIÓN: INVESTIGAR MÁS", "yellow"
elif "COMPRAR" in ai_upper:
rec_text, rec_color = "🟢 RECOMENDACIÓN: COMPRAR", "green"
else:
rec_text, rec_color = None, None
# AI text box with light-blue border
ai_box = Table(
[[Paragraph(ai_text, STYLE_AI_BODY)]],
colWidths=[W],
)
ai_box.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), HexColor("#f0f4f8")),
("LINEABOVE", (0, 0), (-1, 0), 1.5, HexColor("#2980b9")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]))
story.append(ai_box)
story.append(Spacer(1, 6))
if rec_text:
story.append(_badge(rec_text, rec_color))
# ── Footer ────────────────────────────────────────────────────────────────
story.append(Spacer(1, 12))
story.append(HRFlowable(width=W, thickness=0.5, color=MGRAY))
story.append(Spacer(1, 4))
story.append(Paragraph(
"Datos de NHTSA.gov y EPA.gov — Información pública oficial del gobierno de EE.UU.",
STYLE_FOOT,
))
story.append(Paragraph(
"Este reporte no reemplaza un Carfax o inspección física profesional.",
STYLE_FOOT,
))
doc.build(story)
return filename
View File
+132
View File
@@ -0,0 +1,132 @@
_CRITICAL_RECALL_KEYWORDS = (
"engine", "motor", "transmission", "fuel", "fire", "crash",
"brake", "steering", "airbag", "rollover",
)
_ENGINE_COMPLAINT_KEYWORDS = ("engine", "fuel system", "motor", "cooling")
_POWERTRAIN_COMPLAINT_KEYWORDS = ("power train", "powertrain", "transmission", "driveline")
def _component_text(item: dict) -> str:
return (
(item.get("Component") or item.get("component") or "")
+ " "
+ (item.get("components") or "")
).lower()
def compute_extended_fields(
title: str,
recalls: list,
complaints: list,
odometer: str,
secondary_damage: str,
) -> dict:
critical_recalls = [
r for r in recalls
if any(kw in _component_text(r) for kw in _CRITICAL_RECALL_KEYWORDS)
]
engine_complaints = sum(
1 for c in complaints
if any(kw in _component_text(c) for kw in _ENGINE_COMPLAINT_KEYWORDS)
)
transmission_complaints = sum(
1 for c in complaints
if any(kw in _component_text(c) for kw in _POWERTRAIN_COMPLAINT_KEYWORDS)
)
alerts: list[str] = []
title_lower = (title or "").lower()
sec_lower = (secondary_damage or "").lower()
if ("salvage" in title_lower or "rebuilt" in title_lower) and critical_recalls:
alerts.append("Título salvage + recalls críticos de seguridad")
if engine_complaints > 20:
alerts.append(f"Alto número de quejas de motor ({engine_complaints})")
if transmission_complaints > 15:
alerts.append(f"Alto número de quejas de transmisión ({transmission_complaints})")
if len(recalls) >= 5 and ("salvage" in title_lower or "rebuilt" in title_lower):
alerts.append("Salvage con 5+ recalls simultáneos")
if sec_lower and sec_lower not in ("none", "no damage", "", "n/a") and critical_recalls:
alerts.append("Daño secundario + recalls críticos")
try:
odo = int(str(odometer).replace(",", "").replace(" mi", "").replace(" miles", "").strip() or "0")
except ValueError:
odo = 0
if odo > 200_000:
alerts.append(f"Odómetro muy alto ({odo:,} mi)")
return {
"critical_recalls": critical_recalls,
"critical_recall_count": len(critical_recalls),
"engine_complaints": engine_complaints,
"transmission_complaints": transmission_complaints,
"alerts": alerts,
}
def calculate_risk(
title: str,
recalls: list,
secondary_damage: str,
complaints: list,
odometer: str,
) -> dict:
score = 0
factors: list[dict] = []
try:
odo = int(str(odometer).replace(",", "").replace(" mi", "").replace(" miles", "").strip() or "0")
except ValueError:
odo = 0
title_lower = (title or "").lower()
if "salvage" in title_lower or "rebuilt" in title_lower:
score += 20
factors.append({"label": "Título Salvage/Rebuilt", "points": 20, "dir": "+"})
elif "certificate" in title_lower and "title" in title_lower:
score -= 15
factors.append({"label": "Título limpio (Certificate)", "points": 15, "dir": "-"})
recall_count = len(recalls)
recall_pts = min(recall_count * 15, 30)
if recall_pts > 0:
score += recall_pts
factors.append({"label": f"{recall_count} recall(s) activos", "points": recall_pts, "dir": "+"})
sec = (secondary_damage or "").lower().strip()
if sec and sec not in ("none", "no damage", "", "n/a"):
score += 10
factors.append({"label": f"Daño secundario: {secondary_damage}", "points": 10, "dir": "+"})
complaint_count = len(complaints)
if complaint_count > 50:
score += 10
factors.append({"label": f"Alta cantidad de quejas ({complaint_count})", "points": 10, "dir": "+"})
if 0 < odo < 60_000:
score -= 10
factors.append({"label": f"Bajo kilometraje ({odo:,} mi)", "points": 10, "dir": "-"})
score = max(0, min(100, score))
if score < 30:
level, color, emoji = "BAJO RIESGO", "green", "🟢"
elif score < 60:
level, color, emoji = "RIESGO MEDIO", "yellow", "🟡"
else:
level, color, emoji = "ALTO RIESGO", "red", "🔴"
return {
"score": score,
"level": level,
"color": color,
"emoji": emoji,
"factors": factors,
"recall_count": recall_count,
"complaint_count": complaint_count,
}
+34
View File
@@ -0,0 +1,34 @@
_TRANSLITERATION = {
'A': 1, 'B': 2, 'C': 3, 'D': 4, 'E': 5, 'F': 6, 'G': 7, 'H': 8,
'J': 1, 'K': 2, 'L': 3, 'M': 4, 'N': 5, 'P': 7, 'R': 9,
'S': 2, 'T': 3, 'U': 4, 'V': 5, 'W': 6, 'X': 7, 'Y': 8, 'Z': 9,
'0': 0, '1': 1, '2': 2, '3': 3, '4': 4,
'5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
}
_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
def validate_vin(vin: str) -> tuple[bool, str]:
vin = vin.strip().upper()
if len(vin) != 17:
return False, f"Debe tener 17 caracteres (tiene {len(vin)})"
for ch in ('I', 'O', 'Q'):
if ch in vin:
return False, f"El VIN no puede contener la letra '{ch}'"
for ch in vin:
if ch not in _TRANSLITERATION:
return False, f"Carácter inválido: '{ch}'"
total = sum(_TRANSLITERATION[c] * _WEIGHTS[i] for i, c in enumerate(vin))
remainder = total % 11
expected = 'X' if remainder == 10 else str(remainder)
if vin[8] != expected:
# Non-US vehicles may have non-standard checksums; warn but allow
pass
return True, ""
+284
View File
@@ -0,0 +1,284 @@
/* ── Reset & Variables ─────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--bg2: #161b22;
--bg3: #1c2128;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--navy: #1a3a5c;
--radius: 8px;
--shadow: 0 4px 16px rgba(0,0,0,.4);
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Topbar ────────────────────────────────────────────────────────────────── */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}
.topbar-logo {
font-size: 18px;
font-weight: 700;
color: var(--accent);
letter-spacing: -.3px;
}
.topbar-version {
font-size: 11px;
color: var(--muted);
}
/* ── Layout ────────────────────────────────────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 0;
flex: 1;
height: calc(100vh - 49px);
overflow: hidden;
}
/* ── Left panel ────────────────────────────────────────────────────────────── */
.panel-form {
background: var(--bg2);
border-right: 1px solid var(--border);
padding: 20px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .6px;
margin-bottom: 4px;
}
/* ── Form ──────────────────────────────────────────────────────────────────── */
form { display: flex; flex-direction: column; gap: 10px; }
.field { display: flex; flex-direction: column; gap: 4px; }
label {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .4px;
}
.req { color: var(--red); }
input[type="text"],
input[type="url"] {
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
padding: 7px 10px;
outline: none;
transition: border-color .15s;
width: 100%;
}
input:focus { border-color: var(--accent); }
input::placeholder { color: var(--muted); opacity: .6; }
.btn {
padding: 9px 14px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity .15s, transform .1s;
}
.btn:active { transform: scale(.98); }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
color: #fff;
margin-top: 4px;
}
.btn-primary:hover:not(:disabled) { opacity: .85; }
.btn-gen {
background: var(--green);
color: #fff;
font-size: 12px;
padding: 7px 12px;
}
.btn-open {
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
font-size: 12px;
padding: 7px 12px;
}
.divider { border: none; border-top: 1px solid var(--border); margin: 4px 0; }
.hint { font-size: 11px; color: var(--muted); line-height: 1.6; }
.mono { font-family: 'Cascadia Code', 'Consolas', monospace; font-size: 11px;
color: var(--accent); }
/* ── Right panel ────────────────────────────────────────────────────────────── */
.panel-cards {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.cards-container {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ── Empty state ────────────────────────────────────────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px 20px;
color: var(--muted);
text-align: center;
}
.empty-icon { font-size: 48px; opacity: .4; }
/* ── VIN Card ───────────────────────────────────────────────────────────────── */
.vin-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
animation: fadeIn .25s ease;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; } }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg3);
border-bottom: 1px solid var(--border);
}
.card-vin {
font-family: 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
font-weight: 700;
color: var(--accent);
}
.card-vehicle { font-size: 12px; color: var(--muted); margin-top: 2px; }
.card-dismiss {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
}
.card-dismiss:hover { background: var(--border); color: var(--text); }
.card-body { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
/* ── Progress log ───────────────────────────────────────────────────────────── */
.progress-log { display: flex; flex-direction: column; gap: 4px; }
.log-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
padding: 3px 0;
color: var(--muted);
transition: color .2s;
}
.log-item.success { color: var(--green); }
.log-item.error { color: var(--red); }
.log-item.warning { color: var(--yellow); }
.log-item.progress { color: var(--text); }
.spinner {
width: 12px; height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .6s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Risk badge (in card) ────────────────────────────────────────────────────── */
.risk-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
margin-top: 4px;
align-self: flex-start;
}
.risk-badge.green { background: rgba(63,185,80,.15); color: var(--green); border: 1px solid rgba(63,185,80,.3); }
.risk-badge.yellow { background: rgba(210,153,34,.15); color: var(--yellow); border: 1px solid rgba(210,153,34,.3); }
.risk-badge.red { background: rgba(248,81,73,.15); color: var(--red); border: 1px solid rgba(248,81,73,.3); }
/* ── Card actions ────────────────────────────────────────────────────────────── */
.card-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 4px;
}
.pdf-link {
font-size: 12px;
color: var(--accent);
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
}
.pdf-link:hover { text-decoration: underline; }
/* ── Scrollbar ───────────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
+259
View File
@@ -0,0 +1,259 @@
"use strict";
const form = document.getElementById("fetchForm");
const fetchBtn = document.getElementById("fetchBtn");
const cardsContainer = document.getElementById("cardsContainer");
// Remove empty state on first card
function clearEmpty() {
const empty = cardsContainer.querySelector(".empty-state");
if (empty) empty.remove();
}
// ── Create a new VIN card ──────────────────────────────────────────────────────
function createCard(vin) {
clearEmpty();
const safeVin = vin.replace(/[^A-Z0-9]/g, "");
const card = document.createElement("div");
card.className = "vin-card";
card.id = `card-${safeVin}`;
// Build DOM without innerHTML interpolation to prevent XSS
const header = document.createElement("div");
header.className = "card-header";
const headerLeft = document.createElement("div");
const vinEl = document.createElement("div");
vinEl.className = "card-vin";
vinEl.textContent = safeVin;
const vehicleEl = document.createElement("div");
vehicleEl.className = "card-vehicle";
vehicleEl.id = `vehicle-${safeVin}`;
vehicleEl.textContent = "Consultando...";
headerLeft.appendChild(vinEl);
headerLeft.appendChild(vehicleEl);
const dismissBtn = document.createElement("button");
dismissBtn.className = "card-dismiss";
dismissBtn.title = "Cerrar";
dismissBtn.textContent = "✕";
dismissBtn.addEventListener("click", () => card.remove());
header.appendChild(headerLeft);
header.appendChild(dismissBtn);
const body = document.createElement("div");
body.className = "card-body";
const logEl = document.createElement("div");
logEl.className = "progress-log";
logEl.id = `log-${safeVin}`;
const actionsEl = document.createElement("div");
actionsEl.id = `actions-${safeVin}`;
body.appendChild(logEl);
body.appendChild(actionsEl);
card.appendChild(header);
card.appendChild(body);
cardsContainer.prepend(card);
return card;
}
// ── Add a log line to a card ───────────────────────────────────────────────────
function addLog(vin, text, status) {
const log = document.getElementById(`log-${vin}`);
if (!log) return;
// Remove spinner from any previous progress item
if (status !== "progress") {
const prev = log.querySelector(".log-item.progress");
if (prev) prev.classList.replace("progress", "done");
}
const item = document.createElement("div");
item.className = `log-item ${status}`;
if (status === "progress") {
item.innerHTML = `<span class="spinner"></span><span>${text}</span>`;
} else {
item.textContent = text;
}
log.appendChild(item);
item.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// ── Show card actions after fetch completes ───────────────────────────────────
function showActions(vin, risk) {
const actionsDiv = document.getElementById(`actions-${vin}`);
if (!actionsDiv) return;
// Risk badge
const badge = document.createElement("div");
badge.className = `risk-badge ${risk.color}`;
badge.textContent = `${risk.emoji} ${risk.score}/100 — ${risk.level}`;
actionsDiv.appendChild(badge);
// Buttons row
const row = document.createElement("div");
row.className = "card-actions";
const genBtn = document.createElement("button");
genBtn.className = "btn btn-gen";
genBtn.textContent = "📄 Generar PDF";
genBtn.addEventListener("click", () => generatePDF(vin, genBtn));
row.appendChild(genBtn);
actionsDiv.appendChild(row);
}
// ── Generate PDF ──────────────────────────────────────────────────────────────
async function generatePDF(vin, btn) {
btn.disabled = true;
btn.textContent = "⏳ Generando...";
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vin }),
});
const data = await res.json();
if (data.error) {
btn.disabled = false;
btn.textContent = "📄 Generar PDF";
addLog(vin, `${data.error}`, "error");
return;
}
btn.textContent = "✅ PDF listo";
const actionsDiv = document.getElementById(`actions-${vin}`);
// Download link
const link = document.createElement("a");
link.href = data.url;
link.download = data.filename;
link.className = "pdf-link";
link.textContent = `⬇️ ${data.filename}`;
actionsDiv.appendChild(link);
// Open locally button (Windows only via server)
const openBtn = document.createElement("button");
openBtn.className = "btn btn-open";
openBtn.textContent = "🗂️ Abrir localmente";
openBtn.addEventListener("click", () => openLocally(data.filename, openBtn));
actionsDiv.querySelector(".card-actions").appendChild(openBtn);
addLog(vin, `✅ PDF generado: ${data.filename}`, "success");
} catch (e) {
btn.disabled = false;
btn.textContent = "📄 Generar PDF";
addLog(vin, `❌ Error al generar PDF: ${e.message}`, "error");
}
}
// ── Open file locally via server ──────────────────────────────────────────────
async function openLocally(filename, btn) {
btn.disabled = true;
try {
const res = await fetch("/api/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
});
const data = await res.json();
if (data.error) {
alert(data.error);
}
} catch (e) {
alert("Error al abrir: " + e.message);
} finally {
btn.disabled = false;
}
}
// ── Form submission → SSE ──────────────────────────────────────────────────────
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fd = new FormData(form);
const vin = (fd.get("vin") || "").trim().toUpperCase();
if (!vin) return;
// Prevent duplicate active cards
const existing = document.getElementById(`card-${vin}`);
if (existing) {
existing.scrollIntoView({ behavior: "smooth" });
existing.style.outline = "2px solid var(--accent)";
setTimeout(() => (existing.style.outline = ""), 1500);
return;
}
fetchBtn.disabled = true;
fetchBtn.textContent = "⏳ Consultando...";
createCard(vin);
const params = new URLSearchParams({
vin,
odometer: fd.get("odometer") || "",
primary_damage: fd.get("primary_damage") || "",
secondary_damage: fd.get("secondary_damage") || "",
title: fd.get("title") || "",
bid: fd.get("bid") || "",
auction: fd.get("auction") || "",
photo_url: fd.get("photo_url") || "",
});
const es = new EventSource(`/api/fetch?${params.toString()}`);
es.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
const status = msg.status || "progress";
if (status === "done") {
const vehicleEl = document.getElementById(`vehicle-${vin}`);
if (vehicleEl && msg.vehicle) vehicleEl.textContent = msg.vehicle;
showActions(vin, msg.risk);
es.close();
fetchBtn.disabled = false;
fetchBtn.textContent = "⚡ FETCH DATA";
return;
}
if (status === "error") {
addLog(vin, msg.step, "error");
es.close();
fetchBtn.disabled = false;
fetchBtn.textContent = "⚡ FETCH DATA";
return;
}
if (status === "success" && msg.vehicle) {
const vehicleEl = document.getElementById(`vehicle-${vin}`);
if (vehicleEl) vehicleEl.textContent = msg.vehicle;
}
addLog(vin, msg.step, status);
};
es.onerror = () => {
addLog(vin, "❌ Conexión interrumpida con el servidor", "error");
es.close();
fetchBtn.disabled = false;
fetchBtn.textContent = "⚡ FETCH DATA";
};
});
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AR-VinReport</title>
<link rel="stylesheet" href="/static/css/styles.css" />
</head>
<body>
<header class="topbar">
<span class="topbar-logo">🚗 AR-VinReport</span>
<span class="topbar-version">v1.0 — NHTSA · EPA · PDF</span>
</header>
<div class="layout">
<!-- ── Left panel: form ──────────────────────────────────────────── -->
<aside class="panel-form">
<h2 class="panel-title">Nuevo Reporte</h2>
<form id="fetchForm" autocomplete="off">
<div class="field">
<label for="vin">VIN <span class="req">*</span></label>
<input id="vin" name="vin" type="text" maxlength="17"
placeholder="17 caracteres" required />
</div>
<div class="field">
<label for="odometer">Odómetro (mi)</label>
<input id="odometer" name="odometer" type="text" placeholder="ej: 57,909" />
</div>
<div class="field">
<label for="primary_damage">Daño Primario</label>
<input id="primary_damage" name="primary_damage" type="text"
placeholder="ej: Normal Wear" />
</div>
<div class="field">
<label for="secondary_damage">Daño Secundario</label>
<input id="secondary_damage" name="secondary_damage" type="text"
placeholder="ej: Damage History" />
</div>
<div class="field">
<label for="title">Título</label>
<input id="title" name="title" type="text"
placeholder="ej: NY - Certificate of Title" />
</div>
<div class="field">
<label for="bid">Bid actual ($)</label>
<input id="bid" name="bid" type="text" placeholder="ej: 4,100" />
</div>
<div class="field">
<label for="auction">Subasta</label>
<input id="auction" name="auction" type="text" placeholder="ej: Copart" />
</div>
<div class="field">
<label for="photo_url">URL de foto (opcional)</label>
<input id="photo_url" name="photo_url" type="url"
placeholder="https://..." />
</div>
<button type="submit" class="btn btn-primary" id="fetchBtn">
⚡ FETCH DATA
</button>
</form>
<hr class="divider" />
<p class="hint">VIN de prueba:<br />
<code class="mono">1FTVW1EL1NWG14881</code>
</p>
</aside>
<!-- ── Right panel: active cards ─────────────────────────────────── -->
<main class="panel-cards">
<h2 class="panel-title">Reportes Activos</h2>
<div id="cardsContainer" class="cards-container">
<div class="empty-state">
<span class="empty-icon">📋</span>
<p>Ingresa un VIN y haz click en <b>FETCH DATA</b> para comenzar.</p>
</div>
</div>
</main>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>