SPB Git

spb/trouve-ka Public

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

Python 76.8% TypeScript 15.7% SQL 3.9% Shell 1.4% CSS 1.3% Dockerfile 0.7%
10.1 KB · 306 lines python
Raw Blame History
1# Trouve-KA — API FastAPI2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai45"""API de Trouve-KA.67Publique : /api/search, /api/status, /api/submit, /api/health8Protégée (X-Admin-Token) : /api/admin/* — contrôle du crawler, jamais public sans auth.910Dégradation gracieuse : si OpenSearch tombe, /api/status répond quand même;11si Postgres tombe, la recherche répond quand même (analytics sautées).12"""1314import html15import re16import time17from contextlib import asynccontextmanager18from typing import Annotated1920from fastapi import Depends, FastAPI, Header, HTTPException, Query21from fastapi.middleware.cors import CORSMiddleware22from pydantic import BaseModel2324from trouveka.config import get_settings25from trouveka.database import Database26from trouveka.logging import get_logger27from trouveka.queue import Coordination28from trouveka.ranking import build_search_body29from trouveka.search_core import SearchCore30from trouveka.shared import canonicalize_url, display_url, extract_domain, is_http_url3132log = get_logger("api")33settings = get_settings()3435db = Database(settings.database_url, pool_min=settings.pg_pool_min, pool_max=settings.pg_pool_max)36coord = Coordination(settings.redis_url)37search = SearchCore(settings.search_url, settings.search_index)383940@asynccontextmanager41async def lifespan(_app: FastAPI):42    await db.connect()43    await search.ensure_index()44    yield45    await search.close()46    await coord.close()47    await db.close()484950app = FastAPI(title="Trouve-KA API", version="0.1.0", lifespan=lifespan)5152# Derrière ngrok (www.trouve-ka.com), le web app proxifie /api : CORS permissif inutile53# en prod, mais pratique en dev local (web sur :3000, API sur :8080).54app.add_middleware(55    CORSMiddleware,56    allow_origins=["http://localhost:3000", settings.public_url],57    allow_methods=["GET", "POST"],58    allow_headers=["*", "X-Admin-Token"],59)606162def require_admin(x_admin_token: Annotated[str | None, Header()] = None) -> None:63    if not x_admin_token or x_admin_token != settings.admin_token:64        raise HTTPException(status_code=401, detail="Jeton admin invalide")656667_TAG_RE = re.compile(r"<(?!/?em>)[^>]*>")686970def _safe_snippet(fragments: list[str]) -> str:71    """Ne laisse passer que <em>/</em> (highlight); tout le reste est échappé par OpenSearch."""72    snippet = _TAG_RE.sub("", " … ".join(fragments))[:400]73    # La troncature peut couper une balise en deux (« …Plombier</em ») : on nettoie74    snippet = re.sub(r"<[^>]*$", "", snippet)75    if snippet.count("<em>") > snippet.count("</em>"):76        snippet += "</em>"77    return snippet787980BADGE_LABELS = {"government": "Gouvernement", "news": "Actualités", "education": "Éducation"}818283# ---------------------------------------------------------------------- publique8485@app.get("/api/health")86async def health():87    return {"ok": True, "search_ok": await search.ping()}888990@app.get("/api/search")91async def api_search(92    q: str = Query(..., min_length=1, max_length=200),93    page: int = Query(1, ge=1, le=100),94    limit: int = Query(10, ge=1, le=50),95    language: str | None = Query(None, pattern="^(fr|en)$"),96    location: str | None = None,97    category: str | None = Query(None, max_length=40),98    quebec_only: bool = False,99    freshness: str | None = Query(None, pattern="^(day|week|month|year)$"),100    images: bool = False,101):102    started = time.monotonic()103    query_text = f"{q} {location}" if location else q104    body, analysis = build_search_body(105        query_text, page=page, limit=limit, language=language,106        category=category, quebec_only=quebec_only, freshness=freshness,107        images_only=images,108    )109    try:110        res = await search.search(body)111    except Exception:112        log.exception("recherche échouée", extra={"ctx": {"q": q}})113        raise HTTPException(status_code=503, detail="Le moteur de recherche est temporairement indisponible")114115    took_ms = int((time.monotonic() - started) * 1000)116    results = []117    for hit in res["hits"]["hits"]:118        src = hit["_source"]119        highlight = hit.get("highlight", {})120        fragments = highlight.get("body") or highlight.get("description") or []121        snippet = _safe_snippet(fragments) if fragments else html.escape(src.get("description") or "")[:400]122        badges = [BADGE_LABELS[c] for c in src.get("categories", []) if c in BADGE_LABELS]123        if src.get("page_quebec_score", 0) >= 0.45 or src.get("domain_quebec_score", 0) >= 0.6:124            badges.insert(0, "Québec")125        results.append({126            "title": src.get("title") or src["url"],127            "url": src["url"],128            "display_url": display_url(src["url"]),129            "snippet": snippet,130            "domain": src["domain"],131            "language": src.get("language"),132            "quebec_score": src.get("page_quebec_score", 0),133            "badges": badges,134            "published_at": src.get("published_at"),135            "image": src.get("image_url"),136        })137138    total = res["hits"]["total"]["value"]139    # Analytics agrégées, respectueuses de la vie privée — jamais bloquantes140    try:141        await db.record_search_query(q, analysis["language"], total, took_ms)142    except Exception:143        log.exception("analytics de recherche sautées")144145    return {"query": q, "total": total, "took_ms": took_ms, "page": page, "limit": limit, "results": results}146147148@app.get("/api/status")149async def api_status():150    snapshot: dict = {}151    try:152        snapshot = await db.status_snapshot()153    except Exception:154        log.exception("statut PG indisponible")155    search_ok = await search.ping()156    try:157        paused = await coord.is_paused()158    except Exception:159        paused = False160    return {161        "pages_indexed": snapshot.get("pages_indexed", 0),162        "domains_count": snapshot.get("domains_count", 0),163        "indexed_last_hour": snapshot.get("indexed_last_hour", 0),164        "fetched_last_hour": snapshot.get("fetched_last_hour", 0),165        "errors_last_hour": snapshot.get("errors_last_hour", 0),166        "frontier_pending": snapshot.get("frontier_pending", 0),167        "frontier_in_progress": snapshot.get("frontier_in_progress", 0),168        "crawler_state": "paused" if paused else "running",169        "search_ok": search_ok,170    }171172173@app.get("/api/live")174async def api_live():175    """Dernière page visitée par TrouveKABot — alimente le flux temps réel du footer.176177    Public mais volontairement minimal : domaine + URL + horodatage, rien d'interne.178    """179    try:180        events = await db.recent_events(1)181    except Exception:182        return {"event": None}183    if not events:184        return {"event": None}185    e = events[0]186    return {187        "event": {188            "at": e["at"],189            "url": e["url"],190            "domain": extract_domain(e["url"]),191            "outcome": e["outcome"],192        }193    }194195196class SubmitBody(BaseModel):197    url: str198199200@app.post("/api/submit")201async def api_submit(payload: SubmitBody):202    """Soumettre un site québécois. Soumission ≠ inclusion : le crawler valide."""203    if not is_http_url(payload.url):204        raise HTTPException(status_code=422, detail="URL invalide (http/https seulement)")205    url = canonicalize_url(payload.url)206    domain = extract_domain(url) if url else None207    if not url or not domain:208        raise HTTPException(status_code=422, detail="URL invalide")209    await db.add_submission(url)210    await db.enqueue_url(url, domain, priority=0.7, depth=0)211    return {212        "accepted": True,213        "message": "Merci! Le site sera visité par TrouveKABot. La soumission ne garantit pas l'inclusion.",214    }215216217# ---------------------------------------------------------------------- admin218219class SeedsBody(BaseModel):220    urls: list[str]221222223class RecrawlBody(BaseModel):224    url: str | None = None225    domain: str | None = None226227228class DomainBody(BaseModel):229    domain: str230231232@app.get("/api/admin/overview", dependencies=[Depends(require_admin)])233async def admin_overview():234    overview = await db.admin_overview()235    overview["paused"] = await coord.is_paused()236    try:237        overview["enrich_backlog"] = await coord.enrich_backlog()238    except Exception:239        overview["enrich_backlog"] = None240    return overview241242243@app.get("/api/admin/recent", dependencies=[Depends(require_admin)])244async def admin_recent(limit: int = Query(50, ge=1, le=200)):245    return {"events": await db.recent_events(limit)}246247248@app.post("/api/admin/pause", dependencies=[Depends(require_admin)])249async def admin_pause():250    await coord.pause_crawler()251    return {"paused": True}252253254@app.post("/api/admin/resume", dependencies=[Depends(require_admin)])255async def admin_resume():256    await coord.resume_crawler()257    return {"paused": False}258259260@app.post("/api/admin/seeds", dependencies=[Depends(require_admin)])261async def admin_seeds(payload: SeedsBody):262    added = 0263    for raw in payload.urls[:500]:264        url = canonicalize_url(raw.strip())265        domain = extract_domain(url) if url else None266        if url and domain:267            if await db.enqueue_url(url, domain, priority=1.0, depth=0, is_seed=True):268                added += 1269    return {"added": added}270271272@app.post("/api/admin/recrawl", dependencies=[Depends(require_admin)])273async def admin_recrawl(payload: RecrawlBody):274    if payload.url:275        url = canonicalize_url(payload.url)276        ok = await db.requeue_url(url) if url else False277        return {"requeued": 1 if ok else 0}278    if payload.domain:279        return {"requeued": await db.requeue_domain(payload.domain.lower())}280    raise HTTPException(status_code=422, detail="url ou domain requis")281282283@app.post("/api/admin/domains/block", dependencies=[Depends(require_admin)])284async def admin_block_domain(payload: DomainBody):285    await db.block_domain(payload.domain.lower())286    return {"blocked": payload.domain.lower()}287288289@app.get("/api/admin/frontier", dependencies=[Depends(require_admin)])290async def admin_frontier(291    domain: str | None = None,292    status: str | None = Query(None, pattern="^(pending|in_progress|done|failed|blocked)$"),293    limit: int = Query(100, ge=1, le=500),294):295    return {"items": await db.frontier_inspect(domain, status, limit)}296297298def main() -> None:299    import uvicorn300301    uvicorn.run("trouveka.api.main:app", host=settings.api_host, port=settings.api_port, workers=1)302303304if __name__ == "__main__":305    main()306