feat: car-help web UI with browser view, search API, and buying guide
- FastAPI backend on port 8080 with noVNC proxy for same-origin iframe - Split-panel UI: live browser (55%) + search/guide/saved tabs (45%) - Car buying guide: target models, pre-purchase checklist, mechanics nearby, negotiation tips, budget breakdown - Scoring/ranking engine for listings (Toyota Prius = highest) - Docker container runs Xvfb + x11vnc + noVNC + FastAPI together - Accessible at browser.ampbox.io via cloudflared tunnel
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Car-help web app -- FastAPI backend serving the search UI + guide.
|
||||
|
||||
Runs on port 8080 inside the Docker container. Proxies noVNC from port 6080
|
||||
so the browser iframe works same-origin.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from guide import get_guide
|
||||
|
||||
app = FastAPI(title="car-help", version="0.1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
RESULTS_DIR = Path("results")
|
||||
NOVNC_UPSTREAM = "http://localhost:6080"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static files + index (FIRST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
html = Path("static/index.html").read_text()
|
||||
return HTMLResponse(html)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API routes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/api/guide")
|
||||
async def guide():
|
||||
return JSONResponse(get_guide())
|
||||
|
||||
|
||||
@app.get("/api/search")
|
||||
async def search():
|
||||
"""Trigger a car search using Playwright."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"uv", "run", "python", "search.py",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env={
|
||||
"PATH": "/usr/local/bin:/usr/bin:/bin",
|
||||
"HOME": "/root",
|
||||
"DISPLAY": ":99",
|
||||
},
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=120
|
||||
)
|
||||
|
||||
# Find the most recent result file
|
||||
result_files = sorted(RESULTS_DIR.glob("search_*.json"))
|
||||
if result_files:
|
||||
data = json.loads(result_files[-1].read_text())
|
||||
return JSONResponse({
|
||||
"status": "ok",
|
||||
"file": result_files[-1].name,
|
||||
"total": data.get("total_listings", 0),
|
||||
"listings": data.get("listings", []),
|
||||
"stdout": stdout.decode()[-500:] if stdout else "",
|
||||
})
|
||||
return JSONResponse({
|
||||
"status": "ok",
|
||||
"total": 0,
|
||||
"listings": [],
|
||||
"stdout": stdout.decode() if stdout else "",
|
||||
"stderr": stderr.decode() if stderr else "",
|
||||
})
|
||||
except asyncio.TimeoutError:
|
||||
return JSONResponse({"status": "error", "error": "Search timed out"}, 504)
|
||||
except Exception as e:
|
||||
return JSONResponse({"status": "error", "error": str(e)}, 500)
|
||||
|
||||
|
||||
@app.get("/api/results")
|
||||
async def list_results():
|
||||
"""List all saved search results."""
|
||||
RESULTS_DIR.mkdir(exist_ok=True)
|
||||
files = sorted(RESULTS_DIR.glob("search_*.json"), reverse=True)
|
||||
results = []
|
||||
for f in files[:20]:
|
||||
try:
|
||||
data = json.loads(f.read_text())
|
||||
results.append({
|
||||
"filename": f.name,
|
||||
"date": data.get("search_date", ""),
|
||||
"total": data.get("total_listings", 0),
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return JSONResponse(results)
|
||||
|
||||
|
||||
@app.get("/api/results/{filename}")
|
||||
async def get_result(filename: str):
|
||||
"""Get a specific result file."""
|
||||
path = RESULTS_DIR / filename
|
||||
if not path.exists():
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
return JSONResponse(json.loads(path.read_text()))
|
||||
|
||||
|
||||
@app.post("/api/rank")
|
||||
async def rank_listings(request: Request):
|
||||
"""Score and rank car listings."""
|
||||
body = await request.json()
|
||||
listings = body.get("listings", [])
|
||||
|
||||
def score(listing: dict) -> int:
|
||||
s = 50 # base score
|
||||
title = (listing.get("title", "") + " " + listing.get("url", "")).lower()
|
||||
|
||||
# Model bonuses
|
||||
if "prius" in title:
|
||||
s += 30
|
||||
elif "camry hybrid" in title or "camry" in title:
|
||||
s += 25
|
||||
elif "insight" in title:
|
||||
s += 20
|
||||
elif "ioniq" in title:
|
||||
s += 20
|
||||
elif "civic hybrid" in title:
|
||||
s += 15
|
||||
|
||||
# Brand bonuses
|
||||
if "toyota" in title:
|
||||
s += 10
|
||||
elif "honda" in title:
|
||||
s += 7
|
||||
elif "hyundai" in title:
|
||||
s += 5
|
||||
|
||||
# Penalties
|
||||
if "nissan" in title and "leaf" in title:
|
||||
s -= 25
|
||||
if "altima" in title:
|
||||
s -= 20
|
||||
if any(w in title for w in ["salvage", "rebuilt", "flood", "junk"]):
|
||||
s -= 20
|
||||
|
||||
# Price parsing
|
||||
price_str = listing.get("price", "")
|
||||
price_num = 0
|
||||
m = re.search(r"\$?([\d,]+)", price_str)
|
||||
if m:
|
||||
price_num = int(m.group(1).replace(",", ""))
|
||||
if 0 < price_num < 6000:
|
||||
s += 10
|
||||
elif 6000 <= price_num <= 8000:
|
||||
s += 5
|
||||
|
||||
# Positive signals
|
||||
if "one owner" in title or "single owner" in title:
|
||||
s += 8
|
||||
if "clean title" in title:
|
||||
s += 8
|
||||
|
||||
return max(0, min(100, s))
|
||||
|
||||
scored = []
|
||||
for listing in listings:
|
||||
listing["score"] = score(listing)
|
||||
scored.append(listing)
|
||||
|
||||
scored.sort(key=lambda x: x["score"], reverse=True)
|
||||
return JSONResponse(scored)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# noVNC proxy (LAST -- catch-all)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOVNC_PROXY_PATHS = re.compile(
|
||||
r"^/(vnc\.html|vnc_lite\.html|websockify|core/|vendor/|app/|include/|noVNC)"
|
||||
)
|
||||
|
||||
|
||||
@app.api_route(
|
||||
"/{path:path}",
|
||||
methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"],
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def catch_all_novnc_proxy(request: Request, path: str):
|
||||
"""Proxy noVNC assets and websockify from port 6080."""
|
||||
if not NOVNC_PROXY_PATHS.match(f"/{path}"):
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
url = f"{NOVNC_UPSTREAM}/{path}"
|
||||
if request.url.query:
|
||||
url += f"?{request.url.query}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.request(
|
||||
method=request.method,
|
||||
url=url,
|
||||
headers={
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ("host", "connection")
|
||||
},
|
||||
content=await request.body(),
|
||||
)
|
||||
headers = {}
|
||||
if "content-type" in resp.headers:
|
||||
headers["content-type"] = resp.headers["content-type"]
|
||||
return StreamingResponse(
|
||||
iter([resp.content]),
|
||||
status_code=resp.status_code,
|
||||
headers=headers,
|
||||
)
|
||||
Reference in New Issue
Block a user