8dfafcc63d
- 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
233 lines
6.8 KiB
Python
233 lines
6.8 KiB
Python
"""
|
|
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,
|
|
)
|