SPB Git

spb/ka4 Public

ka4 — explorateur structuré du web québécois (édition agressive, Groupe KA). Crawling concurrent, Claude Sonnet+Haiku, Firecrawl/Scrapfly, graphe de connaissances.

Python 97.8% Shell 2.2%
71.8 KB · 1,336 lines python
Raw Blame History
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3"""API + dashboard de suivi de ka4 (FastAPI).45Interface alignée sur le système de design du Groupe KA (néo-brutalisme éditorial :6papier chaud, encre, accent lime, bordures nettes, ombres dures décalées).7Temps réel via SSE, réglages personnalisables (backend, budget, session, accent).8Le bot de crawl tourne dans un processus séparé partageant la même base SQLite (WAL).9"""1011from __future__ import annotations1213import asyncio14import json15import time16from typing import Optional1718from fastapi import FastAPI, Request19from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse2021from src.config import config22from src.storage import Store2324app = FastAPI(title="ka4 — cartographe du web québécois", version="2.0.0")2526SESSION_SECONDS = 15 * 60  # bloc de session (bouton « Continuer »)2728# Réglages personnalisables : nom public -> (clé en base, défaut effectif, type)29CONFIG_KEYS = {30    "daily_budget": ("cfg_daily_budget", 800, int),31    "cycle_delay": ("cfg_cycle_delay", 20.0, float),32    "queue_batch": ("cfg_queue_batch", 8, int),33    "session_minutes": ("cfg_session_minutes", 15.0, float),34    "max_pages": ("cfg_max_pages", config.max_pages_per_run, int),35    "backend": ("cfg_backend", config.scraper_backend, str),36    "respect_robots": ("cfg_respect_robots", "1" if config.respect_robots else "0", str),37    "delay": ("cfg_delay", config.request_delay, float),38}394041def store() -> Store:42    return Store(config.db_path)434445def _domain(url: Optional[str]) -> str:46    """Domaine racine simplifié d'une URL (pour relier entités du même site)."""47    if not url:48        return ""49    u = url.strip().lower()50    for pref in ("https://", "http://"):51        if u.startswith(pref):52            u = u[len(pref):]53    u = u.split("/")[0]54    if u.startswith("www."):55        u = u[4:]56    return u575859def _session(s: Store) -> dict:60    try:61        run_until = float(s.get_setting("run_until", "0") or 0)62    except ValueError:63        run_until = 0.064    remaining = max(0, int(run_until - time.time()))65    return {"run_until": run_until, "session_remaining": remaining, "session_active": remaining > 0}666768@app.get("/health")69def health():70    return {"status": "ok", "service": "ka4"}717273@app.get("/api/stats")74def api_stats():75    s = store()76    try:77        data = s.stats()78        data["paused"] = s.get_setting("paused", "0") == "1"79        data["active_mission_id"] = s.get_setting("active_mission_id", "")80        data.update(_session(s))81        return data82    finally:83        s.close()848586@app.get("/api/events")87def api_events(after_id: int = 0, limit: int = 80):88    s = store()89    try:90        return {"events": s.recent_events(limit=limit, after_id=after_id)}91    finally:92        s.close()939495def _filter_entities(s: Store, type=None, q=None, region=None, sector=None):96    rows = s.export()97    if type:98        rows = [r for r in rows if r["type"] == type]99    if region:100        rl = region.lower()101        rows = [r for r in rows if rl in ((r.get("region") or r.get("location") or "").lower())]102    if sector:103        sl = sector.lower()104        rows = [r for r in rows if sl in ((r.get("sector") or "").lower())]105    if q:106        ql = q.lower()107        rows = [r for r in rows if ql in (r["name"] or "").lower()108                or ql in (r.get("description") or "").lower()109                or ql in (r.get("sector") or "").lower()]110    deg = s.degrees()111    for r in rows:112        r["domain"] = r.get("domain") or _domain(r.get("url"))113        r["relations_count"] = deg.get(r["id"], 0)114        r["social_count"] = len(r.get("social_links") or [])115    return rows116117118@app.get("/api/entities")119def api_entities(type: Optional[str] = None, q: Optional[str] = None, region: Optional[str] = None,120                 sector: Optional[str] = None, sort: str = "name", offset: int = 0, limit: int = 60):121    s = store()122    try:123        rows = _filter_entities(s, type, q, region, sector)124        if sort == "degree":125            rows.sort(key=lambda r: (r["relations_count"], r.get("social_count", 0)), reverse=True)126        elif sort == "recent":127            rows.sort(key=lambda r: (r.get("last_seen") or r.get("updated_at") or 0), reverse=True)128        else:129            rows.sort(key=lambda r: (r.get("name") or "").lower())130        total = len(rows)131        return {"count": total, "entities": rows[offset:offset + limit],132                "offset": offset, "limit": limit}133    finally:134        s.close()135136137@app.get("/api/export.json")138def export_json(type: Optional[str] = None, q: Optional[str] = None,139                region: Optional[str] = None, sector: Optional[str] = None):140    s = store()141    try:142        rows = _filter_entities(s, type, q, region, sector)143    finally:144        s.close()145    import json as _json146    return StreamingResponse(147        iter([_json.dumps(rows, ensure_ascii=False, indent=2)]),148        media_type="application/json",149        headers={"Content-Disposition": "attachment; filename=ka4-entites.json"},150    )151152153@app.get("/api/export.csv")154def export_csv(type: Optional[str] = None, q: Optional[str] = None,155               region: Optional[str] = None, sector: Optional[str] = None):156    s = store()157    try:158        rows = _filter_entities(s, type, q, region, sector)159    finally:160        s.close()161    import csv as _csv162    import io as _io163    cols = ["id", "type", "name", "sector", "region", "city", "url", "email", "phone",164            "founded", "size", "neq", "relations_count", "social_count"]165    buf = _io.StringIO()166    w = _csv.writer(buf)167    w.writerow(cols)168    for r in rows:169        w.writerow([r.get(c, "") for c in cols])170    return StreamingResponse(171        iter([buf.getvalue()]), media_type="text/csv",172        headers={"Content-Disposition": "attachment; filename=ka4-entites.csv"},173    )174175176@app.get("/api/explore/summary")177def explore_summary():178    s = store()179    try:180        st = s.stats()181        return {182            "total": st["entities"],183            "relations": st["relations"],184            "by_type": st["by_type"],185            "regions": s.distinct_locations()[:40],186            "sectors": s.sector_counts()[:40],187            "platforms": s.platform_counts()[:16],188            "top_connected": s.top_connected(10),189        }190    finally:191        s.close()192193194@app.get("/api/graph")195def api_graph(type: Optional[str] = None, region: Optional[str] = None,196              sector: Optional[str] = None, limit: int = 140):197    s = store()198    try:199        return s.graph(limit=limit, etype=type or "", region=region or "",200                       sector=sector or "", connected_only=True)201    finally:202        s.close()203204205@app.get("/api/entity/{entity_id}")206def api_entity(entity_id: int):207    s = store()208    try:209        e = s.get_entity(entity_id)210        if not e:211            return JSONResponse({"error": "introuvable"}, status_code=404)212        e["domain"] = e.get("domain") or _domain(e.get("url"))213        # Relations RÉELLES extraites (typées, avec rôle) — le cœur du graphe214        e["relations"] = s.entity_relations(entity_id)215        # Contexte secondaire : autres entités de la même région216        region = (e.get("region") or e.get("location") or "").strip()217        same_region = []218        if region:219            linked = {r["id"] for r in e["relations"]}220            for r in s.export():221                if r["id"] == entity_id or r["id"] in linked:222                    continue223                if (r.get("region") or r.get("location") or "").strip() == region:224                    same_region.append({"id": r["id"], "name": r["name"], "type": r["type"]})225        e["same_region"] = same_region[:20]226    finally:227        s.close()228    return e229230231@app.get("/api/missions")232def api_missions():233    s = store()234    try:235        return {"missions": s.list_missions()}236    finally:237        s.close()238239240@app.post("/api/missions")241def add_mission(payload: dict):242    s = store()243    try:244        mid = s.add_mission(245            goal=payload.get("goal", "").strip(),246            seed=payload.get("seed", ""),247            sector=payload.get("sector", ""),248            region=payload.get("region", ""),249            priority=int(payload.get("priority", 5)),250        )251        return {"id": mid, "ok": bool(mid)}252    finally:253        s.close()254255256@app.post("/api/missions/{mission_id}/{op}")257def mission_op(mission_id: int, op: str):258    s = store()259    try:260        if op == "delete":261            s.delete_mission(mission_id)262            s.log_event("info", f"Mission #{mission_id} supprimée.")263        elif op == "prioritize":264            # « Lancer maintenant » : priorité max + interrompt l'activité en cours265            s.prioritize_mission(mission_id)266            s.set_setting("abort_current", "1")267            s.set_setting("paused", "0")268            s.log_event("info", f"Mission #{mission_id} priorisée (lancer maintenant).")269        else:270            return JSONResponse({"error": "op inconnue"}, status_code=400)271        return {"ok": True}272    finally:273        s.close()274275276@app.post("/api/archive")277def archive_now(payload: dict):278    s = store()279    try:280        # 1) stopper l'activité en cours (pause + interruption) pour éviter la course281        s.set_setting("paused", "1")282        s.set_setting("abort_current", "1")283        # 2) attendre que le bot confirme qu'il est au repos (≤20s) avant de vider284        for _ in range(40):285            if s.get_setting("bot_busy", "0") != "1":286                break287            time.sleep(0.5)288        # 3) snapshot + reset289        res = s.archive_and_reset(payload.get("label", ""))290        s.log_event("info", f"Archive « {res['label']} » créée ({res['entities']} entités). Espace de travail vidé.")291        return res292    finally:293        s.close()294295296@app.get("/api/archives")297def api_archives():298    s = store()299    try:300        return {"archives": s.list_archives()}301    finally:302        s.close()303304305@app.post("/api/archive/{archive_id}/{op}")306def archive_op(archive_id: int, op: str):307    s = store()308    try:309        if op == "restore":310            return s.restore_archive(archive_id)311        if op == "delete":312            s.delete_archive(archive_id)313            return {"ok": True}314        return JSONResponse({"error": "op inconnue"}, status_code=400)315    finally:316        s.close()317318319@app.get("/api/archive/{archive_id}/export.json")320def archive_export(archive_id: int):321    s = store()322    try:323        a = s.get_archive(archive_id)324    finally:325        s.close()326    if not a:327        return JSONResponse({"error": "introuvable"}, status_code=404)328    import json as _json329    return StreamingResponse(330        iter([_json.dumps(a["data"], ensure_ascii=False, indent=2)]),331        media_type="application/json",332        headers={"Content-Disposition": f"attachment; filename=ka-archive-{archive_id}.json"},333    )334335336@app.post("/api/control/{action}")337def control(action: str):338    s = store()339    try:340        if action == "pause":341            s.set_setting("paused", "1")342        elif action == "resume":343            s.set_setting("paused", "0")344        elif action == "skip":345            # arrête l'agent / le drainage en cours et passe à la suite346            s.set_setting("abort_current", "1")347            s.log_event("info", "Interruption demandée (arrêter l'activité en cours).")348        elif action == "extend":349            try:350                cur = float(s.get_setting("run_until", "0") or 0)351            except ValueError:352                cur = 0.0353            s.set_setting("run_until", str(max(cur, time.time()) + SESSION_SECONDS))354            s.set_setting("paused", "0")355            s.log_event("info", "Session prolongée de 15 min (bouton Continuer).")356        elif action == "stop":357            s.set_setting("run_until", str(time.time()))358        else:359            return JSONResponse({"error": "action inconnue"}, status_code=400)360        out = {"paused": s.get_setting("paused", "0") == "1"}361        out.update(_session(s))362        return out363    finally:364        s.close()365366367@app.get("/api/config")368def get_config():369    s = store()370    try:371        out = {}372        for name, (key, default, cast) in CONFIG_KEYS.items():373            raw = s.get_setting(key, "")374            try:375                out[name] = cast(raw) if raw != "" else default376            except ValueError:377                out[name] = default378        return out379    finally:380        s.close()381382383@app.post("/api/config")384def set_config(payload: dict):385    s = store()386    try:387        applied = {}388        for name, value in payload.items():389            if name not in CONFIG_KEYS:390                continue391            key, _default, cast = CONFIG_KEYS[name]392            try:393                val = cast(value)394            except (ValueError, TypeError):395                continue396            s.set_setting(key, str(val))397            applied[name] = val398        if applied:399            s.log_event("info", "Réglages: " + ", ".join(f"{k}={v}" for k, v in applied.items()))400        return {"applied": applied}401    finally:402        s.close()403404405@app.get("/api/stream")406async def stream(request: Request):407    """Flux SSE : pousse stats + nouveaux événements (remplace le polling lourd)."""408409    async def gen():410        last_id = 0411        while True:412            if await request.is_disconnected():413                break414            s = store()415            try:416                stats = s.stats()417                stats["paused"] = s.get_setting("paused", "0") == "1"418                stats.update(_session(s))419                events = s.recent_events(limit=40, after_id=last_id)420                if events:421                    last_id = max(e["id"] for e in events)422            finally:423                s.close()424            payload = {"stats": stats, "events": list(reversed(events))}425            yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"426            await asyncio.sleep(2.5)427428    return StreamingResponse(429        gen(),430        media_type="text/event-stream",431        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},432    )433434435@app.get("/ka4.css")436def ka4_css():437    # CSS partagé (extrait du dashboard) — source unique pour les deux pages.438    css = HTML.split("<style>", 1)[1].split("</style>", 1)[0]439    return StreamingResponse(iter([css]), media_type="text/css",440                             headers={"Cache-Control": "max-age=300"})441442443@app.get("/", response_class=HTMLResponse)444def index():445    return HTML446447448@app.get("/explore", response_class=HTMLResponse)449def explore():450    return EXPLORE_HTML451452453# =========================================================================454#  Dashboard mono-fichier — design system Groupe KA (néo-brutalisme éditorial)455# =========================================================================456HTML = r"""<!doctype html>457<html lang="fr"><head>458<meta charset="utf-8">459<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">460<meta name="theme-color" content="#f5f3ee">461<title>ka4 · explorateur structuré du web — Groupe KA</title>462<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Ctext x='50%25' y='54%25' font-family='Arial Black,Arial' font-weight='900' font-size='30' fill='%23e11d48' text-anchor='middle' dominant-baseline='central'%3Ek4%3C/text%3E%3C/svg%3E">463<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>464<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">465<style>466:root{467  --paper:#f5f3ee;--surface:#ffffff;--surface-2:#faf9f5;468  --ink:#141814;--ink-2:#4d5551;--ink-3:#8b928c;469  --line:rgba(20,24,20,.14);--line-strong:rgba(20,24,20,.85);470  --green:#9f1239;--green-deep:#7f1d33;471  --lime:#e11d48;--lime-soft:#fce7ec;472  --amber:#e8a33d;--amber-soft:#fdf3e2;--danger:#b3423a;--danger-soft:#fbe9e7;473  --r-card:10px;--r-ctl:6px;474  --shadow-flat:0 1px 2px rgba(20,24,20,.05);475  --shadow-off:6px 6px 0 var(--ink);476  --shadow-off-soft:8px 8px 0 rgba(20,24,20,.08);477  --font-display:'Space Grotesk',system-ui,sans-serif;478  --font-body:'Inter',system-ui,sans-serif;479  --font-mono:'JetBrains Mono',ui-monospace,monospace;480}481*{box-sizing:border-box}482html,body{margin:0}483body{font-family:var(--font-body);font-size:15px;line-height:1.55;background:var(--paper);color:var(--ink);484  -webkit-font-smoothing:antialiased;position:relative;min-height:100vh}485body::before{content:"";position:fixed;inset:0;pointer-events:none;z-index:0;opacity:.35;486  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E")}487::selection{background:var(--lime);color:var(--ink)}488a{color:var(--green);text-decoration:none}a:hover{text-decoration:underline}489.wrap{max-width:1240px;margin:0 auto;padding:0 24px;position:relative;z-index:1}490h1,h2,h3{font-family:var(--font-display);letter-spacing:-.03em;margin:0}491.kicker{font-family:var(--font-mono);font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.13em;492  color:var(--green);display:inline-flex;align-items:center;gap:9px}493.kicker::before{content:"";width:22px;height:2px;background:var(--green);display:inline-block}494.mono{font-family:var(--font-mono)}495496/* Header */497header{position:sticky;top:0;z-index:30;background:rgba(245,243,238,.88);backdrop-filter:saturate(180%) blur(14px);498  border-bottom:2px solid var(--ink)}499.hdr{display:flex;align-items:center;gap:16px;height:64px}500.brand{display:flex;align-items:center;gap:12px;min-width:0;text-decoration:none;color:inherit}501.logo{width:40px;height:40px;border-radius:10px;background:var(--ink);color:var(--lime);display:grid;place-items:center;502  font-family:var(--font-display);font-weight:700;font-size:18px;flex:0 0 auto;transform:rotate(-2deg);transition:.15s}503.brand:hover .logo{transform:rotate(0)}504.wordmark{font-family:var(--font-display);font-weight:700;font-size:22px;letter-spacing:-.04em;line-height:1;display:flex;align-items:baseline;gap:1px}505.wordmark .kx{background:var(--ink);color:var(--lime);border-radius:6px;padding:0 6px 2px;transform:rotate(-2deg);display:inline-block}506.wordmark .kn{color:var(--lime);-webkit-text-stroke:.7px var(--ink);paint-order:stroke fill}507.brand .sub{font-family:var(--font-mono);font-size:10px;font-weight:500;color:var(--ink-3);text-transform:uppercase;letter-spacing:.1em;margin-top:3px}508.brand .sub b{color:var(--green);font-weight:700}509.hdr .right{margin-left:auto;display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}510511/* Contrôles */512.status{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:12px;font-weight:500;513  padding:8px 12px;border:1.5px solid var(--ink);border-radius:999px;background:var(--surface);text-transform:uppercase;letter-spacing:.06em}514.dot{width:9px;height:9px;border-radius:50%;background:var(--green);position:relative}515.dot.live::after{content:"";position:absolute;inset:-5px;border-radius:50%;background:var(--lime);opacity:.5;animation:pulse 2.4s ease-out infinite;z-index:-1}516.dot.paused{background:var(--amber)}517@keyframes pulse{0%{transform:scale(.6);opacity:.6}70%{transform:scale(1.8);opacity:0}100%{opacity:0}}518.btn{font-family:var(--font-display);font-weight:700;font-size:13.5px;min-height:40px;padding:8px 16px;cursor:pointer;519  border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--surface);color:var(--ink);transition:.13s ease}520.btn:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 var(--ink)}521.btn:active{transform:translate(2px,2px);box-shadow:none!important}522.btn-primary{background:var(--ink);color:var(--lime);box-shadow:4px 4px 0 rgba(20,24,20,.25)}523.btn-primary:hover{background:var(--green-deep);color:var(--lime)}524.btn-lime{background:var(--lime);color:var(--ink)}525.btn-icon{padding:8px 12px;font-size:16px}526527/* Ticker */528.ticker{background:var(--ink);color:var(--lime);overflow:hidden;border-bottom:2px solid var(--ink);white-space:nowrap}529.ticker .run{display:inline-block;padding:7px 0;font-family:var(--font-mono);font-size:11.5px;font-weight:500;530  text-transform:uppercase;letter-spacing:.1em;animation:tick 42s linear infinite}531.ticker .run span{padding:0 18px;opacity:.92}532@keyframes tick{from{transform:translateX(0)}to{transform:translateX(-50%)}}533534main{padding:26px 0 60px}535536/* KPIs */537.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:22px}538.kpi{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);padding:16px;box-shadow:var(--shadow-off-soft);539  transition:.15s ease}540.kpi:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}541.kpi .lab{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:var(--ink-3)}542.kpi .val{font-family:var(--font-display);font-weight:700;font-size:34px;letter-spacing:-.03em;line-height:1.05;margin-top:8px}543.kpi .bar{height:4px;border-radius:2px;margin-top:10px;background:var(--accent,var(--lime))}544545/* Grille */546.grid{display:grid;grid-template-columns:1fr;gap:18px}547@media(min-width:980px){.grid{grid-template-columns:1.3fr 1fr}}548.card{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off-soft);overflow:hidden}549.card>.head{display:flex;align-items:center;gap:12px;padding:15px 18px;border-bottom:1.5px solid var(--ink)}550.card>.head .count{margin-left:auto;font-family:var(--font-mono);font-size:11px;font-weight:700;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}551552/* Feed */553#feed{max-height:64vh;overflow:auto;padding:6px}554.ev{display:grid;grid-template-columns:58px 92px 1fr;gap:10px;align-items:baseline;padding:9px 12px;border-radius:6px}555.ev:hover{background:var(--surface-2)}556.ev .t{font-family:var(--font-mono);font-size:11px;color:var(--ink-3);font-variant-numeric:tabular-nums}557.ev .msg{font-size:13.5px;word-break:break-word}558.badge{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;559  padding:3px 8px;border-radius:999px;text-align:center;border:1.5px solid var(--ink)}560.b-scrape{background:var(--surface);color:var(--ink)}561.b-extract{background:var(--lime);color:var(--ink)}562.b-agent{background:var(--ink);color:var(--lime)}563.b-search,.b-map{background:var(--amber-soft);color:#8a5a12}564.b-error{background:var(--danger-soft);color:var(--danger)}565.b-info{background:var(--lime-soft);color:var(--green-deep)}566567/* Tableaux */568.tblwrap{overflow:auto;max-height:56vh}569table{width:100%;border-collapse:collapse;font-size:13.5px}570th,td{text-align:left;padding:11px 16px;vertical-align:top}571th{position:sticky;top:0;background:var(--surface-2);font-family:var(--font-mono);font-size:9.5px;font-weight:700;572  text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2);border-bottom:1.5px solid var(--ink);z-index:1}573td{border-bottom:1px solid var(--line)}574tr:hover td{background:var(--surface-2)}575.tag{display:inline-block;font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;576  padding:3px 9px;border-radius:999px;border:1.5px solid var(--ink);background:var(--lime-soft)}577.tag.person{background:var(--ink);color:var(--lime)}578.tag.st-running{background:var(--lime);color:var(--ink)}.tag.st-pending{background:var(--surface-2)}579.tag.st-done{background:var(--lime-soft);color:var(--green-deep)}.tag.st-error{background:var(--danger-soft);color:var(--danger)}580.name{font-family:var(--font-display);font-weight:600}581.desc{color:var(--ink-2);font-size:12px;margin-top:2px}582.chip{display:inline-block;font-family:var(--font-mono);font-size:11px;font-weight:500;padding:2px 9px;border:1.5px solid var(--ink);583  border-radius:999px;margin:2px 3px 0 0;color:var(--ink);background:var(--surface);box-shadow:2px 2px 0 rgba(20,24,20,.1)}584.chip:hover{text-decoration:none;background:var(--lime-soft)}585586/* Filtres / formulaires */587.filters,.mform{display:flex;gap:9px;flex-wrap:wrap;padding:13px 18px;border-bottom:1.5px solid var(--ink)}588.mform input{flex:1;min-width:200px}589input,select{font-family:var(--font-body);font-size:14px;background:var(--surface);border:1.5px solid var(--ink);color:var(--ink);590  border-radius:var(--r-ctl);padding:9px 13px;outline:none;box-shadow:3px 3px 0 rgba(20,24,20,.08)}591input:focus,select:focus{outline:2px solid var(--green);outline-offset:1px;box-shadow:3px 3px 0 rgba(28,92,65,.25)}592input::placeholder{color:var(--ink-3)}593594/* Drawer réglages */595.scrim{position:fixed;inset:0;background:rgba(20,24,20,.35);backdrop-filter:blur(2px);z-index:40;opacity:0;pointer-events:none;transition:.2s}596.scrim.open{opacity:1;pointer-events:auto}597.drawer{position:fixed;top:0;right:0;height:100%;width:min(400px,92vw);background:var(--paper);border-left:2px solid var(--ink);598  z-index:41;transform:translateX(100%);transition:.25s cubic-bezier(.2,.8,.2,1);overflow-y:auto;box-shadow:-10px 0 0 rgba(20,24,20,.06)}599.drawer.open{transform:translateX(0)}600.drawer .dhead{display:flex;align-items:center;padding:18px 20px;border-bottom:2px solid var(--ink);position:sticky;top:0;background:var(--paper)}601.drawer h2{font-size:19px}.drawer .close{margin-left:auto;cursor:pointer;font-size:22px;line-height:1;background:none;border:none}602.dsec{padding:18px 20px;border-bottom:1.5px dashed var(--line)}603.dsec label{display:block;font-family:var(--font-mono);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:var(--ink-2);margin-bottom:6px}604.dsec .row{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}605.dsec input,.dsec select{width:100%}606.swatches{display:flex;gap:9px;flex-wrap:wrap}607.sw{width:34px;height:34px;border-radius:8px;border:1.5px solid var(--ink);cursor:pointer;box-shadow:2px 2px 0 rgba(20,24,20,.12);transition:.12s}608.sw:hover{transform:translate(-2px,-2px);box-shadow:4px 4px 0 var(--ink)}609.sw.sel{outline:3px solid var(--ink);outline-offset:2px}610.toggle{display:flex;align-items:center;gap:10px}611.toggle input{width:auto;box-shadow:none}612613footer{border-top:2px solid var(--ink);background:var(--ink);color:rgba(245,243,238,.75);margin-top:20px;position:relative;z-index:1}614footer .wrap{padding:26px 24px;font-family:var(--font-mono);font-size:11px;line-height:1.9;text-transform:uppercase;letter-spacing:.06em}615footer b{color:var(--lime)}616footer .lk{color:var(--lime)}617618/* Barre d'action mobile (thumb-friendly) */619.mobilebar{display:none}620.only-mobile{display:none}621@media(max-width:760px){622  .hide-mobile{display:none!important}623  .only-mobile{display:inline-flex!important}624  .mobilebar{display:flex;gap:9px;position:fixed;left:0;right:0;bottom:0;z-index:35;625    padding:10px 14px calc(10px + env(safe-area-inset-bottom));626    background:rgba(245,243,238,.94);backdrop-filter:blur(12px);border-top:2px solid var(--ink)}627  .mobilebar .btn{flex:1;min-height:46px}628  body{padding-bottom:78px}629}630@media(max-width:600px){631  .wrap{padding:0 16px}632  .brand .sub{display:none}633  .hdr{height:56px;flex-wrap:nowrap}634  .wordmark{font-size:20px}.logo{width:36px;height:36px}635  .status{padding:7px 10px}636  .ev{grid-template-columns:46px 76px 1fr;gap:8px}637  .ev .t{font-size:10px}638  .kpis{grid-template-columns:1fr 1fr;gap:12px}639  .kpi{padding:13px}.kpi .val{font-size:26px}640  th,td{padding:9px 11px}641  input,select{font-size:16px}/* anti-zoom iOS */642}643@media(max-width:430px){644  #state{display:none}/* garde le point + le minuteur, gagne de la place */645  .kpi:nth-child(n){}646}647@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition-duration:.01ms!important}}648</style></head><body>649650<header>651  <div class="wrap hdr">652    <a class="brand" href="/">653      <div class="logo">k4</div>654      <div>655        <div class="wordmark"><span class="kn">ka</span><span class="kx">4</span></div>656        <div class="sub">by <b>groupe·ka</b> · explorateur structuré du web</div>657      </div>658    </a>659    <div class="right">660      <span class="status"><span class="dot live" id="live"></span><span id="state">connexion…</span></span>661      <span class="status" id="timerChip" title="Temps restant de la session"><span id="timer">--:--</span></span>662      <button class="btn hide-mobile" id="btnPause" onclick="ctl('pause')">⏸ Pause</button>663      <button class="btn hide-mobile" id="btnResume" onclick="ctl('resume')" style="display:none">▶ Reprendre</button>664      <button class="btn btn-primary hide-mobile" id="btnContinue" onclick="ctl('extend')">▶ Continuer +15 min</button>665      <a class="btn hide-mobile" href="/explore" title="Explorer les données">▦ Données</a>666      <a class="btn btn-icon only-mobile" href="/explore" title="Explorer les données">▦</a>667      <button class="btn btn-icon" title="Réglages" onclick="openDrawer()">⚙</button>668    </div>669  </div>670</header>671<div class="ticker"><div class="run" id="ticker"></div></div>672673<main class="wrap">674  <div class="kpis" id="kpis"></div>675676  <div class="grid">677    <section class="card">678      <div class="head"><span class="kicker">Activité en direct</span><span class="count" id="feedCount"></span></div>679      <div id="feed"></div>680    </section>681682    <section class="card">683      <div class="head"><span class="kicker">Entités découvertes</span><span class="count" id="entCount"></span></div>684      <div class="filters">685        <select id="ftype" onchange="loadEntities()">686          <option value="">Tous les types</option><option value="creator">Créateurs</option>687          <option value="agency">Agences</option><option value="brand">Marques</option>688          <option value="business">Entreprises</option><option value="organization">Organisations</option>689          <option value="person">Personnes</option><option value="website">Sites web</option>690        </select>691        <input id="fq" placeholder="Rechercher…" style="flex:1;min-width:130px">692      </div>693      <div class="tblwrap"><table id="ent"></table></div>694    </section>695  </div>696697  <section class="card" style="margin-top:18px">698    <div class="head"><span class="kicker">Missions</span><span class="count" id="misCount"></span>699      <button class="btn" style="margin-left:auto" onclick="ctl('skip')" title="Arrête l'agent/le crawl en cours et passe à la suite">⏹ Arrêter la mission en cours</button>700      <button class="btn btn-lime" onclick="archiveNow()" title="Sauvegarde les données recueillies dans l'historique puis remet l'espace de travail à vide">🗄 Archiver &amp; vider</button>701    </div>702    <div class="mform">703      <input id="mgoal" placeholder="Nouvel objectif (ex. influenceurs beauté QC, PME techno Laval…)">704      <input id="mseed" placeholder="URL de départ (optionnel)" style="flex:0 0 210px">705      <button class="btn btn-primary" onclick="addMission()">+ Ajouter</button>706    </div>707    <div class="tblwrap"><table id="mis"></table></div>708  </section>709710  <section class="card" style="margin-top:18px">711    <div class="head"><span class="kicker">Archives (historique)</span><span class="count" id="arcCount"></span></div>712    <div class="tblwrap"><table id="arc"></table></div>713  </section>714</main>715716<div class="mobilebar">717  <button class="btn" id="mToggle" onclick="ctl('pause')">⏸ Pause</button>718  <button class="btn btn-primary" id="mContinue" onclick="ctl('extend')">▶ Continuer +15 min</button>719</div>720721<footer><div class="wrap">722  <b>ka·4</b> — bot IA de cartographie · propulsé par Claude Haiku 4.5 + Firecrawl + Scrapfly<br>723  Auteur Simon-Pierre Boucher · <span class="lk">contact@spboucher.ai</span> · une plateforme du <b>Groupe KA</b><br>724  Données publiques uniquement · conforme Loi 25 (Québec) / LPRPDE · robots.txt respecté · zéro boîte noire725</div></footer>726727<div class="scrim" id="scrim" onclick="closeDrawer()"></div>728<aside class="drawer" id="drawer">729  <div class="dhead"><h2>Réglages</h2><button class="close" onclick="closeDrawer()">✕</button></div>730  <div class="dsec">731    <label>Accent de marque</label>732    <div class="swatches" id="swatches"></div>733  </div>734  <div class="dsec">735    <label style="margin-bottom:12px">Paramètres du bot</label>736    <div class="row"><label>Budget quotidien (pages)</label><input id="c_daily_budget" type="number" min="0"></div>737    <div class="row"><label>Durée de session (min)</label><input id="c_session_minutes" type="number" min="1"></div>738    <div class="row"><label>Délai entre requêtes (s)</label><input id="c_delay" type="number" min="0" step="0.5"></div>739    <div class="row"><label>Pages max par cycle</label><input id="c_max_pages" type="number" min="1"></div>740    <div class="row"><label>Backend de scrape</label>741      <select id="c_backend"><option value="auto">auto (Firecrawl → Scrapfly)</option>742        <option value="firecrawl">firecrawl</option><option value="scrapfly">scrapfly</option></select></div>743    <div class="row"><label>robots.txt</label>744      <div class="toggle"><input id="c_respect_robots" type="checkbox"><span class="mono" style="font-size:12px">Respecter robots.txt</span></div></div>745    <button class="btn btn-primary" style="width:100%" onclick="saveConfig()">Enregistrer</button>746  </div>747  <div class="dsec" style="border-bottom:none">748    <label>Session</label>749    <div style="display:flex;gap:8px;flex-wrap:wrap">750      <button class="btn btn-lime" onclick="ctl('extend')">+15 min</button>751      <button class="btn" onclick="ctl('stop')">Terminer</button>752    </div>753  </div>754</aside>755756<script>757const ACCENTS=[["#e11d48","rouge · défaut"],["#ff2d55","rose"],["#9f1239","bordeaux"],["#f59e0b","ambre"],["#0ea5e9","cyan"],["#7c3aed","violet"]];758const $=id=>document.getElementById(id);759const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));760const hhmm=ts=>new Date(ts*1000).toLocaleTimeString('fr-CA',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'});761const fmt=n=>(n||0).toLocaleString('fr-CA');762const mmss=s=>{s=Math.max(0,s|0);return String((s/60)|0).padStart(2,'0')+':'+String(s%60).padStart(2,'0')};763async function j(u,o){const r=await fetch(u,o);return r.json()}764let remaining=0,lastId=0,lastEntCount=-1,feedEls=0,activeMid='';765766/* Accent personnalisable */767function applyAccent(hex){768  document.documentElement.style.setProperty('--lime',hex);769  document.documentElement.style.setProperty('--lime-soft','color-mix(in srgb,'+hex+' 22%, white)');770  localStorage.setItem('ka4-accent',hex);771  document.querySelectorAll('.sw').forEach(e=>e.classList.toggle('sel',e.dataset.hex===hex));772}773function buildSwatches(){774  $('swatches').innerHTML=ACCENTS.map(([h,t])=>`<div class="sw" data-hex="${h}" title="${t}" style="background:${h}" onclick="applyAccent('${h}')"></div>`).join('');775  applyAccent(localStorage.getItem('ka4-accent')||ACCENTS[0][0]);776}777778/* KPIs + ticker + état (depuis le flux) */779const KPIS=[['entities','Entités','var(--lime)'],['relations','Relations','#6d28d9'],780  ['types','Types','#0ea5e9'],['social_links','Liens sociaux','var(--amber)'],781  ['sources','Pages','var(--green-deep)'],['queue_pending','File','var(--ink-3)']];782function renderStats(s){783  const bt=s.by_type||{};784  const v={entities:s.entities,relations:s.relations||0,social_links:s.social_links,sources:s.sources,785    queue_pending:s.queue_pending,types:Object.keys(bt).length};786  $('kpis').innerHTML=KPIS.map(([k,lab,c])=>787    `<div class="kpi"><div class="lab">${lab}</div><div class="val">${fmt(v[k])}</div><div class="bar" style="background:${c}"></div></div>`).join('');788  activeMid=String(s.active_mission_id||'');789  remaining=s.session_remaining||0;790  const running=s.session_active&&!s.paused;791  $('state').textContent=s.paused?'En pause':(s.session_active?'Actif':'Session terminée');792  $('live').className='dot'+(running?' live':' paused');793  $('btnPause').style.display=running?'':'none';794  $('btnResume').style.display=(s.paused&&s.session_active)?'':'none';795  // barre mobile : un seul bouton bascule Pause/Reprendre (masqué si session terminée)796  const mt=$('mToggle');797  mt.style.display=s.session_active?'':'none';798  mt.textContent=running?'⏸ Pause':'▶ Reprendre';799  mt.onclick=()=>ctl(running?'pause':'resume');800  renderTimer();801  const parts=[`${fmt(v.entities)} entités`,`${fmt(v.business)} entreprises`,`${fmt(v.person)} personnes`,802    `${fmt(v.sources)} pages scrapées`,`${fmt(v.social_links)} liens sociaux`,`file: ${fmt(v.queue_pending)}`,803    running?`session ${mmss(remaining)}`:'session en veille','groupe ka · zéro boîte noire'];804  const line=parts.map(p=>`<span>◆ ${p}</span>`).join('');805  $('ticker').innerHTML=line+line;806  if(v.entities!==lastEntCount){lastEntCount=v.entities;loadEntities();}807}808function renderTimer(){809  $('timer').textContent=mmss(remaining);810  $('timerChip').style.color=remaining<=0?'var(--amber)':'';811  const lbl=remaining<=0?'▶ Continuer (terminée)':'▶ Continuer +15 min';812  $('btnContinue').textContent=lbl;$('mContinue').textContent=lbl;813}814function pushEvents(events){815  const feed=$('feed');816  events.forEach(e=>{817    if(e.id<=lastId)return; lastId=e.id;818    const div=document.createElement('div');div.className='ev';819    div.innerHTML=`<span class="t">${hhmm(e.ts)}</span><span class="badge b-${e.kind}">${e.kind}</span><span class="msg">${esc(e.message)}</span>`;820    feed.prepend(div);feedEls++;821  });822  while(feed.childNodes.length>200){feed.removeChild(feed.lastChild);}823  $('feedCount').textContent=feed.childNodes.length+' évén.';824}825826/* Entités + missions (rafraîchis quand utile) */827let entTimer=null;828async function loadEntities(){829  const t=$('ftype').value,q=$('fq').value;830  const d=await j(`/api/entities?type=${t}&q=${encodeURIComponent(q)}&limit=100`);831  $('entCount').textContent=fmt(d.count)+' au total';832  const rows=d.entities.map(e=>{833    const soc=(e.social_links||[]).map(l=>`<a class="chip" href="${esc(l.url)}" target="_blank" rel="noopener">${esc(l.platform)}</a>`).join('');834    const site=e.url?`<a class="chip" href="${esc(e.url)}" target="_blank" rel="noopener">site ↗</a>`:'';835    return `<tr><td><span class="tag ${esc(e.type)}">${esc(e.type)}</span></td>836      <td><div class="name">${esc(e.name)}</div><div class="desc">${esc((e.description||'').slice(0,110))}</div></td>837      <td>${site}${soc}</td><td class="mono" style="font-size:12px">${esc(e.location||'')}</td></tr>`;838  }).join('');839  $('ent').innerHTML=`<thead><tr><th>Type</th><th>Nom</th><th>Liens</th><th>Lieu</th></tr></thead><tbody>${rows}</tbody>`;840}841async function loadMissions(){842  const d=await j('/api/missions');843  $('misCount').textContent=d.missions.length+' mission(s)';844  const rows=d.missions.map(m=>{845    const active=String(m.id)===activeMid;846    const stat=active?'<span class="tag st-running">▶ en cours</span>':`<span class="tag st-${esc(m.status)}">${esc(m.status)}</span>`;847    const act=`<button class="btn" style="padding:5px 9px;min-height:0" title="Lancer maintenant" onclick="missionOp(${m.id},'prioritize')">▶</button>848      <button class="btn" style="padding:5px 9px;min-height:0" title="Supprimer" onclick="missionOp(${m.id},'delete')">✕</button>`;849    return `<tr><td class="mono">#${m.id}</td><td>${esc(m.goal)}</td><td>${stat}</td>850      <td class="mono">${m.runs_count||0}</td><td style="white-space:nowrap">${act}</td></tr>`;851  }).join('');852  $('mis').innerHTML=`<thead><tr><th>ID</th><th>Objectif</th><th>Statut</th><th>Exéc.</th><th>Actions</th></tr></thead><tbody>${rows}</tbody>`853    +(d.missions.length?'':'<div class="pad muted">Aucune mission. Ajoute un objectif ci-dessus (n\'importe quel sujet) pour lancer une exploration.</div>');854}855async function missionOp(id,op){856  if(op==='delete'&&!confirm('Supprimer cette mission ?'))return;857  await j('/api/missions/'+id+'/'+op,{method:'POST'});858  loadMissions();859}860async function addMission(){861  const g=$('mgoal');if(!g.value.trim())return;862  await j('/api/missions',{method:'POST',headers:{'Content-Type':'application/json'},863    body:JSON.stringify({goal:g.value.trim(),seed:$('mseed').value})});864  g.value='';$('mseed').value='';loadMissions();865}866async function ctl(a){const r=await j('/api/control/'+a,{method:'POST'});if(r.session_remaining!=null){remaining=r.session_remaining;renderTimer();}}867868/* Archives : archiver+vider, historique, restaurer/exporter/supprimer */869async function archiveNow(){870  const lab=prompt("Nom de l'archive (les données seront sauvegardées puis l'espace de travail sera vidé) :",871    new Date().toLocaleString('fr-CA'));872  if(lab===null)return;873  const r=await j('/api/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label:lab})});874  alert('Archivé : '+(r.label||'')+' — '+(r.entities||0)+' entités sauvegardées. Espace de travail vidé.');875  loadMissions();loadArchives();loadEntities();lastEntCount=-1;876}877async function loadArchives(){878  const d=await j('/api/archives');879  $('arcCount').textContent=d.archives.length+' archive(s)';880  const rows=d.archives.map(a=>`<tr><td class="mono">#${a.id}</td><td>${esc(a.label||'')}</td>881    <td class="mono">${hhmm(a.created_at)}</td><td class="mono">${fmt(a.entities)}</td><td class="mono">${fmt(a.relations)}</td>882    <td style="white-space:nowrap">883      <button class="btn" style="padding:5px 9px;min-height:0" title="Restaurer dans l'espace de travail" onclick="archiveOp(${a.id},'restore')">↺</button>884      <a class="btn" style="padding:5px 9px;min-height:0" title="Exporter (JSON)" href="/api/archive/${a.id}/export.json">↓</a>885      <button class="btn" style="padding:5px 9px;min-height:0" title="Supprimer l'archive" onclick="archiveOp(${a.id},'delete')">✕</button>886    </td></tr>`).join('');887  $('arc').innerHTML=`<thead><tr><th>ID</th><th>Nom</th><th>Créée</th><th>Entités</th><th>Relations</th><th>Actions</th></tr></thead><tbody>${rows}</tbody>`888    +(d.archives.length?'':'<div class="pad muted">Aucune archive. Clique « Archiver &amp; vider » pour sauvegarder l\'exploration en cours et repartir à zéro.</div>');889}890async function archiveOp(id,op){891  if(op==='delete'&&!confirm("Supprimer définitivement cette archive ?"))return;892  if(op==='restore'&&!confirm("Restaurer cette archive dans l'espace de travail actuel ?"))return;893  const r=await j('/api/archive/'+id+'/'+op,{method:'POST'});894  if(op==='restore')alert('Restauré : '+(r.entities||0)+' entités, '+(r.relations||0)+' relations.');895  loadArchives();loadMissions();lastEntCount=-1;896}897898/* Réglages */899function openDrawer(){$('drawer').classList.add('open');$('scrim').classList.add('open');loadConfig();}900function closeDrawer(){$('drawer').classList.remove('open');$('scrim').classList.remove('open');}901async function loadConfig(){902  const c=await j('/api/config');903  $('c_daily_budget').value=c.daily_budget;$('c_session_minutes').value=c.session_minutes;904  $('c_delay').value=c.delay;$('c_max_pages').value=c.max_pages;$('c_backend').value=c.backend;905  $('c_respect_robots').checked=String(c.respect_robots)==='1';906}907async function saveConfig(){908  const body={daily_budget:+$('c_daily_budget').value,session_minutes:+$('c_session_minutes').value,909    delay:+$('c_delay').value,max_pages:+$('c_max_pages').value,backend:$('c_backend').value,910    respect_robots:$('c_respect_robots').checked?'1':'0'};911  await j('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});912  closeDrawer();913}914915/* Recherche debouncée */916$('fq').addEventListener('input',()=>{clearTimeout(entTimer);entTimer=setTimeout(loadEntities,300);});917918/* Temps réel : SSE, repli sur polling si indisponible */919function startSSE(){920  try{921    const es=new EventSource('/api/stream');922    es.onmessage=ev=>{const d=JSON.parse(ev.data);if(d.stats)renderStats(d.stats);if(d.events)pushEvents(d.events);};923    es.onerror=()=>{es.close();startPolling();};924  }catch(e){startPolling();}925}926function startPolling(){927  async function tick(){const s=await j('/api/stats');renderStats(s);928    const d=await j('/api/events?after_id='+lastId+'&limit=60');pushEvents(d.events.reverse());}929  tick();setInterval(tick,3000);930}931buildSwatches();loadMissions();loadArchives();startSSE();932setInterval(loadMissions,20000);setInterval(loadArchives,30000);933setInterval(()=>{if(remaining>0){remaining--;renderTimer();}},1000);934</script></body></html>"""935936937938939# =========================================================================940#  Page « Répertoire structuré » v3 — 3 vues (cartes / tableau / graphe global)941#  + facettes (type, secteur, région), tri, insights, export CSV/JSON.942# =========================================================================943EXPLORE_HTML = r"""<!doctype html>944<html lang="fr"><head>945<meta charset="utf-8">946<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">947<meta name="theme-color" content="#f5f3ee">948<title>ka4 · Répertoire structuré — graphe du web québécois</title>949<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='64' height='64'%3E%3Crect width='64' height='64' rx='12' fill='%23141814'/%3E%3Ctext x='50%25' y='54%25' font-family='Arial Black,Arial' font-weight='900' font-size='30' fill='%23e11d48' text-anchor='middle' dominant-baseline='central'%3Ek4%3C/text%3E%3C/svg%3E">950<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>951<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">952<link rel="stylesheet" href="/ka4.css">953<style>954.muted{color:var(--ink-3)}955.subhead{padding:22px 0 4px}956.subhead h1{font-size:clamp(26px,5vw,42px);text-transform:uppercase;letter-spacing:-.035em;line-height:1;margin-top:10px}957.subhead h1 .hl{background:var(--lime);border-radius:8px;padding:0 10px;transform:rotate(-1deg);display:inline-block}958.insights{display:flex;gap:10px;flex-wrap:wrap;margin:16px 0 6px}959.ins{font-family:var(--font-mono);font-size:12px;padding:8px 14px;border:1.5px solid var(--ink);border-radius:999px;background:var(--surface);box-shadow:2px 2px 0 rgba(20,24,20,.1)}960.ins b{font-family:var(--font-display);font-size:15px}961.bar2{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:14px 0 18px}962.tabs{display:inline-flex;gap:4px;border:1.5px solid var(--ink);border-radius:999px;padding:4px;background:var(--surface);box-shadow:var(--shadow-off-soft)}963.tab{font-family:var(--font-display);font-weight:700;font-size:13px;padding:7px 15px;border-radius:999px;cursor:pointer;border:none;background:transparent;color:var(--ink)}964.tab.on{background:var(--ink);color:var(--lime)}965.bar2 .exp{margin-left:auto;display:flex;gap:8px}966.bar2 .cnt{font-family:var(--font-mono);font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.08em}967.layout{display:grid;grid-template-columns:1fr;gap:18px;padding-bottom:40px}968@media(min-width:980px){.layout{grid-template-columns:274px 1fr;align-items:start}}969.side{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off-soft);overflow:hidden}970@media(min-width:980px){.side{position:sticky;top:80px}}971.side .sec{padding:14px 16px;border-bottom:1.5px dashed var(--line)}972.side .sec:last-child{border-bottom:none}973.side label{font-family:var(--font-mono);font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.12em;color:var(--ink-3);display:block;margin-bottom:8px}974.side input,.side select{width:100%}975.facets{display:flex;flex-direction:column;gap:3px;max-height:190px;overflow:auto;margin:-2px}976.f{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13px}977.f:hover{background:var(--surface-2)}978.f.on{background:var(--ink);color:var(--lime)}979.f .c{margin-left:auto;font-family:var(--font-mono);font-size:11px;color:var(--ink-3)}980.f.on .c{color:var(--lime)}981.topc{display:flex;flex-direction:column;gap:4px}982.topc a{display:flex;gap:8px;font-size:12.5px;padding:4px 6px;border-radius:6px}983.topc a:hover{background:var(--surface-2);text-decoration:none}984.topc .d{margin-left:auto;font-family:var(--font-mono);font-size:11px;color:var(--green)}985.filterbtn{display:none}986/* Vue cartes */987.egrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}988.ecard{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off-soft);padding:15px;cursor:pointer;transition:.15s}989.ecard:hover{transform:translate(-3px,-3px);box-shadow:var(--shadow-off)}990.ecard .top{display:flex;align-items:center;gap:11px}991.ecard .ini{width:40px;height:40px;border-radius:9px;background:var(--ink);color:var(--lime);display:grid;place-items:center;font-family:var(--font-display);font-weight:700;font-size:17px;flex:0 0 auto;transform:rotate(-2deg)}992.ecard.person .ini{background:var(--lime);color:var(--ink)}993.ecard h3{font-family:var(--font-display);font-size:16px;letter-spacing:-.02em;line-height:1.1}994.ecard .meta{font-family:var(--font-mono);font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-top:3px}995.ecard p{font-size:12.5px;color:var(--ink-2);margin:8px 0 6px}996.ecard .foot{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px}997.rbadge{font-family:var(--font-mono);font-size:10px;font-weight:700;padding:3px 8px;border-radius:999px;background:var(--lime-soft);color:var(--green-deep);border:1.5px solid var(--green)}998/* Vue tableau */999.dtable{background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off-soft);overflow:auto}1000.dtable table{width:100%;border-collapse:collapse;font-size:13px}1001.dtable th{position:sticky;top:0;background:var(--surface-2);font-family:var(--font-mono);font-size:9.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2);border-bottom:1.5px solid var(--ink);padding:10px 12px;text-align:left;cursor:pointer;white-space:nowrap}1002.dtable th.on{color:var(--green)}1003.dtable td{border-bottom:1px solid var(--line);padding:10px 12px;vertical-align:top}1004.dtable tr{cursor:pointer}.dtable tr:hover td{background:var(--surface-2)}1005/* Vue graphe */1006.gwrap{position:relative;background:var(--surface);border:1.5px solid var(--ink);border-radius:var(--r-card);box-shadow:var(--shadow-off-soft);overflow:hidden}1007#net{width:100%;height:min(72vh,680px);display:block;touch-action:none;cursor:grab;background:1008  radial-gradient(circle at 1px 1px, rgba(20,24,20,.06) 1px, transparent 0) 0 0/22px 22px}1009#net:active{cursor:grabbing}1010.gctrl{position:absolute;top:12px;right:12px;display:flex;flex-direction:column;gap:6px}1011.gctrl button{width:38px;height:38px;font-size:18px;padding:0;display:grid;place-items:center}1012.glegend{position:absolute;left:12px;bottom:12px;display:flex;gap:12px;flex-wrap:wrap;background:rgba(255,255,255,.9);1013  border:1.5px solid var(--ink);border-radius:8px;padding:7px 10px;font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.06em}1014.glegend i{display:inline-block;width:11px;height:11px;border-radius:50%;border:1.5px solid var(--ink);vertical-align:-1px;margin-right:5px}1015.gnode circle{cursor:pointer}.gnode:hover circle{stroke-width:3}1016/* Drawer détail */1017.drawer.wide{width:min(580px,96vw)}1018.det h2{font-size:22px;letter-spacing:-.03em}1019.graph{width:100%;height:290px;border:1.5px solid var(--ink);border-radius:10px;background:var(--surface-2);box-shadow:var(--shadow-off-soft)}1020.legend{display:flex;gap:16px;flex-wrap:wrap;font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-2);margin-top:10px}1021.legend i{display:inline-block;width:18px;height:0;border-top:2px solid var(--ink);vertical-align:middle;margin-right:6px}1022.legend i.reg{border-top:2px dashed rgba(20,24,20,.4)}1023.det-row{display:flex;gap:10px;margin:7px 0;font-size:14px}1024.det-row .k{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--ink-3);flex:0 0 96px;padding-top:3px}1025.det-row .v{flex:1;word-break:break-word}1026.conn h4{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--green);margin:16px 0 8px}1027.conn .chip{cursor:pointer}1028@media(max-width:979px){1029  .filterbtn{display:inline-flex}1030  .side{display:none}.side.open{display:block}1031}1032</style></head><body>10331034<header>1035  <div class="wrap hdr">1036    <a class="brand" href="/">1037      <div class="logo">k4</div>1038      <div><div class="wordmark"><span class="kn">ka</span><span class="kx">4</span></div>1039        <div class="sub">by <b>groupe·ka</b> · répertoire structuré</div></div>1040    </a>1041    <div class="right"><a class="btn btn-primary" href="/">← Tableau de bord</a></div>1042  </div>1043</header>10441045<main class="wrap">1046  <div class="subhead">1047    <span class="kicker">Répertoire structuré</span>1048    <h1>Répertoire <span class="hl">structuré</span></h1>1049    <div class="insights" id="insights"></div>1050  </div>10511052  <div class="bar2">1053    <div class="tabs">1054      <button class="tab on" data-v="cards" onclick="setView('cards')">▦ Cartes</button>1055      <button class="tab" data-v="table" onclick="setView('table')">▤ Tableau</button>1056      <button class="tab" data-v="graph" onclick="setView('graph')">◉ Graphe</button>1057    </div>1058    <button class="btn filterbtn" onclick="document.getElementById('side').classList.toggle('open')">⚙ Filtres</button>1059    <span class="cnt" id="count"></span>1060    <div class="exp">1061      <a class="btn" id="expCsv" href="/api/export.csv">↓ CSV</a>1062      <a class="btn" id="expJson" href="/api/export.json">↓ JSON</a>1063    </div>1064  </div>10651066  <div class="layout">1067    <aside class="side" id="side">1068      <div class="sec"><label>Recherche</label><input id="fq" placeholder="Nom, secteur, description…"></div>1069      <div class="sec"><label>Trier par</label>1070        <select id="fsort" onchange="state.sort=this.value;reload()">1071          <option value="degree">Plus connectées</option>1072          <option value="name">Nom (A→Z)</option>1073          <option value="recent">Récentes</option>1074        </select></div>1075      <div class="sec"><label>Type</label><div class="facets" id="fType"></div></div>1076      <div class="sec"><label>Secteur</label><div class="facets" id="fSector"></div></div>1077      <div class="sec"><label>Région</label><div class="facets" id="fRegion"></div></div>1078      <div class="sec"><label>Plus connectées</label><div class="topc" id="topc"></div></div>1079      <div class="sec"><button class="btn" style="width:100%" onclick="resetFilters()">Réinitialiser</button></div>1080    </aside>10811082    <section>1083      <div id="v-cards" class="egrid"></div>1084      <div id="v-table" class="dtable" style="display:none"></div>1085      <div id="v-graph" class="gwrap" style="display:none">1086        <svg id="net" viewBox="0 0 900 620" preserveAspectRatio="xMidYMid meet"><g id="gwrap"></g></svg>1087        <div class="gctrl">1088          <button class="btn" onclick="zoom(1.25)">+</button>1089          <button class="btn" onclick="zoom(0.8)">−</button>1090          <button class="btn" onclick="fitGraph()" title="Recentrer">⤢</button>1091        </div>1092        <div class="glegend">1093          <span><i style="background:#141814"></i>Entreprise</span>1094          <span><i style="background:#e8a33d"></i>Organisation</span>1095          <span><i style="background:#9f1239"></i>Personne</span>1096          <span><i style="background:#8b928c"></i>Site web</span>1097        </div>1098      </div>1099      <div style="text-align:center;margin-top:20px">1100        <button class="btn" id="moreBtn" style="display:none" onclick="loadMore()">Charger plus</button>1101      </div>1102    </section>1103  </div>1104</main>11051106<footer><div class="wrap">1107  <b>ka·4</b> — répertoire structuré · toute entité, ses attributs &amp; ses relations<br>1108  Auteur Simon-Pierre Boucher · <span class="lk">contact@spboucher.ai</span> · une plateforme du <b>Groupe KA</b><br>1109  Données publiques uniquement · conforme Loi 25 (Québec) / LPRPDE1110</div></footer>11111112<div class="scrim" id="scrim" onclick="closeD()"></div>1113<aside class="drawer wide" id="drawer"><div class="det" id="det"></div></aside>11141115<script>1116const $=id=>document.getElementById(id);1117const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));1118const fmt=n=>(n||0).toLocaleString('fr-CA');1119const TYPES={creator:'Créateur',agency:'Agence',brand:'Marque',business:'Entreprise',organization:'Organisation',person:'Personne',website:'Site web'};1120const fmtFol=n=>{n=+n||0;return n>=1e6?(n/1e6).toFixed(1).replace('.0','')+'M':n>=1e3?(n/1e3).toFixed(1).replace('.0','')+'K':(n||'')};1121const REL={WORKS_AT:'travaille chez',FOUNDER_OF:'fondateur·trice de',OWNS:'propriétaire de',1122  MEMBER_OF:'membre de',PARTNER_OF:'partenaire de',SUBSIDIARY_OF:'filiale de',PARENT_OF:'société mère de',1123  AFFILIATED_WITH:'affilié·e à',SUPPLIER_OF:'fournisseur de',LOCATED_IN:'situé·e à'};1124const COL={business:'#141814',organization:'#e8a33d',person:'#9f1239',website:'#8b928c'};1125const ini=n=>{const t=(n||'?').trim();return (t[0]||'?').toUpperCase()+(t.split(' ')[1]?t.split(' ')[1][0].toUpperCase():'')};1126const _dom=u=>{try{return new URL(u).hostname.replace(/^www\./,'')}catch(e){return ''}};1127async function j(u){const r=await fetch(u);return r.json()}1128let state={view:'cards',type:'',sector:'',region:'',q:'',sort:'degree'},items=[],offset=0,total=0,tmr=null;1129const LIMIT=60;11301131function qs(){const p=new URLSearchParams();1132  if(state.type)p.set('type',state.type);if(state.sector)p.set('sector',state.sector);1133  if(state.region)p.set('region',state.region);if(state.q)p.set('q',state.q);return p.toString();}11341135/* Résumé / facettes / insights */1136async function loadSummary(){1137  const d=await j('/api/explore/summary');1138  $('insights').innerHTML=[1139    ['Entités',d.total],['Relations',d.relations],['Secteurs',(d.sectors||[]).length],['Régions',(d.regions||[]).length]1140  ].map(([k,v])=>`<span class="ins">${k} <b>${fmt(v)}</b></span>`).join('');1141  const bt=d.by_type||{};1142  facetGroup('fType','type',Object.keys(TYPES).map(k=>[k,TYPES[k],bt[k]||0]));1143  facetGroup('fSector','sector',(d.sectors||[]).slice(0,30).map(x=>[x.sector,x.sector,x.count]));1144  facetGroup('fRegion','region',(d.regions||[]).slice(0,30).map(x=>[x.location,x.location,x.count]));1145  $('topc').innerHTML=(d.top_connected||[]).map(e=>1146    `<a onclick="openEntity(${e.id})">${esc(e.name)}<span class="d">${e.degree}</span></a>`).join('')||'<span class="muted" style="font-size:12px">—</span>';1147}1148function facetGroup(elId,key,arr){1149  $(elId).innerHTML=arr.map(([val,lab,n])=>1150    `<div class="f${state[key]===val?' on':''}" onclick="setFacet('${key}',${JSON.stringify(val).replace(/"/g,'&quot;')})">1151      <span>${esc(lab)}</span><span class="c">${fmt(n)}</span></div>`).join('')||'<span class="muted" style="font-size:12px">—</span>';1152}1153function setFacet(key,val){state[key]=(state[key]===val)?'':val;loadSummary();reload();}1154function resetFilters(){state.type=state.sector=state.region=state.q='';$('fq').value='';loadSummary();reload();}11551156/* Vues */1157function setView(v){state.view=v;1158  document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('on',t.dataset.v===v));1159  $('v-cards').style.display=v==='cards'?'':'none';1160  $('v-table').style.display=v==='table'?'':'none';1161  $('v-graph').style.display=v==='graph'?'':'none';1162  $('moreBtn').style.display=(v!=='graph'&&offset<total)?'':'none';1163  if(v==='graph')loadGraph();else render();1164}1165function updateExport(){const q=qs();$('expCsv').href='/api/export.csv?'+q;$('expJson').href='/api/export.json?'+q;}11661167async function reload(){updateExport();offset=0;items=[];1168  if(state.view==='graph'){loadGraph();return;}1169  await loadPage();}1170async function loadPage(){1171  const d=await j(`/api/entities?${qs()}&sort=${state.sort}&offset=${offset}&limit=${LIMIT}`);1172  total=d.count;items=items.concat(d.entities);offset+=d.entities.length;1173  $('count').textContent=`${fmt(items.length)} / ${fmt(total)}`;1174  $('moreBtn').style.display=(state.view!=='graph'&&offset<total)?'':'none';1175  render();1176}1177function loadMore(){loadPage();}1178function render(){state.view==='table'?renderTable():renderCards();}11791180function emptyState(){1181  const q=(state.q||state.type||state.sector||state.region);1182  return `<div style="grid-column:1/-1;text-align:center;padding:60px 20px">1183    <div style="font-size:44px;line-height:1">🗺️</div>1184    <h3 style="font-family:var(--font-display);font-size:22px;margin:14px 0 6px">${q?'Aucune entité pour ce filtre':'Le répertoire est vide'}</h3>1185    <p class="muted" style="max-width:460px;margin:0 auto">${q?'Essaie d\'élargir ou de réinitialiser les filtres.':'Lance une exploration : ouvre le <b>Tableau de bord</b>, ajoute une mission (n\'importe quel sujet), puis démarre le bot. Les entités et leurs relations apparaîtront ici.'}</p>1186    <div style="margin-top:16px"><a class="btn btn-primary" href="/">Aller au tableau de bord</a></div></div>`;1187}1188function renderCards(){1189  $('v-cards').innerHTML=items.map(e=>{1190    const meta=[TYPES[e.type]||esc(e.type),e.niche?esc(e.niche):(e.sector?esc(e.sector):''),1191                e.platform?esc(e.platform):'',esc(e.region||e.location||'')].filter(Boolean).join(' · ');1192    const rb=e.relations_count?`<span class="rbadge">${e.relations_count} lien${e.relations_count>1?'s':''}</span>`:'';1193    const fb=e.followers?`<span class="rbadge" style="background:var(--lime-soft);color:var(--green-deep);border-color:var(--lime)">${fmtFol(e.followers)} abonnés</span>`:'';1194    const hb=e.handle?`<span class="chip">${esc(e.handle)}</span>`:'';1195    const site=e.domain?`<a class="chip" href="${esc(e.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${esc(e.domain)}</a>`:'';1196    return `<div class="ecard ${esc(e.type)}" onclick="openEntity(${e.id})">1197      <div class="top"><div class="ini">${esc(ini(e.name))}</div>1198        <div><h3>${esc(e.name)}</h3><div class="meta">${meta}</div></div></div>1199      ${e.description?`<p>${esc(e.description.slice(0,120))}</p>`:''}1200      <div class="foot">${fb}${rb}${hb}${site}</div></div>`;1201  }).join('')||emptyState();1202}1203function th(key,lab){return `<th class="${state.sort===key?'on':''}" onclick="state.sort='${key}';reload()">${lab}</th>`}1204function renderTable(){1205  const rows=items.map(e=>`<tr onclick="openEntity(${e.id})">1206    <td><span class="tag ${esc(e.type)}">${TYPES[e.type]||esc(e.type)}</span></td>1207    <td class="name">${esc(e.name)}</td><td>${esc(e.sector||'')}</td>1208    <td>${esc(e.region||e.location||'')}</td>1209    <td class="mono">${e.relations_count||0}</td><td class="mono">${e.social_count||0}</td>1210    <td>${e.domain?`<a href="${esc(e.url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">↗</a>`:''}</td></tr>`).join('');1211  $('v-table').innerHTML=`<table><thead><tr>1212    <th>Type</th>${th('name','Nom')}<th>Secteur</th><th>Région</th>1213    ${th('degree','Relations')}<th>Liens</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;1214}12151216/* ---------- Vue GRAPHE GLOBAL (force-directed) ---------- */1217let gt={x:0,y:0,s:1},NODES=[],POS={};1218async function loadGraph(){1219  const d=await j(`/api/graph?${qs()}&limit=140`);1220  NODES=d.nodes;const edges=d.edges;1221  $('count').textContent=`${fmt(NODES.length)} nœuds · ${fmt(edges.length)} liens`;1222  if(!NODES.length){$('gwrap').innerHTML='<text x="450" y="300" text-anchor="middle" font-family="Inter" fill="#8b928c">Aucune relation à afficher pour ce filtre — le graphe s\'enrichit au fil du crawl.</text>';return;}1223  POS=layout(NODES,edges,900,620,260);1224  const byId={};NODES.forEach(n=>byId[n.id]=n);1225  let e='',nd='';1226  edges.forEach(g=>{const a=POS[g.s],b=POS[g.t];if(!a||!b)return;1227    e+=`<line x1="${a.x.toFixed(1)}" y1="${a.y.toFixed(1)}" x2="${b.x.toFixed(1)}" y2="${b.y.toFixed(1)}" stroke="rgba(20,24,20,.22)" stroke-width="1.2"/>`;});1228  NODES.forEach(n=>{const p=POS[n.id];const r=6+Math.min(16,n.degree*1.6);const c=COL[n.type]||'#8b928c';1229    nd+=`<g class="gnode" onclick="openEntity(${n.id})"><title>${esc(n.name)} · ${TYPES[n.type]||n.type} · ${n.degree} lien(s)</title>`+1230      `<circle cx="${p.x.toFixed(1)}" cy="${p.y.toFixed(1)}" r="${r.toFixed(1)}" fill="${c}" stroke="#141814" stroke-width="1.5"/>`+1231      (n.degree>=4?`<text x="${p.x.toFixed(1)}" y="${(p.y-r-3).toFixed(1)}" font-family="'Space Grotesk',sans-serif" font-size="11" font-weight="600" fill="#141814" text-anchor="middle">${esc(n.name.slice(0,22))}</text>`:'')+1232      `</g>`;});1233  $('gwrap').innerHTML=e+nd;1234  fitGraph();1235}1236function layout(nodes,edges,W,H,iters){1237  const pos={};nodes.forEach((n,i)=>{const a=i/nodes.length*2*Math.PI;pos[n.id]={x:W/2+Math.cos(a)*W/6+(i%5),y:H/2+Math.sin(a)*H/6+(i%3)};});1238  const k=Math.sqrt((W*H)/Math.max(1,nodes.length))*0.62;1239  for(let it=0;it<iters;it++){1240    const disp={};nodes.forEach(n=>disp[n.id]={x:0,y:0});1241    for(let i=0;i<nodes.length;i++)for(let j2=i+1;j2<nodes.length;j2++){1242      const a=pos[nodes[i].id],b=pos[nodes[j2].id];let dx=a.x-b.x,dy=a.y-b.y;let dd=Math.hypot(dx,dy)||.01;1243      const f=k*k/dd,ux=dx/dd,uy=dy/dd;1244      disp[nodes[i].id].x+=ux*f;disp[nodes[i].id].y+=uy*f;disp[nodes[j2].id].x-=ux*f;disp[nodes[j2].id].y-=uy*f;}1245    edges.forEach(g=>{const a=pos[g.s],b=pos[g.t];if(!a||!b)return;let dx=a.x-b.x,dy=a.y-b.y;let dd=Math.hypot(dx,dy)||.01;1246      const f=dd*dd/k,ux=dx/dd,uy=dy/dd;disp[g.s].x-=ux*f;disp[g.s].y-=uy*f;disp[g.t].x+=ux*f;disp[g.t].y+=uy*f;});1247    // gravité vers le centre : garde les composants déconnectés groupés (évite la dérive aux bords)1248    nodes.forEach(n=>{const p=pos[n.id];disp[n.id].x+=(W/2-p.x)*0.06;disp[n.id].y+=(H/2-p.y)*0.06;});1249    const t=Math.max(2,(1-it/iters)*k*1.5);1250    nodes.forEach(n=>{const dd2=disp[n.id];let d=Math.hypot(dd2.x,dd2.y)||.01;const lim=Math.min(d,t);1251      pos[n.id].x+=dd2.x/d*lim;pos[n.id].y+=dd2.y/d*lim;});}1252  return pos;1253}1254function applyT(){$('gwrap').setAttribute('transform',`translate(${gt.x},${gt.y}) scale(${gt.s})`);}1255function zoom(f){gt.s=Math.max(.3,Math.min(4,gt.s*f));applyT();}1256function fitGraph(){1257  const ids=Object.keys(POS);if(!ids.length){gt={x:0,y:0,s:1};applyT();return;}1258  let mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9;1259  ids.forEach(i=>{const p=POS[i];mnx=Math.min(mnx,p.x);mny=Math.min(mny,p.y);mxx=Math.max(mxx,p.x);mxy=Math.max(mxy,p.y);});1260  const bw=Math.max(1,mxx-mnx),bh=Math.max(1,mxy-mny),pad=70;1261  gt.s=Math.max(.3,Math.min(2.4,Math.min((900-pad)/bw,(620-pad)/bh)));1262  gt.x=(900-(mnx+mxx)*gt.s)/2;gt.y=(620-(mny+mxy)*gt.s)/2;applyT();1263}1264(function(){const svg=$('net');let drag=false,lx,ly;1265  svg.addEventListener('mousedown',e=>{drag=true;lx=e.clientX;ly=e.clientY;});1266  window.addEventListener('mouseup',()=>drag=false);1267  window.addEventListener('mousemove',e=>{if(!drag)return;const r=svg.getBoundingClientRect();const sc=900/r.width;1268    gt.x+=(e.clientX-lx)*sc;gt.y+=(e.clientY-ly)*sc;lx=e.clientX;ly=e.clientY;applyT();});1269  svg.addEventListener('wheel',e=>{e.preventDefault();zoom(e.deltaY<0?1.12:0.9);},{passive:false});1270  let pd=0;1271  svg.addEventListener('touchstart',e=>{if(e.touches.length===1){drag=true;lx=e.touches[0].clientX;ly=e.touches[0].clientY;}1272    else if(e.touches.length===2){pd=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);}},{passive:true});1273  svg.addEventListener('touchmove',e=>{const r=svg.getBoundingClientRect();const sc=900/r.width;1274    if(e.touches.length===1&&drag){gt.x+=(e.touches[0].clientX-lx)*sc;gt.y+=(e.touches[0].clientY-ly)*sc;lx=e.touches[0].clientX;ly=e.touches[0].clientY;applyT();}1275    else if(e.touches.length===2){const nd=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);if(pd)zoom(nd/pd);pd=nd;}},{passive:true});1276  svg.addEventListener('touchend',()=>{drag=false;pd=0;});1277})();12781279/* ---------- Détail (drawer) + ego-graph ---------- */1280function chip(url,label,stop){return `<a class="chip" href="${esc(url)}" target="_blank" rel="noopener"${stop?' onclick="event.stopPropagation()"':''}>${esc(label)}</a>`}1281function egoGraph(e){1282  const rm={};(e.relations||[]).forEach(r=>{const k=r.id;if(!rm[k])rm[k]=Object.assign({kind:'rel',roles:[]},r);rm[k].roles.push(r.role||REL[r.relation]||'');});1283  const rels=Object.values(rm).map(n=>Object.assign(n,{role:n.roles.filter(Boolean).join(' · ')}));1284  const reg=(e.same_region||[]).map(n=>Object.assign({kind:'region'},n));1285  const neigh=rels.concat(reg).slice(0,12);1286  if(!neigh.length) return '<p class="muted" style="margin:10px 0">Aucune relation extraite pour l\'instant.</p>';1287  const W=560,H=300,cx=W/2,cy=H/2,R=112;let ed='',lb='',no='';1288  neigh.forEach((n,i)=>{const a=(i/neigh.length)*2*Math.PI-Math.PI/2,x=cx+R*Math.cos(a),y=cy+R*Math.sin(a);1289    const isRel=n.kind==='rel',col=isRel?'#141814':'rgba(20,24,20,.3)',dash=isRel?'':' stroke-dasharray="4 3"';1290    ed+=`<line x1="${cx}" y1="${cy}" x2="${x.toFixed(0)}" y2="${y.toFixed(0)}" stroke="${col}" stroke-width="${isRel?2:1.2}"${dash}/>`;1291    if(isRel){const lab=(n.role||REL[n.relation]||'').slice(0,20);if(lab)lb+=`<text x="${((cx+x)/2).toFixed(0)}" y="${((cy+y)/2-3).toFixed(0)}" font-family="'JetBrains Mono',monospace" font-size="8" fill="#9f1239" text-anchor="middle">${esc(lab)}</text>`;}1292    const fill=n.type==='person'?'#e11d48':'#ffffff';1293    no+=`<g class="gnode" onclick="openEntity(${n.id})"><title>${esc(n.name)}${n.role?' — '+esc(n.role):''}</title>`+1294      `<circle cx="${x.toFixed(0)}" cy="${y.toFixed(0)}" r="17" fill="${fill}" stroke="#141814" stroke-width="1.5"/>`+1295      `<text x="${x.toFixed(0)}" y="${y.toFixed(0)}" font-family="'JetBrains Mono',monospace" font-size="10" font-weight="700" fill="#141814" text-anchor="middle" dominant-baseline="central">${esc(ini(n.name))}</text></g>`;});1296  const center=`<circle cx="${cx}" cy="${cy}" r="28" fill="#141814"/><text x="${cx}" y="${cy}" font-family="'Space Grotesk',sans-serif" font-size="15" font-weight="700" fill="#e11d48" text-anchor="middle" dominant-baseline="central">${esc(ini(e.name))}</text>`;1297  return `<svg viewBox="0 0 ${W} ${H}" class="graph" preserveAspectRatio="xMidYMid meet">${ed}${lb}${center}${no}</svg>`+1298    `<div class="legend"><span><i></i>relation extraite (${rels.length})</span><span><i class="reg"></i>même région (${reg.length})</span></div>`;1299}1300async function openEntity(id){1301  const e=await j('/api/entity/'+id);if(e.error)return;1302  const rows=[];const add=(k,v)=>{if(v)rows.push(`<div class="det-row"><div class="k">${k}</div><div class="v">${v}</div></div>`)};1303  add('Type',TYPES[e.type]||esc(e.type));1304  add('Niche',e.niche?esc(e.niche):'');add('Handle',e.handle?esc(e.handle):'');1305  add('Plateforme',e.platform?esc(e.platform):'');1306  add('Abonnés',e.followers?fmtFol(e.followers)+' ('+fmt(e.followers)+')':'');1307  add('Langues',e.languages?esc(e.languages):'');1308  add('Secteur',e.sector?esc(e.sector):'');1309  add('Description',e.description?esc(e.description):'');1310  add('Site web',e.url?`<a href="${esc(e.url)}" target="_blank" rel="noopener">${esc(e.domain||e.url)} ↗</a>`:'');1311  add('Région',esc(e.region||e.location||''));add('Adresse',esc(e.address||''));1312  add('Fondée',esc(e.founded||''));add('Taille',esc(e.size||''));add('NEQ',esc(e.neq||''));1313  add('Courriel',e.email?`<a href="mailto:${esc(e.email)}">${esc(e.email)}</a>`:'');1314  add('Téléphone',e.phone?esc(e.phone):'');1315  add('Médias sociaux',(e.social_links||[]).map(l=>chip(l.url,l.platform+(l.followers?' ('+fmtFol(l.followers)+')':''))).join(' '));1316  add('Sources',(e.sources||[]).slice(0,6).map(u=>chip(u,_dom(u)||'source',1)).join(' '));1317  const rels=e.relations||[];let conn='';1318  if(rels.length){conn+=`<h4>Relations extraites — ${rels.length}</h4><div>`+rels.map(r=>{1319    const verb=r.role?esc(r.role):(REL[r.relation]||esc(r.relation));1320    return `<span class="chip" onclick="openEntity(${r.id})">${esc(r.name)} <span class="muted">· ${verb}</span></span>`;}).join(' ')+`</div>`;}1321  if((e.same_region||[]).length){conn+=`<h4>Même région — ${e.same_region.length}</h4><div>`+1322    e.same_region.map(n=>`<span class="chip" onclick="openEntity(${n.id})">${esc(n.name)} · ${TYPES[n.type]||esc(n.type)}</span>`).join(' ')+`</div>`;}1323  $('det').innerHTML=`<div class="dhead"><h2>${esc(e.name)}</h2><button class="close" onclick="closeD()">✕</button></div>1324    <div class="dsec"><span class="kicker">Carte de connexions</span><div style="margin-top:12px">${egoGraph(e)}</div></div>1325    <div class="dsec">${rows.join('')||'<p class="muted">Aucune coordonnée.</p>'}</div>1326    <div class="dsec conn" style="border-bottom:none">${conn||'<p class="muted">Aucune connexion pour l\'instant.</p>'}</div>`;1327  $('drawer').classList.add('open');$('scrim').classList.add('open');1328}1329function closeD(){$('drawer').classList.remove('open');$('scrim').classList.remove('open');}13301331$('fq').addEventListener('input',e=>{clearTimeout(tmr);state.q=e.target.value;tmr=setTimeout(reload,300);});1332document.addEventListener('keydown',e=>{if(e.key==='Escape')closeD();});1333$('fsort').value=state.sort;1334loadSummary();updateExport();reload();1335</script></body></html>"""1336