chore: remove old car-help files, prepare new structure

This commit is contained in:
Ken
2026-05-26 18:16:07 +00:00
parent 4656af010e
commit 04ae443207
17 changed files with 99 additions and 2334 deletions
+14 -1
View File
@@ -1,4 +1,17 @@
# Python
.venv/ .venv/
results/
__pycache__/ __pycache__/
*.pyc *.pyc
*.egg-info/
# Node/Frontend
frontend/node_modules/
frontend/dist/
# Runtime
artifacts/
# IDE
.idea/
.vscode/
*.swp
-29
View File
@@ -1,29 +0,0 @@
FROM python:3.13-slim
# Install display and VNC deps
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
xvfb x11vnc websockify novnc \
libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \
libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libgtk-3-0 \
fonts-liberation && \
rm -rf /var/lib/apt/lists/*
# Install Python deps
COPY pyproject.toml uv.lock /app/
WORKDIR /app
RUN pip install uv && uv sync --frozen
# Install Playwright Chromium
RUN uv run playwright install chromium
# Copy source
COPY . /app/
# Entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 6080 8080
ENTRYPOINT ["/entrypoint.sh"]
-534
View File
@@ -1,534 +0,0 @@
"""
Car-help web app -- FastAPI backend with Anthropic-powered search + guide generation.
Runs on port 8080 inside the Docker container.
"""
import asyncio
import json
import os
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
app = FastAPI(title="car-help", version="0.2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True)
NOVNC_UPSTREAM = "http://localhost:6080"
ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
ANTHROPIC_MODEL = "claude-sonnet-4-20250514"
# ---------------------------------------------------------------------------
# Anthropic API helper
# ---------------------------------------------------------------------------
async def call_anthropic(prompt: str, max_tokens: int = 2000, timeout: int = 60) -> str:
"""Call the Anthropic Messages API and return the text response."""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{ANTHROPIC_BASE_URL}/v1/messages",
headers={
"x-api-key": ANTHROPIC_API_KEY,
"content-type": "application/json",
"anthropic-version": "2023-06-01",
},
json={
"model": ANTHROPIC_MODEL,
"max_tokens": max_tokens,
"messages": [{"role": "user", "content": prompt}],
},
timeout=timeout,
)
data = resp.json()
if "content" in data and data["content"]:
return data["content"][0].get("text", "")
return json.dumps(data)
# ---------------------------------------------------------------------------
# Parse user query into search parameters via LLM
# ---------------------------------------------------------------------------
async def parse_query(query: str, filters: list[str]) -> dict:
"""Use the LLM to turn a natural language query + filters into search params."""
filter_str = ", ".join(filters) if filters else "none"
prompt = f"""You are a car search assistant. Parse this user query and active filters into concrete search parameters.
User query: "{query}"
Active filters: {filter_str}
Return ONLY valid JSON (no markdown, no explanation) with these keys:
- "makes": list of car makes to search (e.g. ["Toyota", "Honda"]). Empty list = any make.
- "models": list of specific models (e.g. ["Prius", "Camry Hybrid"]). Empty list = any model.
- "min_year": minimum year (integer, e.g. 2010). null if not specified.
- "max_year": maximum year (integer). null if not specified.
- "max_price": maximum price in dollars (integer). Default 10000.
- "fuel_type": one of "hybrid", "electric", "any". Default based on filters.
- "zip": ZIP code. Default "98077" (Woodinville WA).
- "radius": search radius in miles (integer). Default 50.
- "seller_type": one of "private", "dealer", "any". Default "any".
- "max_miles": maximum mileage (integer). null if not specified.
- "search_summary": one-line human-readable summary of what we're searching for.
Example: {{"makes": ["Toyota"], "models": ["Prius"], "min_year": 2012, "max_year": 2015, "max_price": 8000, "fuel_type": "hybrid", "zip": "98077", "radius": 50, "seller_type": "any", "max_miles": 100000, "search_summary": "Toyota Prius 2012-2015 under $8K within 50mi"}}"""
text = await call_anthropic(prompt, max_tokens=500, timeout=30)
# Extract JSON from response
text = text.strip()
# Try to find JSON object in the response
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
# Fallback defaults
return {
"makes": [],
"models": [],
"min_year": None,
"max_year": None,
"max_price": 10000,
"fuel_type": "hybrid",
"zip": "98077",
"radius": 50,
"seller_type": "any",
"max_miles": None,
"search_summary": query,
}
# ---------------------------------------------------------------------------
# Build search URLs from parsed parameters
# ---------------------------------------------------------------------------
def build_craigslist_url(params: dict) -> str:
"""Build Craigslist search URL from params."""
base = "https://seattle.craigslist.org/search/cta?"
parts = []
fuel_map = {"hybrid": "4", "electric": "6", "any": ""}
fuel = fuel_map.get(params.get("fuel_type", "hybrid"), "")
if fuel:
parts.append(f"auto_fuel_type={fuel}")
if params.get("max_price"):
parts.append(f"max_price={params['max_price']}")
parts.append(f"postal={params.get('zip', '98077')}")
parts.append(f"search_distance={params.get('radius', 50)}")
if params.get("min_year"):
parts.append(f"min_auto_year={params['min_year']}")
if params.get("max_year"):
parts.append(f"max_auto_year={params['max_year']}")
if params.get("max_miles"):
parts.append(f"max_auto_miles={params['max_miles']}")
if params.get("seller_type") == "private":
parts.append("purveyor=owner")
elif params.get("seller_type") == "dealer":
parts.append("purveyor=dealer")
# Add make/model as search query
query_parts = []
if params.get("makes"):
query_parts.extend(params["makes"])
if params.get("models"):
query_parts.extend(params["models"])
if query_parts:
parts.append(f"query={'+'.join(query_parts)}")
parts.append("sort=date")
return base + "&".join(parts)
def build_cargurus_url(params: dict) -> str:
"""Build CarGurus search URL from params."""
base = (
"https://www.cargurus.com/Cars/inventorylisting/"
"viewDetailsFilterViewInventoryListing.action?"
)
parts = []
parts.append(f"zip={params.get('zip', '98077')}")
if params.get("max_price"):
parts.append(f"maxPrice={params['max_price']}")
fuel_map = {"hybrid": "HYBRID", "electric": "ELECTRIC", "any": ""}
fuel = fuel_map.get(params.get("fuel_type", "hybrid"), "")
if fuel:
parts.append(f"fuelTypes={fuel}")
parts.append(f"distance={params.get('radius', 50)}")
if params.get("max_miles"):
parts.append(f"maxMileage={params['max_miles']}")
if params.get("min_year"):
parts.append(f"startYear={params['min_year']}")
if params.get("max_year"):
parts.append(f"endYear={params['max_year']}")
parts.append("sortDir=ASC&sortType=PRICE")
return base + "&".join(parts)
def build_autotempest_url(params: dict) -> str:
"""Build AutoTempest search URL from params."""
base = "https://www.autotempest.com/results?"
parts = []
parts.append(f"zip={params.get('zip', '98077')}")
if params.get("max_price"):
parts.append(f"maxprice={params['max_price']}")
fuel_map = {"hybrid": "hybrid", "electric": "electric", "any": ""}
fuel = fuel_map.get(params.get("fuel_type", "hybrid"), "")
if fuel:
parts.append(f"fuel={fuel}")
parts.append(f"radius={params.get('radius', 50)}")
if params.get("makes"):
parts.append(f"make={params['makes'][0].lower()}")
if params.get("models"):
parts.append(f"model={params['models'][0].lower()}")
if params.get("min_year"):
parts.append(f"minyear={params['min_year']}")
if params.get("max_year"):
parts.append(f"maxyear={params['max_year']}")
if params.get("max_miles"):
parts.append(f"maxmiles={params['max_miles']}")
return base + "&".join(parts)
# ---------------------------------------------------------------------------
# Scoring engine
# ---------------------------------------------------------------------------
def score_listing(listing: dict, params: dict) -> int:
"""Score a single listing 0-100."""
s = 50
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
elif "corolla" in title:
s += 18
elif "rav4" in title:
s += 15
# Brand bonuses
if "toyota" in title:
s += 10
elif "honda" in title:
s += 7
elif "hyundai" in title:
s += 5
elif "kia" in title:
s += 4
# 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", "parts only"]):
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
elif price_num > 15000:
s -= 10
# Positive signals
if "one owner" in title or "single owner" in title:
s += 8
if "clean title" in title:
s += 8
if "low miles" in title or "low mileage" in title:
s += 5
# If user searched for specific makes/models, boost matches
for make in params.get("makes", []):
if make.lower() in title:
s += 5
for model in params.get("models", []):
if model.lower() in title:
s += 5
return max(0, min(100, s))
# ---------------------------------------------------------------------------
# Generate guide via LLM
# ---------------------------------------------------------------------------
async def generate_guide(query: str, listings: list[dict], params: dict) -> dict:
"""Call the Anthropic API to generate a custom buying guide."""
# Prepare top 20 listings summary
top_listings = listings[:20]
listings_text = "\n".join(
f"- {item.get('title', 'Unknown')} | {item.get('price', 'N/A')} | Score: {item.get('score', '?')} | {item.get('url', '')}"
for item in top_listings
)
if not listings_text:
listings_text = "(No listings found)"
prompt = f"""You are a car buying expert helping someone near Woodinville, WA (ZIP 98077).
The user searched for: "{query}"
Search parameters: {json.dumps(params, default=str)}
Here are the top listings found:
{listings_text}
Generate a comprehensive buying guide tailored to this specific search. Return ONLY valid JSON (no markdown fences, no explanation before/after) with these keys:
1. "top_picks": Analysis of the best listings found. Which ones look like the best deals and why? Reference specific listings by name/price. If no listings found, give general advice for this type of car. Use markdown formatting (bold, bullet points).
2. "checklist": What to inspect when looking at THESE specific car models. Be specific to the makes/models found (e.g., if mostly Prius results, talk about hybrid battery health, catalytic converter theft, etc.). Use markdown formatting.
3. "mechanics": Recommend 3-4 mechanics near Woodinville WA 98077 that would be good for pre-purchase inspections of these types of cars. Include name, approximate location, and why they're good. Use markdown formatting.
4. "negotiation": Negotiation tips specific to this price range and car type. Include what to look up beforehand (KBB, etc.), how much below asking to start, WA-specific rules (doc fees, etc.). Use markdown formatting.
5. "budget": Total budget breakdown including the car, registration, insurance, maintenance reserves. Be specific to the price range searched. Use markdown formatting.
Each value should be a string with markdown formatting for rich display. Be practical, specific, and actionable."""
text = await call_anthropic(prompt, max_tokens=4000, timeout=90)
text = text.strip()
# Try to parse JSON from response
match = re.search(r"\{.*\}", text, re.DOTALL)
if match:
try:
guide = json.loads(match.group())
# Ensure all keys exist
for key in ["top_picks", "checklist", "mechanics", "negotiation", "budget"]:
if key not in guide:
guide[key] = "No information available."
return guide
except json.JSONDecodeError:
pass
# Fallback: return the raw text in top_picks
return {
"top_picks": text or "Guide generation failed. Please try again.",
"checklist": "Run a search to generate a custom checklist.",
"mechanics": "Run a search to get mechanic recommendations.",
"negotiation": "Run a search to get negotiation tips.",
"budget": "Run a search to get a budget breakdown.",
}
# ---------------------------------------------------------------------------
# Static files + index
# ---------------------------------------------------------------------------
@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.post("/api/search")
async def search(request: Request):
"""Main search endpoint: parse query, crawl sites, score, generate guide."""
body = await request.json()
query = body.get("query", "").strip()
filters = body.get("filters", [])
if not query:
return JSONResponse({"error": "Please enter a search query"}, 400)
try:
# Step 1: Parse query into search parameters
params = await parse_query(query, filters)
search_summary = params.get("search_summary", query)
# Step 2: Build search URLs
cl_url = build_craigslist_url(params)
cg_url = build_cargurus_url(params)
at_url = build_autotempest_url(params)
# Step 3: Run Playwright crawl via search.py subprocess
search_args = json.dumps(
{
"craigslist_url": cl_url,
"cargurus_url": cg_url,
"autotempest_url": at_url,
"params": params,
}
)
proc = await asyncio.create_subprocess_exec(
"uv",
"run",
"python",
"search.py",
"--dynamic",
search_args,
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=180)
# Step 4: Load results
result_files = sorted(RESULTS_DIR.glob("search_*.json"))
all_listings = []
if result_files:
data = json.loads(result_files[-1].read_text())
all_listings = data.get("listings", [])
# Step 5: Score and rank
for listing in all_listings:
listing["score"] = score_listing(listing, params)
all_listings.sort(key=lambda x: x["score"], reverse=True)
# Step 6: Generate guide via LLM
guide = await generate_guide(query, all_listings, params)
return JSONResponse(
{
"status": "ok",
"search_summary": search_summary,
"total": len(all_listings),
"listings": all_listings,
"guide": guide,
"urls_searched": {
"craigslist": cl_url,
"cargurus": cg_url,
"autotempest": at_url,
},
}
)
except asyncio.TimeoutError:
return JSONResponse({"error": "Search timed out after 3 minutes"}, 504)
except Exception as e:
import traceback
return JSONResponse(
{
"error": str(e),
"traceback": traceback.format_exc(),
},
500,
)
# ---------------------------------------------------------------------------
# Keep existing endpoints
# ---------------------------------------------------------------------------
@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()))
# ---------------------------------------------------------------------------
# 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,
)
View File
View File
View File
View File
View File
-29
View File
@@ -1,29 +0,0 @@
#!/bin/bash
set -e
export DISPLAY=:99
Xvfb :99 -screen 0 1280x720x24 -ac &
sleep 1
x11vnc -display :99 -forever -nopw -listen 0.0.0.0 -rfbport 5900 -shared 2>/dev/null &
sleep 1
websockify --web=/usr/share/novnc/ 6080 localhost:5900 &
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 " car-help UI: http://localhost:8080"
echo "============================================="
if [ $# -eq 0 ]; then
tail -f /dev/null
else
exec "$@"
fi
View File
-205
View File
@@ -1,205 +0,0 @@
"""
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",
}
-11
View File
@@ -1,11 +0,0 @@
[project]
name = "car-help"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.136.3",
"httpx>=0.28.1",
"playwright>=1.60.0",
"uvicorn>=0.48.0",
]
-297
View File
@@ -1,297 +0,0 @@
"""
Car search crawler -- finds used cars based on dynamic parameters.
Always runs headed on Xvfb. View live at research.ampbox.io.
Usage:
python search.py # runs default hardcoded search
python search.py --dynamic '{...}' # runs with dynamic URLs/params from app.py
"""
import asyncio
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from playwright.async_api import async_playwright
RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True)
def extract_title_from_url(url: str) -> str:
"""Extract a readable title from a Craigslist URL slug."""
m = re.search(r"/d/([^/]+)/", url)
if m:
return m.group(1).replace("-", " ").title()
return ""
async def search_craigslist(page, url: str) -> list[dict]:
"""Craigslist -- simple HTML, most reliable to scrape."""
print(f" Searching Craigslist: {url}")
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
await page.wait_for_timeout(3000)
listings = []
cards = await page.query_selector_all(".cl-search-result")
print(f" Found {len(cards)} result elements")
for card in cards[:50]:
try:
text = await card.inner_text()
lines = [
ln.strip()
for ln in text.split("\n")
if ln.strip() and ln.strip() != "\u2022"
]
link_el = await card.query_selector(
"a.main, a.cl-app-anchor, a[href*='/d/']"
)
link = await link_el.get_attribute("href") if link_el else ""
title = ""
price = ""
for line in lines:
if line.startswith("$"):
price = line
elif re.match(r"^\d+/\d+$", line):
continue
elif re.match(r"^\d+k?\s*mi", line, re.I):
continue
elif not title and len(line) > 5:
title = line
if not title and link:
title = extract_title_from_url(link)
if not price:
price_el = await card.query_selector(".priceinfo")
if price_el:
price = await price_el.inner_text()
img_el = await card.query_selector("img[src]")
img = await img_el.get_attribute("src") if img_el else ""
listings.append(
{
"source": "craigslist",
"title": title.strip(),
"price": price.strip(),
"url": link,
"image": img,
}
)
except Exception:
continue
print(f" Extracted {len(listings)} Craigslist listings")
return listings
async def search_cargurus(page, url: str) -> list[dict]:
"""CarGurus -- JS-heavy but good data."""
print(f" Searching CarGurus: {url}")
await page.goto(url, wait_until="networkidle", timeout=60000)
await page.wait_for_timeout(3000)
listings = []
cards = await page.query_selector_all(
"article, [data-cg-ft='car-blade'], a[href*='/Cars/']"
)
for card in cards[:50]:
try:
text = await card.inner_text()
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
title = lines[0] if lines else "Unknown"
price = ""
for line in lines:
if "$" in line and any(c.isdigit() for c in line):
price = line
break
link_el = await card.query_selector("a[href*='/Cars/']")
if not link_el:
link_el = card if await card.get_attribute("href") else None
link = await link_el.get_attribute("href") if link_el else ""
if link and not link.startswith("http"):
link = f"https://www.cargurus.com{link}"
img_el = await card.query_selector("img[src*='cargurus']")
img = await img_el.get_attribute("src") if img_el else ""
listings.append(
{
"source": "cargurus",
"title": title[:100].strip(),
"price": price.strip(),
"url": link,
"image": img,
}
)
except Exception:
continue
print(f" Found {len(listings)} CarGurus listings")
return listings
async def search_autotempest(page, url: str) -> list[dict]:
"""AutoTempest -- meta-aggregator."""
print(f" Searching AutoTempest: {url}")
await page.goto(url, wait_until="networkidle", timeout=60000)
await page.wait_for_timeout(5000)
listings = []
cards = await page.query_selector_all(
".result-list-item, .listing, [class*='result']"
)
for card in cards[:50]:
try:
text = await card.inner_text()
if len(text.strip()) < 10:
continue
lines = [ln.strip() for ln in text.split("\n") if ln.strip()]
title = lines[0] if lines else "Unknown"
price = ""
for line in lines:
if "$" in line and any(c.isdigit() for c in line):
price = line
break
link_el = await card.query_selector("a[href]")
link = await link_el.get_attribute("href") if link_el else ""
img_el = await card.query_selector("img[src]")
img = await img_el.get_attribute("src") if img_el else ""
listings.append(
{
"source": "autotempest",
"title": title[:100].strip(),
"price": price.strip(),
"url": link,
"image": img,
}
)
except Exception:
continue
print(f" Found {len(listings)} AutoTempest listings")
return listings
async def main():
print(f"Car Search -- {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
# Parse dynamic args if provided
dynamic_config = None
if len(sys.argv) >= 3 and sys.argv[1] == "--dynamic":
dynamic_config = json.loads(sys.argv[2])
params = dynamic_config.get("params", {})
search_summary = params.get("search_summary", "dynamic search")
print(f"Dynamic search: {search_summary}")
else:
print("Default search: Used hybrids under $10K within 50mi of 98077")
print("=" * 70)
all_listings: list[dict] = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=False)
context = await browser.new_context(
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
),
viewport={"width": 1280, "height": 720},
)
page = await context.new_page()
if dynamic_config:
# Dynamic search with provided URLs
searches = [
(search_craigslist, dynamic_config.get("craigslist_url", "")),
(search_cargurus, dynamic_config.get("cargurus_url", "")),
(search_autotempest, dynamic_config.get("autotempest_url", "")),
]
for search_fn, url in searches:
if url:
try:
results = await search_fn(page, url)
all_listings.extend(results)
except Exception as e:
print(f" ERROR in {search_fn.__name__}: {e}")
else:
# Default hardcoded search
default_urls = {
"craigslist": (
"https://seattle.craigslist.org/search/cta"
"?auto_fuel_type=4&max_price=10000&postal=98077"
"&search_distance=50&sort=date"
),
"cargurus": (
"https://www.cargurus.com/Cars/inventorylisting/"
"viewDetailsFilterViewInventoryListing.action"
"?zip=98077&maxPrice=10000&fuelTypes=HYBRID"
"&distance=50&sortDir=ASC&sortType=PRICE"
),
"autotempest": (
"https://www.autotempest.com/results"
"?zip=98077&maxprice=10000&fuel=hybrid&radius=50"
),
}
for name, url in default_urls.items():
search_fn = {
"craigslist": search_craigslist,
"cargurus": search_cargurus,
"autotempest": search_autotempest,
}[name]
try:
results = await search_fn(page, url)
all_listings.extend(results)
except Exception as e:
print(f" ERROR in {name}: {e}")
await browser.close()
# Save results
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M")
search_params = (
dynamic_config.get("params", {})
if dynamic_config
else {"zip": "98077", "max_price": 10000, "fuel": "hybrid", "radius_miles": 50}
)
output_file = RESULTS_DIR / f"search_{timestamp}.json"
with open(output_file, "w") as f:
json.dump(
{
"search_date": datetime.now(timezone.utc).isoformat(),
"params": search_params,
"total_listings": len(all_listings),
"listings": all_listings,
},
f,
indent=2,
)
print("=" * 70)
print(f"Total: {len(all_listings)} listings found")
print(f"Saved to: {output_file}")
for listing in all_listings[:10]:
print(
f" [{listing['source']}] {listing['price']:>8s} {listing['title'][:60]}"
)
if len(all_listings) > 10:
print(f" ... and {len(all_listings) - 10} more")
if __name__ == "__main__":
asyncio.run(main())
-95
View File
@@ -1,95 +0,0 @@
# Car search sites for used hybrids under $10K near Woodinville WA (98077)
search:
zip: "98077"
radius_miles: 50
max_price: 10000
fuel_type: hybrid
target_models:
- Toyota Prius (2009-2015, 3rd gen) -- most reliable, huge supply, 50+ mpg
- Toyota Camry Hybrid (2007-2015) -- comfortable, very reliable
- Honda Civic Hybrid (2009-2014) -- decent, avoid pre-2009 (battery issues)
- Honda Insight (2009-2014) -- compact, reliable, good mpg
- Hyundai Ioniq (2017+) -- newer, good value if in budget
- Toyota Highlander Hybrid (2006-2015) -- more space, lower mpg
avoid:
- Honda Civic Hybrid pre-2009 (battery pack failures)
- Nissan Altima Hybrid (poor hybrid system reliability)
aggregators:
- name: CarGurus
url: https://www.cargurus.com/Cars/inventorylisting/viewDetailsFilterViewInventoryListing.action
search_params: "zip=98077&maxPrice=10000&fuelTypes=HYBRID&distance=50"
notes: Best for price analysis (deals rated good/great/fair). JS-heavy, needs Playwright.
- name: Cars.com
url: https://www.cars.com/shopping/results/
search_params: "fuel_slugs[]=hybrid&maximum_price=10000&zip=98077&maximum_distance=50"
notes: Large inventory. JS-heavy.
- name: Autotrader
url: https://www.autotrader.com/cars-for-sale/all-cars
search_params: "zip=98077&maxPrice=10000&fuelTypeGroup=HYB&searchRadius=50"
notes: Good filters. Heavy JS, aggressive bot detection.
- name: Edmunds
url: https://www.edmunds.com/inventory/srp.html
search_params: "inventorytype=used&zip=98077&price=0-10000&fuel=hybrid&radius=50"
notes: Good reviews integration. JS-heavy.
- name: KBB
url: https://www.kbb.com/cars-for-sale/all/
search_params: "zip=98077&maxPrice=10000&fuelType=Hybrid&distance=50"
notes: Good for fair price estimates.
- name: TrueCar
url: https://www.truecar.com/used-cars-for-sale/listings/
search_params: "location=98077&price_high=10000&fuel_type=Hybrid&search_radius=50"
notes: Shows what others paid. JS-heavy.
- name: AutoTempest
url: https://www.autotempest.com/results
search_params: "zip=98077&maxprice=10000&fuel=hybrid&radius=50"
notes: Meta-aggregator -- searches all sites at once. Good starting point.
dealers:
- name: CarMax
url: https://www.carmax.com/cars/hybrid
locations: [Renton WA, Lynnwood WA]
notes: No-haggle pricing. Good return policy.
- name: Carvana
url: https://www.carvana.com/cars
notes: Online-only, delivery to home. 7-day return policy.
- name: 405 Motors
url: https://www.405motors.com/
location: Woodinville WA 98072
notes: Local dealer, hybrid/budget focused. Very close to home.
marketplace:
- name: Facebook Marketplace
url: https://www.facebook.com/marketplace/seattle/vehicles
notes: Large private party selection. Requires FB login.
- name: Craigslist Seattle
url: https://seattle.craigslist.org/search/cta
search_params: "auto_fuel_type=4&max_price=10000&postal=98077&search_distance=50"
notes: Good for private party deals. Simple HTML, easiest to scrape.
- name: OfferUp
url: https://offerup.com/explore/sck/wa/seattle/cars-trucks
notes: Mobile-first marketplace.
auctions:
- name: Copart (North Seattle)
url: https://www.copart.com/locations/north-seattle-wa-48
notes: Salvage + run-and-drive. Free buyer account. Risk of hidden damage.
- name: IAAI (Seattle)
url: https://www.iaai.com/Locations/327
notes: Insurance auctions open to public.
- name: GovDeals
url: https://www.govdeals.com/
notes: Government surplus vehicles. Occasional gems.
-784
View File
@@ -1,784 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>car-help | AI Car 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;
--pill-bg: #21262d;
--pill-active: #1f6feb;
}
* { 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);
overflow: hidden;
}
.app-header {
padding: 14px 20px 10px;
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;
}
/* Search box area */
.search-area {
padding: 16px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.search-input-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.search-input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus {
border-color: var(--accent);
}
.search-input::placeholder {
color: var(--text-dim);
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn-search {
background: #238636;
color: #fff;
}
.btn-search:hover { background: #2ea043; }
.btn-search:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filter pills */
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.pill {
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
user-select: none;
border: 1px solid var(--border);
background: var(--pill-bg);
color: var(--text-dim);
transition: all 0.15s;
}
.pill:hover {
border-color: var(--accent);
color: var(--text);
}
.pill.active {
background: var(--pill-active);
border-color: var(--pill-active);
color: #fff;
}
/* Scrollable content area */
.content-area {
flex: 1;
overflow-y: auto;
padding: 0;
}
/* Status bar */
.status-bar {
padding: 10px 20px;
font-size: 12px;
color: var(--text-dim);
background: var(--bg);
border-bottom: 1px solid var(--border);
min-height: 36px;
display: flex;
align-items: center;
gap: 8px;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Results section */
.results-section {
padding: 16px 20px;
}
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
/* Listing cards */
.listing-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 10px;
transition: border-color 0.15s;
overflow: hidden;
}
.listing-card:hover { border-color: var(--accent); }
.card-body {
display: flex;
gap: 12px;
padding: 12px 14px;
}
.card-img {
width: 80px;
height: 60px;
border-radius: 6px;
object-fit: cover;
background: var(--pill-bg);
flex-shrink: 0;
}
.card-img-placeholder {
width: 80px;
height: 60px;
border-radius: 6px;
background: var(--pill-bg);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--border);
}
.card-info {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 13px;
font-weight: 500;
line-height: 1.3;
margin-bottom: 4px;
}
.card-title a {
color: var(--accent);
text-decoration: none;
}
.card-title a:hover { text-decoration: underline; }
.card-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.card-price {
font-size: 14px;
font-weight: 700;
color: var(--green);
}
.score-badge {
display: inline-block;
padding: 1px 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: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
background: var(--pill-bg);
border: 1px solid var(--border);
color: var(--text-dim);
}
/* Guide section */
.guide-section {
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.guide-accordion {
margin-bottom: 8px;
}
.guide-accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
user-select: none;
transition: border-color 0.15s;
}
.guide-accordion-header:hover {
border-color: var(--accent);
}
.guide-accordion-header h3 {
font-size: 14px;
font-weight: 600;
color: var(--accent);
}
.guide-accordion-header .arrow {
font-size: 12px;
color: var(--text-dim);
transition: transform 0.2s;
}
.guide-accordion.open .arrow {
transform: rotate(180deg);
}
.guide-accordion-body {
display: none;
padding: 14px;
background: var(--card-bg);
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
font-size: 13px;
line-height: 1.7;
color: var(--text-dim);
}
.guide-accordion.open .guide-accordion-header {
border-radius: 8px 8px 0 0;
border-color: var(--accent);
}
.guide-accordion.open .guide-accordion-body {
display: block;
}
.guide-accordion-body h1,
.guide-accordion-body h2,
.guide-accordion-body h3,
.guide-accordion-body h4 {
color: var(--text);
margin: 12px 0 6px;
}
.guide-accordion-body h1 { font-size: 16px; }
.guide-accordion-body h2 { font-size: 15px; }
.guide-accordion-body h3 { font-size: 14px; }
.guide-accordion-body h4 { font-size: 13px; }
.guide-accordion-body p {
margin: 6px 0;
}
.guide-accordion-body ul, .guide-accordion-body ol {
margin: 6px 0 6px 20px;
}
.guide-accordion-body li {
margin: 3px 0;
}
.guide-accordion-body strong {
color: var(--text);
}
.guide-accordion-body code {
background: var(--bg);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
.empty-state p { font-size: 14px; line-height: 1.6; }
.empty-state .hint { font-size: 12px; margin-top: 8px; color: var(--text-dim); }
/* Error state */
.error-box {
padding: 14px;
background: #2d1b1b;
border: 1px solid #5c2020;
border-radius: 8px;
color: var(--red);
font-size: 13px;
margin: 16px 20px;
}
/* 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</span>
</div>
<div class="browser-frame">
<iframe id="vnc-frame" src="about:blank" 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">AI-powered car search -- Woodinville WA</div>
</div>
<!-- Search area -->
<div class="search-area">
<div class="search-input-row">
<input type="text" class="search-input" id="search-query"
placeholder="What are you looking for?"
onkeydown="if(event.key==='Enter') runSearch()">
<button class="btn btn-search" id="btn-search" onclick="runSearch()">Search</button>
</div>
<div class="filter-row" id="filter-row">
<!-- Filters rendered by JS -->
</div>
</div>
<!-- Status bar -->
<div class="status-bar" id="status-bar">
Type what you want and hit Search
</div>
<!-- Scrollable content -->
<div class="content-area" id="content-area">
<div class="empty-state" id="empty-state">
<div class="icon">&#128663;</div>
<p>Describe the car you want and I'll find it.</p>
<div class="hint">Try: "reliable hybrid under 8K for a teen" or "Toyota Prius 2012-2015 low miles"</div>
</div>
<div id="results-container" style="display:none"></div>
<div id="guide-container" style="display:none"></div>
</div>
</div>
</div>
<script>
// --- Filter definitions ---
const FILTERS = [
{ id: "hybrid", label: "Hybrid", default: true },
{ id: "electric", label: "Electric", default: false },
{ id: "under_8k", label: "Under $8K", default: false },
{ id: "under_10k", label: "Under $10K", default: true },
{ id: "toyota_honda", label: "Toyota/Honda only", default: false },
{ id: "low_miles", label: "Low miles (<100K)", default: false },
{ id: "within_25mi", label: "Within 25mi", default: false },
{ id: "within_50mi", label: "Within 50mi", default: true },
{ id: "private_party", label: "Private party only", default: false },
{ id: "dealer_only", label: "Dealer only", default: false },
];
let activeFilters = new Set(FILTERS.filter(f => f.default).map(f => f.id));
function renderFilters() {
const row = document.getElementById('filter-row');
row.innerHTML = FILTERS.map(f => {
const active = activeFilters.has(f.id) ? 'active' : '';
return `<div class="pill ${active}" data-filter="${f.id}" onclick="toggleFilter('${f.id}', this)">${f.label}</div>`;
}).join('');
}
function toggleFilter(id, el) {
// Handle mutual exclusions
if (id === 'under_8k' && !activeFilters.has(id)) activeFilters.delete('under_10k');
if (id === 'under_10k' && !activeFilters.has(id)) activeFilters.delete('under_8k');
if (id === 'within_25mi' && !activeFilters.has(id)) activeFilters.delete('within_50mi');
if (id === 'within_50mi' && !activeFilters.has(id)) activeFilters.delete('within_25mi');
if (id === 'private_party' && !activeFilters.has(id)) activeFilters.delete('dealer_only');
if (id === 'dealer_only' && !activeFilters.has(id)) activeFilters.delete('private_party');
if (id === 'hybrid' && !activeFilters.has(id)) activeFilters.delete('electric');
if (id === 'electric' && !activeFilters.has(id)) activeFilters.delete('hybrid');
if (activeFilters.has(id)) {
activeFilters.delete(id);
} else {
activeFilters.add(id);
}
renderFilters();
}
// --- Search ---
let searching = false;
async function runSearch() {
if (searching) return;
const query = document.getElementById('search-query').value.trim();
if (!query) {
setStatus('Please enter what you\'re looking for');
return;
}
searching = true;
const btn = document.getElementById('btn-search');
btn.disabled = true;
btn.textContent = 'Searching...';
// Hide empty state, show containers
document.getElementById('empty-state').style.display = 'none';
document.getElementById('results-container').style.display = 'none';
document.getElementById('guide-container').style.display = 'none';
setStatus('<span class="spinner"></span> Researching... parsing your query, searching sites, ranking results. Watch the browser on the left.', true);
try {
const resp = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: query,
filters: Array.from(activeFilters),
}),
});
const data = await resp.json();
if (data.error) {
setStatus('Error: ' + data.error);
document.getElementById('results-container').innerHTML =
`<div class="error-box">${escHtml(data.error)}</div>`;
document.getElementById('results-container').style.display = 'block';
} else {
const total = data.total || 0;
const summary = data.search_summary || query;
setStatus(`Found ${total} listings for "${summary}"`);
renderResults(data.listings || []);
renderGuide(data.guide || {});
}
} catch (e) {
setStatus('Search failed: ' + e.message);
document.getElementById('results-container').innerHTML =
`<div class="error-box">${escHtml(e.message)}</div>`;
document.getElementById('results-container').style.display = 'block';
}
searching = false;
btn.disabled = false;
btn.textContent = 'Search';
}
function setStatus(html, raw) {
const bar = document.getElementById('status-bar');
if (raw) {
bar.innerHTML = html;
} else {
bar.textContent = html;
}
}
// --- Render results ---
function renderResults(listings) {
const container = document.getElementById('results-container');
if (!listings.length) {
container.innerHTML = `
<div class="results-section">
<div class="section-title">Results</div>
<div class="empty-state" style="padding:30px">
<p>No listings found. Try broadening your search.</p>
</div>
</div>`;
container.style.display = 'block';
return;
}
let html = `<div class="results-section">
<div class="section-title">Results (${listings.length})</div>`;
for (const l of listings) {
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 listing';
const price = l.price || '';
const link = l.url
? `<a href="${escHtml(l.url)}" target="_blank">${escHtml(title)}</a>`
: escHtml(title);
const imgHtml = l.image
? `<img class="card-img" src="${escHtml(l.image)}" alt="" onerror="this.outerHTML='<div class=\\'card-img-placeholder\\'>&#128663;</div>'">`
: `<div class="card-img-placeholder">&#128663;</div>`;
html += `
<div class="listing-card">
<div class="card-body">
${imgHtml}
<div class="card-info">
<div class="card-title">${link}</div>
<div class="card-meta">
${price ? `<span class="card-price">${escHtml(price)}</span>` : ''}
<span class="source-badge">${escHtml(l.source || '?')}</span>
${scoreBadge}
</div>
</div>
</div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.style.display = 'block';
}
// --- Render guide ---
function renderGuide(guide) {
const container = document.getElementById('guide-container');
if (!guide || Object.keys(guide).length === 0) {
container.style.display = 'none';
return;
}
const sections = [
{ key: 'top_picks', title: 'Top Picks', defaultOpen: true },
{ key: 'checklist', title: 'What to Check', defaultOpen: false },
{ key: 'mechanics', title: 'Nearby Mechanics', defaultOpen: false },
{ key: 'negotiation', title: 'Negotiation Tips', defaultOpen: false },
{ key: 'budget', title: 'Budget Breakdown', defaultOpen: false },
];
let html = `<div class="guide-section">
<div class="section-title">Your Custom Buying Guide</div>`;
for (const sec of sections) {
const content = guide[sec.key];
if (!content) continue;
const openClass = sec.defaultOpen ? 'open' : '';
html += `
<div class="guide-accordion ${openClass}">
<div class="guide-accordion-header" onclick="this.parentElement.classList.toggle('open')">
<h3>${sec.title}</h3>
<span class="arrow">&#9660;</span>
</div>
<div class="guide-accordion-body">
${renderMarkdown(content)}
</div>
</div>`;
}
html += '</div>';
container.innerHTML = html;
container.style.display = 'block';
}
// --- Simple markdown renderer ---
function renderMarkdown(text) {
if (!text) return '';
let html = escHtml(text);
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Headers (process before line breaks)
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Unordered lists
html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>');
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
// Numbered lists
html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>');
// Paragraphs: double newlines
html = html.replace(/\n\n/g, '</p><p>');
// Single newlines that aren't inside tags
html = html.replace(/\n/g, '<br>');
// Wrap in paragraph if not starting with a tag
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
}
// --- Util ---
function escHtml(s) {
if (!s) return '';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
// --- Init ---
renderFilters();
// noVNC iframe setup
(function() {
const vncFrame = document.getElementById('vnc-frame');
const host = window.location.hostname;
const proto = window.location.protocol;
if (host.endsWith('.ampbox.io')) {
vncFrame.src = proto + '//vnc.ampbox.io/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000';
} else {
vncFrame.src = proto + '//' + host + ':6080/vnc.html?autoconnect=true&resize=scale&reconnect=true&reconnect_delay=1000';
}
})();
</script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
# test_structure.sh - Verify expected repo state after task-1-clean-old-files
# RED: Run before changes (should fail)
# GREEN: Run after changes (should pass)
REPO=/home/ken/workspace/research-workbench
PASS=0
FAIL=0
check_absent() {
if [ ! -e "$REPO/$1" ]; then
echo "PASS: '$1' is absent"
((PASS++))
else
echo "FAIL: '$1' should be absent but exists"
((FAIL++))
fi
}
check_dir() {
if [ -d "$REPO/$1" ]; then
echo "PASS: directory '$1' exists"
((PASS++))
else
echo "FAIL: directory '$1' should exist but is missing"
((FAIL++))
fi
}
check_gitignore_line() {
if grep -qF "$1" "$REPO/.gitignore"; then
echo "PASS: .gitignore contains '$1'"
((PASS++))
else
echo "FAIL: .gitignore is missing '$1'"
((FAIL++))
fi
}
echo "=== Checking old car-help files are removed ==="
check_absent "search.py"
check_absent "guide.py"
check_absent "sites.yaml"
check_absent "app.py"
check_absent "static"
check_absent "results"
check_absent "Dockerfile"
check_absent "entrypoint.sh"
check_absent "pyproject.toml"
check_absent "uv.lock"
echo ""
echo "=== Checking new directories exist ==="
check_dir "backend"
check_dir "tests"
check_dir "bundle/behaviors"
check_dir "bundle/agents"
check_dir "bundle/context"
check_dir "frontend/src"
check_dir "artifacts"
check_dir "docs/plans"
echo ""
echo "=== Checking .gitignore contents ==="
# Python
check_gitignore_line ".venv/"
check_gitignore_line "__pycache__/"
check_gitignore_line "*.pyc"
check_gitignore_line "*.egg-info/"
# Node/Frontend
check_gitignore_line "frontend/node_modules/"
check_gitignore_line "frontend/dist/"
# Runtime
check_gitignore_line "artifacts/"
# IDE
check_gitignore_line ".idea/"
check_gitignore_line ".vscode/"
check_gitignore_line "*.swp"
echo ""
echo "=============================="
echo "Results: $PASS passed, $FAIL failed"
echo "=============================="
[ $FAIL -eq 0 ] && exit 0 || exit 1
Generated
-349
View File
@@ -1,349 +0,0 @@
version = 1
revision = 3
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]]
name = "car-help"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi" },
{ name = "httpx" },
{ name = "playwright" },
{ name = "uvicorn" },
]
[package.metadata]
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]]
name = "greenlet"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
{ 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]]
name = "playwright"
version = "1.60.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" },
{ url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" },
{ url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" },
{ url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" },
{ url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" },
{ url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" },
{ 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]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
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" },
]
[[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]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
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" },
]
[[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" },
]