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:
Ken
2026-05-25 20:34:16 +00:00
parent f09317f95b
commit 8dfafcc63d
8 changed files with 1512 additions and 92 deletions
+232
View File
@@ -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,
)