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
+1 -1
View File
@@ -24,6 +24,6 @@ COPY . /app/
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
EXPOSE 6080 EXPOSE 6080 8080
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+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,
)
Regular → Executable
+9 -8
View File
@@ -1,27 +1,28 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Start virtual display
export DISPLAY=:99 export DISPLAY=:99
Xvfb :99 -screen 0 1280x720x24 -ac & Xvfb :99 -screen 0 1280x720x24 -ac &
sleep 1 sleep 1
# Start VNC server on the virtual display (no password, listen on 5900) x11vnc -display :99 -forever -nopw -listen 0.0.0.0 -rfbport 5900 -shared 2>/dev/null &
x11vnc -display :99 -forever -nopw -listen 0.0.0.0 -rfbport 5900 -shared &
sleep 1 sleep 1
# Start noVNC web proxy (serves browser view at http://localhost:6080/vnc.html)
websockify --web=/usr/share/novnc/ 6080 localhost:5900 & websockify --web=/usr/share/novnc/ 6080 localhost:5900 &
sleep 1 sleep 1
echo "noVNC ready on :6080"
# Start FastAPI -- use uv run from /app
cd /app
uv run python -m uvicorn app:app --host 0.0.0.0 --port 8080 --log-level info &
sleep 2
echo "=============================================" echo "============================================="
echo " noVNC ready: http://localhost:6080/vnc.html" echo " car-help UI: http://localhost:8080"
echo "=============================================" echo "============================================="
# Run whatever command was passed (or drop to shell)
if [ $# -eq 0 ]; then if [ $# -eq 0 ]; then
echo "No command given. Container is running -- attach to use."
echo "Run searches with: docker exec car-help uv run python search.py"
tail -f /dev/null tail -f /dev/null
else else
exec "$@" exec "$@"
+205
View File
@@ -0,0 +1,205 @@
"""
Car buying guide for a 17-year-old in Woodinville WA.
Budget: $8-10K for a reliable used hybrid.
"""
def get_guide() -> dict:
return {
"target_cars": [
{
"model": "Toyota Prius (2010-2015, 3rd gen)",
"why": "Most reliable hybrid ever made. 50+ MPG. Huge parts supply. Battery lasts 150-200K miles. Dead-simple to maintain.",
"mpg": "50+",
"budget": "$6,500-$9,000",
"score": 10,
"notes": "The boring answer is the right answer. A 2012 Prius with 120K miles will run another 100K with basic maintenance.",
},
{
"model": "Toyota Camry Hybrid (2012-2017)",
"why": "More comfortable than Prius, very reliable. Bigger car, feels more solid. Great for a new driver.",
"mpg": "38-40",
"budget": "$7,000-$10,000",
"score": 9,
"notes": "Slightly harder to find in budget but worth it. More trunk space than Prius.",
},
{
"model": "Honda Insight (2010-2014, 2nd gen)",
"why": "Compact, reliable, good fuel economy. Honda build quality.",
"mpg": "41-44",
"budget": "$5,000-$7,500",
"score": 8,
"notes": "Less common than Prius but solid choice. IMA system is simpler than Toyota's.",
},
{
"model": "Hyundai Ioniq (2017-2019)",
"why": "Newer tech, excellent MPG, modern safety features. Good value depreciation curve.",
"mpg": "55+",
"budget": "$8,000-$10,000",
"score": 8,
"notes": "Best MPG in class. Newer so fewer miles typically. May be tight on budget.",
},
{
"model": "Honda Civic Hybrid (2012-2015)",
"why": "Good daily driver, Honda reliability. Watch battery health closely.",
"mpg": "44",
"budget": "$5,000-$8,000",
"score": 7,
"notes": "Only consider 2012+. Earlier models had IMA battery failures. Always get battery tested.",
},
],
"avoid": [
{
"model": "Nissan Leaf",
"reason": "Battery degrades badly in heat/cold. PNW winters + no active thermal management = range drops to 40-50 miles. Replacement battery costs $5,000+.",
},
{
"model": "Honda Civic Hybrid pre-2012",
"reason": "IMA battery failures are extremely common. Honda lost a class action over this. Replacement is $2,000-$3,000.",
},
{
"model": "Nissan Altima Hybrid",
"reason": "Rare, poor parts availability. Nissan's hybrid system was licensed from Toyota but support is minimal.",
},
{
"model": "Ford Escape Hybrid pre-2010",
"reason": "Aging hybrid system, expensive repairs. Brake actuator failures are common and cost $1,500+.",
},
],
"pre_purchase_checklist": [
{
"item": "Bring an OBD2 scanner",
"detail": "$20 on Amazon (BAFX or ELM327). Plug in, check for stored codes. Any hybrid-related codes (P0A80, P3000) = walk away.",
},
{
"item": "Check hybrid battery health",
"detail": "Ask seller for a hybrid battery scan. Or bring the Dr. Prius app (Android) or HybridAssistant. Look for cell voltage imbalance.",
},
{
"item": "Look for rust underneath",
"detail": "PNW cars are generally good for this, but check wheel wells, rocker panels, and undercarriage. Any structural rust = walk away.",
},
{
"item": "Check all electronics",
"detail": "Lights, AC, heat, power windows, door locks, radio, backup camera. Electrical gremlins are expensive.",
},
{
"item": "Test drive thoroughly",
"detail": "Listen for unusual sounds. Test brakes hard. Feel for vibrations at highway speed. Test regenerative braking (should feel smooth). Drive at least 15 minutes.",
},
{
"item": "Check tire tread depth",
"detail": "Penny test: insert penny head-first into tread. If you see all of Lincoln's head, tires need replacing ($400-$600).",
},
{
"item": "Ask for maintenance records",
"detail": "Oil changes, brake pads, coolant flushes, transmission fluid. No records = assume nothing was done.",
},
{
"item": "Check VIN for recalls",
"detail": "Go to NHTSA.gov/recalls and enter VIN. Open recalls should be fixed for free at dealer.",
},
{
"item": "Run a vehicle history report",
"detail": "Carfax or AutoCheck ($40, or ask dealer for a free one). Check for accidents, title issues, odometer rollbacks.",
},
{
"item": "Check 12V battery age",
"detail": "Separate from the hybrid battery. Look for a date sticker on the battery. If 4+ years old, budget $150-$200 for replacement.",
},
{
"item": "Check for catalytic converter",
"detail": "Theft is extremely common on Prius (contains palladium). Look under the car -- if you see a fresh weld or aftermarket cat, ask questions.",
},
],
"mechanics_nearby": [
{
"name": "Totem Lake Automotive",
"address": "12720 NE 124th St, Kirkland, WA",
"notes": "Good reviews, does hybrid inspections. Close to Woodinville.",
"distance": "~10 min",
},
{
"name": "Eastside Automotive",
"address": "12011 NE 12th St, Bellevue, WA",
"notes": "AAA approved shop. Thorough pre-purchase inspections.",
"distance": "~20 min",
},
{
"name": "Priusly Hybrid Service",
"address": "Seattle, WA",
"notes": "Prius and hybrid specialists. Worth the drive if buying a Prius. They know every failure mode.",
"distance": "~30 min",
},
{
"name": "Mastracci's Automotive",
"address": "14510 NE 91st St, Redmond, WA",
"notes": "Longstanding local shop with solid reputation.",
"distance": "~15 min",
},
],
"mechanics_note": "Budget $100-$175 for a pre-purchase inspection. NEVER skip this. Even a $5,000 car can have $3,000 in hidden problems. The $150 you spend here is the best money in the entire process.",
"mechanic_red_flags": {
"good_signs": [
"ASE certified technicians",
"Will let you watch the inspection",
"Explains findings clearly, shows you the actual parts",
"Has hybrid-specific diagnostic tools",
"Gives you a written report",
"Doesn't push you to buy or not buy -- just gives facts",
],
"red_flags": [
"Won't let you be present during inspection",
"Quotes repairs before finishing the inspection",
"Pressures you to buy the car (they might know the seller)",
"No hybrid experience or tools",
"Rushes through it in under 30 minutes",
"Only does a visual inspection (no scanner, no lift)",
],
"key_question": 'Ask: "Do you have a hybrid battery health scanner?" If no, find a mechanic who does. This is the single most expensive component.',
},
"negotiation_tips": [
"Know the KBB private party value BEFORE you go (kbb.com). This is your anchor price.",
"Point out any issues found in the pre-purchase inspection to justify a lower offer.",
"Start 15-20% below asking price. Expect to meet somewhere in the middle.",
"Be willing to walk away. There are always more cars. The moment you fall in love with a specific car, you lose negotiating power.",
"Don't mention you're paying cash until the price is agreed. Cash removes the seller's uncertainty but also removes your leverage.",
'At a dealer: the "doc fee" is negotiable in WA. State law caps it at ~$200 but dealers routinely charge $500+. Push back.',
"Private party is usually $1,000-$2,000 cheaper than dealer for the same car, but no warranty.",
"For a 17-year-old's first car: reliable > pretty. A boring Prius with 120K miles beats a cool-looking car with problems. Every time.",
"Budget $500-$1,000 on top of purchase price for registration, insurance setup, and immediate maintenance.",
],
"budget_breakdown": [
{
"item": "Car purchase",
"range": "$7,000-$9,000",
"note": "Leave room for the extras below",
},
{
"item": "Pre-purchase inspection",
"range": "$150",
"note": "Non-negotiable. Do this for every car you're serious about.",
},
{
"item": "Title transfer + registration (WA)",
"range": "$150-$300",
"note": "Depends on vehicle weight and county.",
},
{
"item": "Insurance (17-year-old, liability only)",
"range": "$150-$250/month",
"note": "On a used car, liability-only makes sense. Full coverage would be $300+/month.",
},
{
"item": "Immediate maintenance",
"range": "$100-$200",
"note": "Oil change, air filter, cabin filter, wipers. Do this day one regardless.",
},
{
"item": "Emergency fund",
"range": "$500",
"note": "Something will break or need attention in the first 3 months. Always does.",
},
],
"budget_total": "$8,000-$10,500 total needed to be fully set up and driving",
}
+3
View File
@@ -4,5 +4,8 @@ version = "0.1.0"
description = "Add your description here" description = "Add your description here"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"fastapi>=0.136.3",
"httpx>=0.28.1",
"playwright>=1.60.0", "playwright>=1.60.0",
"uvicorn>=0.48.0",
] ]
+39 -82
View File
@@ -1,20 +1,10 @@
""" """
Car search crawler -- finds used hybrids under $10K near Woodinville WA. Car search crawler -- finds used hybrids under $10K near Woodinville WA.
Uses Playwright (headless Chromium) to handle JS-heavy car sites. Playwright Chromium always runs headed on Xvfb. View live at browser.ampbox.io.
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.
""" """
import asyncio import asyncio
import json import json
import os
import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -23,26 +13,6 @@ from playwright.async_api import async_playwright
RESULTS_DIR = Path("results") RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True) 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]: async def search_craigslist(page) -> list[dict]:
"""Craigslist Seattle -- simple HTML, most reliable to scrape.""" """Craigslist Seattle -- simple HTML, most reliable to scrape."""
@@ -126,44 +96,6 @@ async def search_cargurus(page) -> list[dict]:
return listings 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]: async def search_autotempest(page) -> list[dict]:
"""AutoTempest -- meta-aggregator, searches multiple sites.""" """AutoTempest -- meta-aggregator, searches multiple sites."""
url = ( url = (
@@ -199,19 +131,49 @@ async def search_autotempest(page) -> list[dict]:
return listings 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(): async def main():
mode = "HEADED (noVNC)" if HEADED else "HEADLESS" print(f"Car Search -- {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print(f"Car Search [{mode}] -- {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( print("Live browser: https://browser.ampbox.io/vnc.html")
"Looking for: Used hybrids under $10K "
"within 50 miles of 98077 (Woodinville WA)"
)
print("=" * 70) print("=" * 70)
all_listings: list[dict] = [] all_listings: list[dict] = []
async with async_playwright() as p: 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( context = await browser.new_context(
user_agent=( user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
@@ -221,11 +183,7 @@ async def main():
) )
page = await context.new_page() page = await context.new_page()
searches = [search_craigslist, search_cargurus, search_autotempest] for search_fn in [search_craigslist, search_cargurus, search_autotempest, search_facebook]:
if HEADED:
searches.append(search_facebook)
for search_fn in searches:
try: try:
results = await search_fn(page) results = await search_fn(page)
all_listings.extend(results) all_listings.extend(results)
@@ -241,7 +199,6 @@ async def main():
json.dump( json.dump(
{ {
"search_date": datetime.now(timezone.utc).isoformat(), "search_date": datetime.now(timezone.utc).isoformat(),
"mode": mode,
"params": { "params": {
"zip": "98077", "zip": "98077",
"max_price": 10000, "max_price": 10000,
+784
View File
@@ -0,0 +1,784 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>car-help | Hybrid Finder</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--text-dim: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--orange: #db6d28;
--card-bg: #1c2128;
--hover: #1f2937;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
overflow: hidden;
}
.layout {
display: flex;
height: 100vh;
}
/* Left panel -- browser view */
.browser-panel {
width: 55%;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.browser-header {
padding: 8px 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-dim);
}
.browser-header .dot {
width: 10px; height: 10px; border-radius: 50%;
}
.dot.green { background: var(--green); }
.dot.yellow { background: var(--yellow); }
.dot.red { background: var(--red); }
.browser-header span { margin-left: 8px; font-weight: 500; }
.browser-frame {
flex: 1;
}
.browser-frame iframe {
width: 100%;
height: 100%;
border: none;
}
/* Right panel -- app */
.app-panel {
width: 45%;
display: flex;
flex-direction: column;
background: var(--bg);
}
.app-header {
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.app-header h1 {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.app-header .subtitle {
font-size: 12px;
color: var(--text-dim);
margin-top: 2px;
}
/* Tabs */
.tabs {
display: flex;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.tab {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
color: var(--text-dim);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
user-select: none;
}
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Tab content */
.tab-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.tab-pane { display: none; }
.tab-pane.active { display: block; }
/* Search tab */
.search-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.btn {
padding: 8px 20px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover { background: var(--hover); border-color: var(--accent); }
.btn.primary {
background: #238636;
border-color: #2ea043;
}
.btn.primary:hover { background: #2ea043; }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.search-status {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 16px;
min-height: 18px;
}
/* Listing cards */
.listing-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 10px;
transition: border-color 0.15s;
}
.listing-card:hover { border-color: var(--accent); }
.listing-card .card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
}
.listing-card .title {
font-size: 14px;
font-weight: 500;
color: var(--text);
flex: 1;
}
.listing-card .title a {
color: var(--accent);
text-decoration: none;
}
.listing-card .title a:hover { text-decoration: underline; }
.listing-card .price {
font-size: 15px;
font-weight: 700;
color: var(--green);
white-space: nowrap;
}
.listing-card .meta {
display: flex;
gap: 12px;
margin-top: 6px;
font-size: 12px;
color: var(--text-dim);
}
.score-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
}
.score-high { background: #238636; }
.score-mid { background: #9e6a03; }
.score-low { background: #da3633; }
.source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text-dim);
}
/* Guide tab */
.guide-section {
margin-bottom: 28px;
}
.guide-section h2 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: var(--accent);
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
}
.guide-section h3 {
font-size: 14px;
font-weight: 600;
margin: 12px 0 6px;
color: var(--text);
}
.guide-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
}
.guide-card .model-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.guide-card .model-score {
float: right;
font-weight: 700;
font-size: 15px;
}
.guide-card p {
font-size: 13px;
color: var(--text-dim);
margin-top: 4px;
line-height: 1.5;
}
.guide-card .model-stats {
display: flex;
gap: 16px;
margin-top: 6px;
font-size: 12px;
color: var(--text-dim);
}
.guide-card .model-stats strong { color: var(--text); }
.avoid-card {
background: #2d1b1b;
border-color: #5c2020;
}
.avoid-card .model-name { color: var(--red); }
.checklist-item {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 6px;
}
.checklist-item .item-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.checklist-item .item-detail {
font-size: 12px;
color: var(--text-dim);
margin-top: 3px;
line-height: 1.4;
}
.tip-item {
padding: 6px 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-dim);
border-bottom: 1px solid var(--border);
}
.tip-item:last-child { border-bottom: none; }
.mechanic-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 8px;
}
.mechanic-card .mech-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
}
.mechanic-card .mech-addr {
font-size: 12px;
color: var(--text-dim);
}
.mechanic-card .mech-notes {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
.mechanic-card .mech-dist {
font-size: 11px;
color: var(--accent);
float: right;
}
.budget-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.budget-table th {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
color: var(--text-dim);
font-weight: 500;
}
.budget-table td {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.budget-table .amount { color: var(--green); font-weight: 600; }
.budget-table .note-col { color: var(--text-dim); font-size: 12px; }
.budget-total {
margin-top: 12px;
padding: 12px 14px;
background: #0f2b1a;
border: 1px solid #238636;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
color: var(--green);
}
.callout {
padding: 12px 14px;
background: #1a1f2e;
border: 1px solid #3b4f80;
border-radius: 8px;
font-size: 13px;
color: var(--text-dim);
line-height: 1.5;
margin: 12px 0;
}
.callout.warn {
background: #2a1f0a;
border-color: #6e4b0a;
}
.good-bad-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
.good-list, .bad-list {
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.good-list li::marker { color: var(--green); }
.bad-list li::marker { color: var(--red); }
/* Saved tab */
.saved-file {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 8px;
cursor: pointer;
transition: border-color 0.15s;
}
.saved-file:hover { border-color: var(--accent); }
.saved-file .filename {
font-size: 14px;
font-weight: 500;
color: var(--accent);
}
.saved-file .file-meta {
font-size: 12px;
color: var(--text-dim);
margin-top: 3px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state .big { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
/* Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="layout">
<!-- Left: Browser view -->
<div class="browser-panel">
<div class="browser-header">
<div class="dot red"></div>
<div class="dot yellow"></div>
<div class="dot green"></div>
<span>Live Browser -- browser.ampbox.io</span>
</div>
<div class="browser-frame">
<iframe src="/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000" allow="clipboard-read; clipboard-write"></iframe>
</div>
</div>
<!-- Right: App panel -->
<div class="app-panel">
<div class="app-header">
<h1>car-help</h1>
<div class="subtitle">Used hybrid finder -- Woodinville WA -- Budget $8-10K</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="search">Search</div>
<div class="tab" data-tab="guide">Guide</div>
<div class="tab" data-tab="saved">Saved</div>
</div>
<div class="tab-content">
<!-- Search pane -->
<div class="tab-pane active" id="pane-search">
<div class="search-controls">
<button class="btn primary" id="btn-search" onclick="runSearch()">Run Search</button>
<button class="btn" onclick="rankResults()">Rank Results</button>
</div>
<div class="search-status" id="search-status"></div>
<div id="search-results"></div>
</div>
<!-- Guide pane -->
<div class="tab-pane" id="pane-guide">
<div id="guide-content">
<div class="empty-state">
<div class="big">...</div>
<p>Loading guide...</p>
</div>
</div>
</div>
<!-- Saved pane -->
<div class="tab-pane" id="pane-saved">
<div id="saved-content">
<div class="empty-state">
<div class="big">...</div>
<p>Loading saved results...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// --- Tab switching ---
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById('pane-' + tab.dataset.tab).classList.add('active');
});
});
// --- Current search results (in memory) ---
let currentListings = [];
// --- Search ---
async function runSearch() {
const btn = document.getElementById('btn-search');
const status = document.getElementById('search-status');
btn.disabled = true;
btn.textContent = 'Searching...';
status.textContent = 'Launching Playwright searches across Craigslist, CarGurus, AutoTempest, Facebook... this takes 1-2 minutes. Watch the browser on the left.';
try {
const resp = await fetch('/api/search');
const data = await resp.json();
if (data.status === 'ok') {
currentListings = data.listings || [];
status.textContent = `Found ${data.total_listings} listings. Saved to ${data.file}.`;
renderListings(currentListings);
loadSaved(); // refresh saved tab
} else {
status.textContent = 'Search error: ' + (data.message || 'unknown');
console.error(data.log);
}
} catch (e) {
status.textContent = 'Search failed: ' + e.message;
}
btn.disabled = false;
btn.textContent = 'Run Search';
}
async function rankResults() {
if (currentListings.length === 0) {
document.getElementById('search-status').textContent = 'No results to rank. Run a search first.';
return;
}
document.getElementById('search-status').textContent = 'Ranking...';
try {
const resp = await fetch('/api/rank', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentListings),
});
const ranked = await resp.json();
currentListings = ranked;
document.getElementById('search-status').textContent = `Ranked ${ranked.length} listings by score.`;
renderListings(ranked);
} catch (e) {
document.getElementById('search-status').textContent = 'Ranking failed: ' + e.message;
}
}
function renderListings(listings) {
const container = document.getElementById('search-results');
if (!listings.length) {
container.innerHTML = '<div class="empty-state"><div class="big">0</div><p>No listings yet. Hit "Run Search" to start.</p></div>';
return;
}
container.innerHTML = listings.map(l => {
const score = l.score != null ? l.score : null;
let scoreBadge = '';
if (score !== null) {
const cls = score >= 70 ? 'score-high' : score >= 50 ? 'score-mid' : 'score-low';
scoreBadge = `<span class="score-badge ${cls}">${score}</span>`;
}
const title = l.title || 'Unknown';
const price = l.price || 'N/A';
const link = l.url ? `<a href="${escHtml(l.url)}" target="_blank">${escHtml(title)}</a>` : escHtml(title);
return `
<div class="listing-card">
<div class="card-top">
<div class="title">${link}</div>
<div class="price">${escHtml(price)}</div>
</div>
<div class="meta">
<span class="source-badge">${escHtml(l.source || '?')}</span>
${scoreBadge}
</div>
</div>`;
}).join('');
}
// --- Guide ---
async function loadGuide() {
try {
const resp = await fetch('/api/guide');
const g = await resp.json();
renderGuide(g);
} catch (e) {
document.getElementById('guide-content').innerHTML = '<p>Failed to load guide.</p>';
}
}
function renderGuide(g) {
let html = '';
// Target cars
html += '<div class="guide-section"><h2>Recommended Cars</h2>';
for (const car of g.target_cars) {
html += `
<div class="guide-card">
<div class="model-score" style="color: ${car.score >= 9 ? 'var(--green)' : car.score >= 8 ? 'var(--yellow)' : 'var(--text-dim)'}">${car.score}/10</div>
<div class="model-name">${escHtml(car.model)}</div>
<p>${escHtml(car.why)}</p>
<div class="model-stats">
<span>MPG: <strong>${escHtml(car.mpg)}</strong></span>
<span>Budget: <strong>${escHtml(car.budget)}</strong></span>
</div>
<p style="margin-top:6px; font-style:italic">${escHtml(car.notes)}</p>
</div>`;
}
html += '</div>';
// Avoid
html += '<div class="guide-section"><h2>Avoid These</h2>';
for (const car of g.avoid) {
html += `
<div class="guide-card avoid-card">
<div class="model-name">${escHtml(car.model)}</div>
<p>${escHtml(car.reason)}</p>
</div>`;
}
html += '</div>';
// Pre-purchase checklist
html += '<div class="guide-section"><h2>Pre-Purchase Checklist</h2>';
for (const item of g.pre_purchase_checklist) {
html += `
<div class="checklist-item">
<div class="item-title">${escHtml(item.item)}</div>
<div class="item-detail">${escHtml(item.detail)}</div>
</div>`;
}
html += '</div>';
// Mechanics nearby
html += '<div class="guide-section"><h2>Mechanics for Pre-Purchase Inspection</h2>';
html += `<div class="callout warn">${escHtml(g.mechanics_note)}</div>`;
for (const m of g.mechanics_nearby) {
html += `
<div class="mechanic-card">
<span class="mech-dist">${escHtml(m.distance)}</span>
<div class="mech-name">${escHtml(m.name)}</div>
<div class="mech-addr">${escHtml(m.address)}</div>
<div class="mech-notes">${escHtml(m.notes)}</div>
</div>`;
}
html += '</div>';
// Mechanic red flags
html += '<div class="guide-section"><h2>Choosing a Mechanic</h2>';
html += '<div class="good-bad-grid"><div><h3 style="color:var(--green)">Good Signs</h3><ul class="good-list">';
for (const s of g.mechanic_red_flags.good_signs) {
html += `<li>${escHtml(s)}</li>`;
}
html += '</ul></div><div><h3 style="color:var(--red)">Red Flags</h3><ul class="bad-list">';
for (const s of g.mechanic_red_flags.red_flags) {
html += `<li>${escHtml(s)}</li>`;
}
html += '</ul></div></div>';
html += `<div class="callout">${escHtml(g.mechanic_red_flags.key_question)}</div>`;
html += '</div>';
// Negotiation tips
html += '<div class="guide-section"><h2>Negotiation Tips</h2>';
for (const tip of g.negotiation_tips) {
html += `<div class="tip-item">${escHtml(tip)}</div>`;
}
html += '</div>';
// Budget breakdown
html += '<div class="guide-section"><h2>Budget Breakdown</h2>';
html += '<table class="budget-table"><thead><tr><th>Item</th><th>Cost</th><th>Notes</th></tr></thead><tbody>';
for (const b of g.budget_breakdown) {
html += `<tr><td>${escHtml(b.item)}</td><td class="amount">${escHtml(b.range)}</td><td class="note-col">${escHtml(b.note)}</td></tr>`;
}
html += '</tbody></table>';
html += `<div class="budget-total">${escHtml(g.budget_total)}</div>`;
html += '</div>';
document.getElementById('guide-content').innerHTML = html;
}
// --- Saved results ---
async function loadSaved() {
try {
const resp = await fetch('/api/results');
const files = await resp.json();
const container = document.getElementById('saved-content');
if (!files.length) {
container.innerHTML = '<div class="empty-state"><div class="big">0</div><p>No saved searches yet.</p></div>';
return;
}
container.innerHTML = files.map(f => `
<div class="saved-file" onclick="loadSavedFile('${escHtml(f.filename)}')">
<div class="filename">${escHtml(f.filename)}</div>
<div class="file-meta">${f.total} listings -- ${f.date ? new Date(f.date).toLocaleString() : 'unknown date'}</div>
</div>`).join('');
} catch (e) {
document.getElementById('saved-content').innerHTML = '<p>Failed to load saved results.</p>';
}
}
async function loadSavedFile(filename) {
try {
const resp = await fetch('/api/results/' + filename);
const data = await resp.json();
currentListings = data.listings || [];
// Switch to search tab and render
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelector('[data-tab="search"]').classList.add('active');
document.getElementById('pane-search').classList.add('active');
document.getElementById('search-status').textContent = `Loaded ${filename}: ${data.total_listings} listings`;
renderListings(currentListings);
} catch (e) {
alert('Failed to load: ' + e.message);
}
}
// --- Util ---
function escHtml(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
// --- Init ---
loadGuide();
loadSaved();
</script>
</body>
</html>
Generated
+239 -1
View File
@@ -2,16 +2,100 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]] [[package]]
name = "car-help" name = "car-help"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "playwright" }, { name = "playwright" },
{ name = "uvicorn" },
] ]
[package.metadata] [package.metadata]
requires-dist = [{ name = "playwright", specifier = ">=1.60.0" }] requires-dist = [
{ name = "fastapi", specifier = ">=0.136.3" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "playwright", specifier = ">=1.60.0" },
{ name = "uvicorn", specifier = ">=0.48.0" },
]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]]
name = "click"
version = "8.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "fastapi"
version = "0.136.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
@@ -70,6 +154,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
] ]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
]
[[package]] [[package]]
name = "playwright" name = "playwright"
version = "1.60.0" version = "1.60.0"
@@ -89,6 +219,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" },
] ]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
]
[[package]] [[package]]
name = "pyee" name = "pyee"
version = "13.0.1" version = "13.0.1"
@@ -101,6 +302,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
] ]
[[package]]
name = "starlette"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@@ -109,3 +322,28 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
] ]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
]