spb/ka6 Public
ka6 — bot FLAGSHIP du Groupe KA : explorateur structuré du web québécois. Crawling concurrent robuste, Claude Sonnet+Haiku, graphe de connaissances, dashboard temps réel.
Python 97.8%
Shell 2.2%
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3"""API + dashboard de suivi de ka6 (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="ka6 — 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": "ka6"}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=ka6-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=ka6-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("/ka6.css")436def ka6_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>ka6 · 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='%237c3aed' text-anchor='middle' dominant-baseline='central'%3Ek6%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:#4f46e5;--green-deep:#4338ca;471 --lime:#7c3aed;--lime-soft:#ede9fe;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">k6</div>654 <div>655 <div class="wordmark"><span class="kn">ka</span><span class="kx">6</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 & 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·6</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=[["#7c3aed","violet · défaut"],["#6d28d9","améthyste"],["#4f46e5","indigo"],["#db2777","magenta"],["#0ea5e9","cyan"],["#f59e0b","ambre"]];758const $=id=>document.getElementById(id);759const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[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('ka6-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('ka6-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 & 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>ka6 · 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='%237c3aed' text-anchor='middle' dominant-baseline='central'%3Ek6%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="/ka6.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">k6</div>1038 <div><div class="wordmark"><span class="kn">ka</span><span class="kx">6</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:#4f46e5"></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·6</b> — répertoire structuré · toute entité, ses attributs & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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:'#4f46e5',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,'"')})">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="#4f46e5" text-anchor="middle">${esc(lab)}</text>`;}1292 const fill=n.type==='person'?'#7c3aed':'#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="#7c3aed" 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