chore: remove old car-help files, prepare new structure
This commit is contained in:
+14
-1
@@ -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
@@ -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"]
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
@@ -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.
|
|
||||||
@@ -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">🚗</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\\'>🚗</div>'">`
|
|
||||||
: `<div class="card-img-placeholder">🚗</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">▼</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>
|
|
||||||
Executable
+85
@@ -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
|
||||||
@@ -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" },
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user