#!/usr/bin/env python3 """ BlaaAi — AI-powered DBA listing analyzer FastAPI backend Usage: uvicorn app:app --reload --port 8000 """ import json, os, smtplib from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from pathlib import Path from typing import Optional from fastapi import BackgroundTasks, FastAPI, HTTPException from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel from starlette.requests import Request from fetch_dba import ( DATA_DIR, ITEM_CACHE, create_search, detect_domain, enrich_listings, fetch_page, find_new, listings_file, load_meta, load_seen, save_seen, seen_file, list_searches, ) from score import CRITERIA, METRICS_FILE, score_listings app = FastAPI(title="BlaaAi") app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") executor = ThreadPoolExecutor(max_workers=4) # ── Meta helpers ────────────────────────────────────────────────────────────── def update_meta(search_id: str, **kwargs) -> dict: p = DATA_DIR / search_id / "meta.json" meta = json.loads(p.read_text()) meta.update(kwargs) p.write_text(json.dumps(meta, ensure_ascii=False, indent=2)) return meta # ── Background pipeline ─────────────────────────────────────────────────────── def run_fetch_and_score(search_id: str, prefs: str = "") -> None: """Synchronous fetch + score pipeline — runs in thread pool.""" try: meta = load_meta(search_id) search_url = meta["url"] # ── Fetch ────────────────────────────────────────────────────────────── update_meta(search_id, status="fetching") sf = seen_file(search_id) lf = listings_file(search_id) seen = load_seen(sf) listings, _ = fetch_page(search_url, 1) new_items = find_new(listings, seen) if new_items: new_items = enrich_listings(new_items) existing = json.loads(lf.read_text()) if lf.exists() else [] existing.extend(new_items) lf.write_text(json.dumps(existing, ensure_ascii=False, indent=2)) save_seen(sf, seen | {l["id"] for l in listings}) # ── Score ────────────────────────────────────────────────────────────── update_meta(search_id, status="scoring") items = json.loads(lf.read_text()) if lf.exists() else [] domain = detect_domain(search_url) criteria = CRITERIA[domain] score_listings(items, criteria, prefs, force=False, source_file=lf) update_meta( search_id, status="ready", listing_count=len(items), last_scored_at=datetime.now().isoformat(timespec="seconds"), prefs=prefs, ) except Exception as e: update_meta(search_id, status="error", error=str(e)) raise # ── Pydantic models ─────────────────────────────────────────────────────────── class NewSearchRequest(BaseModel): url: str prefs: Optional[str] = "" class EmailRequest(BaseModel): email: str # ── Routes ──────────────────────────────────────────────────────────────────── @app.get("/health") async def health(): return { "status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat(), "version": os.getenv("BUILD_VERSION", "unknown"), "commit": os.getenv("GIT_COMMIT", "unknown")[:7], } @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse(request, "index.html") @app.get("/search/{search_id}", response_class=HTMLResponse) async def search_view(request: Request, search_id: str): return templates.TemplateResponse(request, "index.html", {"search_id": search_id}) @app.post("/api/searches") async def create(body: NewSearchRequest, background_tasks: BackgroundTasks): search_id = create_search(body.url) update_meta(search_id, status="queued", prefs=body.prefs or "") background_tasks.add_task(run_fetch_and_score, search_id, body.prefs or "") return {"id": search_id, "status": "queued"} @app.get("/api/searches") async def get_all(): return list_searches() @app.get("/api/searches/{search_id}") async def get_search(search_id: str): try: meta = load_meta(search_id) except FileNotFoundError: raise HTTPException(404, "Søgning ikke fundet") lf = listings_file(search_id) if lf.exists(): items = json.loads(lf.read_text()) ranked = sorted( [i for i in items if i.get("ai_score") is not None], key=lambda x: x["ai_score"], reverse=True, ) meta["listings"] = ranked meta["listing_count"] = len(items) meta["scored_count"] = len(ranked) if "status" not in meta and ranked: meta["status"] = "ready" if "status" not in meta: meta["status"] = "ready" if meta.get("listing_count", 0) > 0 else "unknown" return meta @app.post("/api/searches/{search_id}/rescore") async def rescore(search_id: str, body: NewSearchRequest, background_tasks: BackgroundTasks): try: load_meta(search_id) except FileNotFoundError: raise HTTPException(404, "Søgning ikke fundet") update_meta(search_id, status="queued", prefs=body.prefs or "") background_tasks.add_task(run_fetch_and_score, search_id, body.prefs or "") return {"id": search_id, "status": "queued"} @app.get("/metrics") async def get_metrics(): global_metrics = {} if METRICS_FILE.exists(): global_metrics = json.loads(METRICS_FILE.read_text()) # Per-search breakdown searches = [] if DATA_DIR.exists(): for d in sorted(DATA_DIR.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): mf = d / "metrics.json" if mf.exists(): searches.append(json.loads(mf.read_text())) # Item cache stats cached_items = len(list(ITEM_CACHE.glob("*.json"))) if ITEM_CACHE.exists() else 0 return {**global_metrics, "item_cache_size": cached_items, "searches": searches} @app.post("/api/searches/{search_id}/email") async def send_email(search_id: str, body: EmailRequest): try: meta = load_meta(search_id) except FileNotFoundError: raise HTTPException(404, "Søgning ikke fundet") if meta.get("status") != "ready": raise HTTPException(400, "Analysen er ikke færdig endnu") lf = listings_file(search_id) if not lf.exists(): raise HTTPException(400, "Ingen resultater at sende") items = json.loads(lf.read_text()) ranked = sorted( [i for i in items if i.get("ai_score") is not None], key=lambda x: x["ai_score"], reverse=True, )[:10] html = _build_email_html(ranked, meta) _send_email(body.email, "🔍 Dine DBA-resultater fra BlaaAi", html) return {"status": "sent", "to": body.email} # ── Email helpers ───────────────────────────────────────────────────────────── def _build_email_html(ranked: list[dict], meta: dict) -> str: rows = "" for i, item in enumerate(ranked, 1): score = item.get("ai_score", 0) bar = "█" * int(score) + "░" * (10 - int(score)) reason = item.get("ai_reason", "") warn = f'
⚠️ {item["ai_warnings"]}
' if item.get("ai_warnings") else "" rows += f"""{reason}
{warn} Se annonce →Søgning: {meta['url'][:60]}…
Leveret af BlaaAi
""" def _send_email(to: str, subject: str, html: str) -> None: host = os.environ.get("SMTP_HOST", "") user = os.environ.get("SMTP_USER", "") pwd = os.environ.get("SMTP_PASS", "") port = int(os.environ.get("SMTP_PORT", "587")) if not host: raise HTTPException(503, "Email ikke konfigureret (SMTP_HOST mangler)") msg = MIMEMultipart("alternative") msg["Subject"] = subject msg["From"] = user msg["To"] = to msg.attach(MIMEText(html, "html")) with smtplib.SMTP(host, port) as s: s.starttls() s.login(user, pwd) s.sendmail(user, to, msg.as_string()) if __name__ == "__main__": import uvicorn port = int(os.getenv("PORT", "8000")) uvicorn.run("app:app", host="0.0.0.0", port=port, reload=True)