""" 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, )