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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user