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
+39 -82
View File
@@ -1,20 +1,10 @@
"""
Car search crawler -- finds used hybrids under $10K near Woodinville WA.
Uses Playwright (headless Chromium) to handle JS-heavy car sites.
Two modes:
headless (default): uv run python search.py
headed via noVNC: docker run ... (see README)
When running in the Docker container, the browser is visible at
http://localhost:6080/vnc.html -- use this to handle CAPTCHAs,
log into Facebook Marketplace, or visually inspect results.
Playwright Chromium always runs headed on Xvfb. View live at browser.ampbox.io.
"""
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
@@ -23,26 +13,6 @@ from playwright.async_api import async_playwright
RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True)
# If DISPLAY is set (noVNC container), run headed
HEADED = bool(os.environ.get("DISPLAY"))
async def pause_for_human(page, reason: str):
"""Pause and prompt for human interaction via noVNC."""
if not HEADED:
print(f" SKIP (headless): {reason}")
return False
print(f"\n{'=' * 60}")
print(f" HUMAN NEEDED: {reason}")
print(f" Open http://localhost:6080/vnc.html to interact")
print(f" Press Enter here when done...")
print(f"{'=' * 60}")
# Wait for user to press Enter in the terminal
await asyncio.get_event_loop().run_in_executor(None, input)
return True
async def search_craigslist(page) -> list[dict]:
"""Craigslist Seattle -- simple HTML, most reliable to scrape."""
@@ -126,44 +96,6 @@ async def search_cargurus(page) -> list[dict]:
return listings
async def search_facebook(page) -> list[dict]:
"""Facebook Marketplace -- needs login (human via noVNC)."""
if not HEADED:
print(" SKIP Facebook Marketplace (requires login -- run in Docker with noVNC)")
return []
url = "https://www.facebook.com/marketplace/seattle/vehicles/?maxPrice=10000"
print(f" Opening Facebook Marketplace...")
await page.goto(url, wait_until="networkidle", timeout=60000)
# Check if login is needed
if "login" in page.url.lower():
await pause_for_human(page, "Log into Facebook, then press Enter")
await page.wait_for_timeout(3000)
await pause_for_human(page, "Apply filters (hybrid, $10K max, 50mi) and press Enter when ready")
# Scrape whatever is visible
listings = []
cards = await page.query_selector_all("[class*='marketplace']")
for card in cards[:30]:
try:
text = await card.inner_text()
link_el = await card.query_selector("a[href*='/marketplace/']")
link = await link_el.get_attribute("href") if link_el else ""
listings.append({
"source": "facebook",
"title": text[:100].strip(),
"price": "see listing",
"url": f"https://www.facebook.com{link}" if link else "",
})
except Exception:
continue
print(f" Found {len(listings)} Facebook listings")
return listings
async def search_autotempest(page) -> list[dict]:
"""AutoTempest -- meta-aggregator, searches multiple sites."""
url = (
@@ -199,19 +131,49 @@ async def search_autotempest(page) -> list[dict]:
return listings
async def search_facebook(page) -> list[dict]:
"""Facebook Marketplace -- view at browser.ampbox.io to log in if needed."""
url = "https://www.facebook.com/marketplace/seattle/vehicles/?maxPrice=10000"
print(f" Opening Facebook Marketplace...")
await page.goto(url, wait_until="networkidle", timeout=60000)
if "login" in page.url.lower():
print(" Facebook needs login -- open https://browser.ampbox.io/vnc.html to log in")
print(" Press Enter when done...")
await asyncio.get_event_loop().run_in_executor(None, input)
await page.wait_for_timeout(3000)
listings = []
cards = await page.query_selector_all("[class*='marketplace']")
for card in cards[:30]:
try:
text = await card.inner_text()
link_el = await card.query_selector("a[href*='/marketplace/']")
link = await link_el.get_attribute("href") if link_el else ""
listings.append({
"source": "facebook",
"title": text[:100].strip(),
"price": "see listing",
"url": f"https://www.facebook.com{link}" if link else "",
})
except Exception:
continue
print(f" Found {len(listings)} Facebook listings")
return listings
async def main():
mode = "HEADED (noVNC)" if HEADED else "HEADLESS"
print(f"Car Search [{mode}] -- {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print(
"Looking for: Used hybrids under $10K "
"within 50 miles of 98077 (Woodinville WA)"
)
print(f"Car Search -- {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print("Looking for: Used hybrids under $10K within 50mi of 98077 (Woodinville WA)")
print("Live browser: https://browser.ampbox.io/vnc.html")
print("=" * 70)
all_listings: list[dict] = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=not HEADED)
browser = await p.chromium.launch(headless=False)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@@ -221,11 +183,7 @@ async def main():
)
page = await context.new_page()
searches = [search_craigslist, search_cargurus, search_autotempest]
if HEADED:
searches.append(search_facebook)
for search_fn in searches:
for search_fn in [search_craigslist, search_cargurus, search_autotempest, search_facebook]:
try:
results = await search_fn(page)
all_listings.extend(results)
@@ -241,7 +199,6 @@ async def main():
json.dump(
{
"search_date": datetime.now(timezone.utc).isoformat(),
"mode": mode,
"params": {
"zip": "98077",
"max_price": 10000,