feat: initial commit
Showing 23 changed files with +3,879 and −0
added
.env.example
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | + | |
| 4 | +ANTHROPIC_API_KEY=sk-ant-... | |
| 5 | +FIRECRAWL_API_KEY=fc-... | |
| 6 | + | |
| 7 | +KA_BOT_MODEL=claude-haiku-4-5-20251001 | |
| 8 | +KA_BOT_DB=ka_bot.db | |
| 9 | + | |
| 10 | +KA_BOT_DELAY=1.5 | |
| 11 | +KA_BOT_RESPECT_ROBOTS=true | |
| 12 | +KA_BOT_MAX_PAGES=50 | |
added
.gitignore
+17 −0
@@ -0,0 +1,17 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +.env | |
| 4 | +*.db | |
| 5 | +*.db-wal | |
| 6 | +*.db-shm | |
| 7 | +*.db-journal | |
| 8 | +__pycache__/ | |
| 9 | +*.pyc | |
| 10 | +.venv/ | |
| 11 | +venv/ | |
| 12 | +logs/ | |
| 13 | +exports/ | |
| 14 | +*.log | |
| 15 | +.DS_Store | |
| 16 | +.claude/ | |
| 17 | +deploy/ngrok-*.yml | |
added
README.md
+203 −0
@@ -0,0 +1,203 @@ | ||
| 1 | +<div align="center"> | |
| 2 | + | |
| 3 | +# ka2 — explorateur structuré du web québécois | |
| 4 | +### Édition légère · une plateforme du **Groupe KA** | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | +**ka2** est un bot **scraper + recherche piloté par IA** qui cartographie le web québécois | |
| 21 | +sous forme de **graphe de connaissances** : entités (entreprises, organisations, personnes, | |
| 22 | +créateurs/influenceurs, agences, marques…), leurs **attributs** et leurs **relations typées**. | |
| 23 | + | |
| 24 | +🌐 **Dashboard en direct : https://www.ka2.bot** | |
| 25 | + | |
| 26 | +</div> | |
| 27 | + | |
| 28 | +--- | |
| 29 | + | |
| 30 | +## ✨ Aperçu | |
| 31 | + | |
| 32 | +| | | | |
| 33 | +|---|---| | |
| 34 | +|  |  | | |
| 35 | +| **Tableau de bord** temps réel (thème vert lime) | **Répertoire structuré** — graphe global interactif | | |
| 36 | + | |
| 37 | +> Les captures ci-dessus utilisent `assets/ka2_dashboard.png` et `assets/ka2_explorer.png`. | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 41 | +## 🎯 Positionnement — Édition légère | |
| 42 | + | |
| 43 | +Le Groupe KA opère trois bots jumeaux partageant le **même moteur**, à trois paliers : | |
| 44 | + | |
| 45 | +| Bot | Palier | Contrôleur | Concurrence | Pages/jour | Thème | | |
| 46 | +|-----|--------|-----------|-------------|-----------|-------| | |
| 47 | +| ka2 | léger / économique | Haiku 4.5 | 6 | 800 | 🟢 vert lime | | |
| 48 | +| ka4 | agressif | Sonnet 5 | 10 | 3000 | 🔴 rouge | | |
| 49 | +| ka6 | **flagship** | Sonnet 5 | 16 | 9000 | 🟣 violet | | |
| 50 | + | |
| 51 | +> **Ce dépôt = ka2** (Édition légère, thème vert lime). | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +## 🧠 Comment ça marche | |
| 56 | + | |
| 57 | +``` | |
| 58 | + ┌──────────────────────────────────────────────┐ | |
| 59 | + Missions ───────▶ │ Agent (Claude) — planifie, cherche, │ | |
| 60 | + (dashboard) │ approfondit (deep_dive), pilote via outils │ | |
| 61 | + └───────────────┬──────────────────────────────┘ | |
| 62 | + │ outils | |
| 63 | + search_web / map_site / scrape_page / deep_dive / crawl_queue | |
| 64 | + │ | |
| 65 | + ┌─────────────────────────▼─────────────────────────┐ | |
| 66 | + │ Firecrawl (principal) → Scrapfly (repli anti-bot) │ | |
| 67 | + │ robots.txt + rate-limit + fail-fast + coupe-circuit│ | |
| 68 | + └─────────────────────────┬─────────────────────────┘ | |
| 69 | + │ markdown | |
| 70 | + ┌────────────▼────────────┐ | |
| 71 | + │ Extracteur (Haiku) │ | |
| 72 | + │ → GRAPHE {entités, │ | |
| 73 | + │ relations typées} │ | |
| 74 | + └────────────┬────────────┘ | |
| 75 | + │ résolution d'entités + provenance | |
| 76 | + ┌────────────▼────────────┐ | |
| 77 | + │ SQLite (WAL) │ | |
| 78 | + │ entités · relations · │ | |
| 79 | + │ liens sociaux · sources │ | |
| 80 | + └────────────┬────────────┘ | |
| 81 | + │ SSE temps réel | |
| 82 | + ┌────────────▼────────────┐ | |
| 83 | + │ Dashboard + Répertoire │ | |
| 84 | + │ (FastAPI, 3 vues) │ | |
| 85 | + └──────────────────────────┘ | |
| 86 | +``` | |
| 87 | + | |
| 88 | +--- | |
| 89 | + | |
| 90 | +## 🚀 Fonctionnalités | |
| 91 | + | |
| 92 | +- 🕸️ **Graphe de connaissances** — entités normalisées + **relations typées** (WORKS_AT, FOUNDER_OF, | |
| 93 | + REPRESENTED_BY, COLLABORATES_WITH, SPONSORED_BY, MEMBER_OF, PARTNER_OF…), avec rôle et provenance. | |
| 94 | +- 🧩 **Résolution d'entités / déduplication** (nom normalisé + domaine + région) et **régions QC canoniques**. | |
| 95 | +- 👤 **Modèle créateur/influenceur** — type `creator`/`agency`/`brand`, handle, plateforme, **abonnés** | |
| 96 | + (par plateforme), niche, langues. | |
| 97 | +- ⚡ **Crawling concurrent** (6 pages en parallèle) + **frontière agressive** (auto-découverte de liens). | |
| 98 | +- 🛟 **Robustesse** — timeouts fail-fast, **retries** par URL (dead-letter), **coupe-circuit** par domaine. | |
| 99 | +- 🧭 **Agent rigoureux** (plan → découverte → collecte → vérification) + **auto-compaction** du contexte. | |
| 100 | +- 🎛️ **Contrôles de mission** — arrêter la mission en cours, **lancer maintenant** (prioriser), supprimer. | |
| 101 | +- 🗄️ **Archives** — snapshot des données puis remise à zéro ; historique **restaurable / exportable**. | |
| 102 | +- 🖥️ **Dashboard temps réel (SSE)** — KPIs, flux d'activité, réglages en direct, thème vert lime, responsive. | |
| 103 | +- 🔎 **Répertoire structuré** — 3 vues : **Cartes**, **Tableau** triable, **Graphe global** (force-directed, | |
| 104 | + pan/zoom), facettes (type/secteur/région), recherche, **export CSV/JSON**. | |
| 105 | +- 🇶🇨 **Conforme** — robots.txt respecté, rate-limiting, note **Loi 25 (Québec) / LPRPDE**, données publiques. | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## ⚙️ Configuration (`.env`) | |
| 110 | + | |
| 111 | +| Variable | Rôle | Valeur (ka2) | | |
| 112 | +|---|---|---| | |
| 113 | +| `ANTHROPIC_API_KEY` | Clé Anthropic | — | | |
| 114 | +| `FIRECRAWL_API_KEY` | Clé Firecrawl | — | | |
| 115 | +| `SCRAPFLY_API_KEY` | Clé Scrapfly (repli) | — | | |
| 116 | +| `KA_BOT_MODEL` | Modèle contrôleur | `claude-haiku-4-5-20251001` | | |
| 117 | +| `KA_BOT_EXTRACT_MODEL` | Modèle extraction | `claude-haiku-4-5-20251001` | | |
| 118 | +| `KA_BOT_DB` | Base SQLite | `ka_bot.db` | | |
| 119 | +| `KA_BOT_CONCURRENCY` | Pages en parallèle | `6` | | |
| 120 | +| `KA_BOT_DELAY` | Délai entre requêtes (s) | `0.6` | | |
| 121 | +| `KA_BOT_MAX_PAGES` | Pages max / exécution | `120` | | |
| 122 | +| `KA_BOT_FRONTIER` | Liens auto-enfilés / page | `12–16` | | |
| 123 | +| `KA_BOT_MAX_ATTEMPTS` | Tentatives / URL | `2` | | |
| 124 | +| `KA_BOT_DOMAIN_FAILS` | Seuil coupe-circuit | `4` | | |
| 125 | +| `KA_BOT_AUTOSEED` | Pré-remplir des missions | `0` (off) | | |
| 126 | + | |
| 127 | +> ⚠️ Le fichier `.env` (clés API) **n'est pas** versionné (`.gitignore`). | |
| 128 | + | |
| 129 | +--- | |
| 130 | + | |
| 131 | +## 🏗️ Installation & exécution | |
| 132 | + | |
| 133 | +```bash | |
| 134 | +python3 -m venv .venv && source .venv/bin/activate | |
| 135 | +pip install -r requirements.txt | |
| 136 | +cp .env.example .env # renseigner les clés | |
| 137 | + | |
| 138 | +# Dashboard (web) | |
| 139 | +uvicorn web.app:app --host 127.0.0.1 --port 8799 | |
| 140 | + | |
| 141 | +# Bot long terme (crawler) | |
| 142 | +python main.py run | |
| 143 | + | |
| 144 | +# Modes ponctuels | |
| 145 | +python main.py pipeline --query "influenceurs beauté Québec" | |
| 146 | +python main.py agent --goal "Cartographier les agences d'influence de Montréal" | |
| 147 | +python main.py stats | |
| 148 | +python main.py export --out exports/entites.json | |
| 149 | +``` | |
| 150 | + | |
| 151 | +## ☁️ Déploiement (cluster MacLustr — node M4M36) | |
| 152 | + | |
| 153 | +```bash | |
| 154 | +bash deploy/deploy.sh M4M36 8799 www.ka2.bot | |
| 155 | +``` | |
| 156 | + | |
| 157 | +Crée 3 services **launchd** résilients : `com.ka2.web`, `com.ka2.bot`, `com.ka2.ngrok` | |
| 158 | +(tunnel `--url=https://www.ka2.bot`, inspection ngrok sur `:4040` pour cohabiter avec les autres bots). | |
| 159 | + | |
| 160 | +--- | |
| 161 | + | |
| 162 | +## 🔌 API principale | |
| 163 | + | |
| 164 | +| Méthode | Route | Rôle | | |
| 165 | +|---|---|---| | |
| 166 | +| `GET` | `/health` | état du service | | |
| 167 | +| `GET` | `/api/stats` | compteurs + état session | | |
| 168 | +| `GET` | `/api/stream` | flux SSE (stats + événements) | | |
| 169 | +| `GET` | `/api/entities` | entités (filtres type/région/secteur, tri, pagination) | | |
| 170 | +| `GET` | `/api/entity/{id}` | fiche entité + relations | | |
| 171 | +| `GET` | `/api/graph` | nœuds + arêtes du graphe global | | |
| 172 | +| `GET` | `/api/export.{json,csv}` | export filtré | | |
| 173 | +| `POST` | `/api/missions` | ajouter une mission | | |
| 174 | +| `POST` | `/api/missions/{id}/{prioritize,delete}` | lancer maintenant / supprimer | | |
| 175 | +| `POST` | `/api/control/{pause,resume,skip,extend}` | contrôles bot | | |
| 176 | +| `POST` | `/api/archive` · `GET /api/archives` | archiver & vider · historique | | |
| 177 | +| `POST` | `/api/archive/{id}/{restore,delete}` | restaurer / supprimer archive | | |
| 178 | + | |
| 179 | +--- | |
| 180 | + | |
| 181 | +## 🗃️ Modèle de données (SQLite) | |
| 182 | + | |
| 183 | +`entities` (type, nom, canonical/norm, domaine, secteur, région, coordonnées, **niche/handle/plateforme/abonnés**, | |
| 184 | +provenance) · `relations` (from, to, type, rôle, source, confiance) · `social_links` (plateforme, url, abonnés) · | |
| 185 | +`sources` · `entity_mentions` · `crawl_queue` · `missions` · `events` · `settings` · `archives`. | |
| 186 | + | |
| 187 | +--- | |
| 188 | + | |
| 189 | +## ⚖️ Conformité & usage responsable | |
| 190 | + | |
| 191 | +Données **publiques** uniquement · `robots.txt` respecté · rate-limiting · finalités légitimes (annuaire, | |
| 192 | +veille, recherche de marché). Encadré par la **Loi 25 (Québec)** et la **LPRPDE**. Ne pas constituer de | |
| 193 | +profils de personnes privées à des fins de surveillance ou de démarchage non sollicité. | |
| 194 | + | |
| 195 | +--- | |
| 196 | + | |
| 197 | +<div align="center"> | |
| 198 | + | |
| 199 | +**ka2** · une plateforme du **Groupe KA** | |
| 200 | +Auteur **Simon-Pierre Boucher** · ✉️ **contact@spboucher.ai** | |
| 201 | +*Zéro boîte noire — tout est automatisé, rien n'est inventé.* | |
| 202 | + | |
| 203 | +</div> | |
added
assets/ka2_dashboard.png
+0 −0
Binary file not shown.
added
assets/ka2_explorer.png
+0 −0
Binary file not shown.
added
deploy/deploy.sh
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +# | |
| 5 | +# Déploie ka2 sur un node macOS du cluster (défaut : M4M36) : | |
| 6 | +# - venv + dépendances | |
| 7 | +# - 3 services launchd résilients (KeepAlive) : web, bot long terme, tunnel ngrok | |
| 8 | +# - tunnel ngrok --url=https://www.ka2.bot -> uvicorn 127.0.0.1:8799 | |
| 9 | +# | |
| 10 | +# Usage (depuis le laptop) : bash deploy/deploy.sh [NODE] [PORT] [DOMAIN] | |
| 11 | +set -euo pipefail | |
| 12 | + | |
| 13 | +NODE="${1:-M4M36}" | |
| 14 | +PORT="${2:-8799}" | |
| 15 | +DOMAIN="${3:-www.ka2.bot}" | |
| 16 | +REMOTE_DIR="~/cluster-projects/ka2" | |
| 17 | +NGROK="/opt/homebrew/bin/ngrok" | |
| 18 | + | |
| 19 | +echo "==> Déploiement de ka2 sur ${NODE} (port ${PORT}, domaine ${DOMAIN})" | |
| 20 | + | |
| 21 | +# 1) Synchro du code (sans venv/db/git) | |
| 22 | +rsync -az --delete \ | |
| 23 | + --exclude '.venv' --exclude '.git' --exclude '*.db' --exclude '*.db-*' \ | |
| 24 | + --exclude '__pycache__' --exclude 'exports' \ | |
| 25 | + ./ "${NODE}:${REMOTE_DIR}/" | |
| 26 | + | |
| 27 | +# 2) Configuration distante (venv, deps, launchd) | |
| 28 | +ssh "${NODE}" DOMAIN="${DOMAIN}" PORT="${PORT}" NGROK="${NGROK}" 'bash -s' <<'REMOTE' | |
| 29 | +set -euo pipefail | |
| 30 | +DIR="$HOME/cluster-projects/ka2" | |
| 31 | +cd "$DIR" | |
| 32 | + | |
| 33 | +echo "--> venv + dépendances" | |
| 34 | +python3 -m venv .venv | |
| 35 | +./.venv/bin/python -m pip install -q --upgrade pip | |
| 36 | +./.venv/bin/python -m pip install -q -r requirements.txt | |
| 37 | + | |
| 38 | +PY="$DIR/.venv/bin/python" | |
| 39 | +UVICORN="$DIR/.venv/bin/uvicorn" | |
| 40 | +LOGS="$DIR/logs"; mkdir -p "$LOGS" | |
| 41 | +LA="$HOME/Library/LaunchAgents"; mkdir -p "$LA" | |
| 42 | + | |
| 43 | +write_plist () { | |
| 44 | + local label="$1" ; shift | |
| 45 | + local plist="$LA/${label}.plist" | |
| 46 | + { | |
| 47 | + echo '<?xml version="1.0" encoding="UTF-8"?>' | |
| 48 | + echo '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' | |
| 49 | + echo '<plist version="1.0"><dict>' | |
| 50 | + echo " <key>Label</key><string>${label}</string>" | |
| 51 | + echo ' <key>ProgramArguments</key><array>' | |
| 52 | + for a in "$@"; do echo " <string>${a}</string>"; done | |
| 53 | + echo ' </array>' | |
| 54 | + echo " <key>WorkingDirectory</key><string>${DIR}</string>" | |
| 55 | + echo ' <key>RunAtLoad</key><true/>' | |
| 56 | + echo ' <key>KeepAlive</key><true/>' | |
| 57 | + echo " <key>StandardOutPath</key><string>${LOGS}/${label}.out.log</string>" | |
| 58 | + echo " <key>StandardErrorPath</key><string>${LOGS}/${label}.err.log</string>" | |
| 59 | + echo ' <key>EnvironmentVariables</key><dict>' | |
| 60 | + echo " <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>" | |
| 61 | + echo ' </dict>' | |
| 62 | + echo '</dict></plist>' | |
| 63 | + } > "$plist" | |
| 64 | + launchctl unload "$plist" 2>/dev/null || true | |
| 65 | + launchctl load "$plist" | |
| 66 | + echo " launchd chargé: ${label}" | |
| 67 | +} | |
| 68 | + | |
| 69 | +echo "--> services launchd" | |
| 70 | +write_plist "com.ka2.web" "$UVICORN" "web.app:app" "--host" "127.0.0.1" "--port" "${PORT}" | |
| 71 | +write_plist "com.ka2.bot" "$PY" "main.py" "run" "--daily-budget" "800" "--cycle-delay" "20" | |
| 72 | +write_plist "com.ka2.ngrok" "$NGROK" "http" "--url=https://${DOMAIN}" "${PORT}" | |
| 73 | + | |
| 74 | +echo "--> session : 15 min à partir de maintenant (bouton « Continuer » pour +15 min)" | |
| 75 | +"$PY" - <<'PYSET' | |
| 76 | +import time | |
| 77 | +from src.config import config | |
| 78 | +from src.storage import Store | |
| 79 | +s = Store(config.db_path) | |
| 80 | +s.set_setting("run_until", str(time.time() + 15 * 60)) | |
| 81 | +s.set_setting("paused", "0") | |
| 82 | +s.close() | |
| 83 | +print("run_until = maintenant + 15 min") | |
| 84 | +PYSET | |
| 85 | + | |
| 86 | +sleep 5 | |
| 87 | +echo "--> healthcheck local" | |
| 88 | +curl -s "http://127.0.0.1:${PORT}/health" || echo "(web pas encore prêt)" | |
| 89 | +echo | |
| 90 | +echo "--> tunnel ngrok" | |
| 91 | +curl -s http://127.0.0.1:4040/api/tunnels 2>/dev/null | head -c 400 || echo "(api ngrok non prête)" | |
| 92 | +echo | |
| 93 | +REMOTE | |
| 94 | + | |
| 95 | +echo | |
| 96 | +echo "==> Déploiement terminé. Dashboard : https://${DOMAIN}" | |
added
main.py
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# Author: Simon-Pierre Boucher | |
| 3 | +# Contact: contact@spboucher.ai | |
| 4 | +"""CLI de ka-bot — cartographe du web québécois (Claude Haiku 4.5 + Firecrawl). | |
| 5 | + | |
| 6 | +Exemples : | |
| 7 | + python main.py agent --goal "Cartographier les PME technos de Québec" \\ | |
| 8 | + --seed "https://www.quebecinternational.ca" --max-steps 20 | |
| 9 | + python main.py pipeline --query "agences marketing Montréal" --limit 10 | |
| 10 | + python main.py drain --max-pages 30 | |
| 11 | + python main.py stats | |
| 12 | + python main.py export --out exports/entites.json | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import argparse | |
| 18 | +import json | |
| 19 | +import os | |
| 20 | +import sys | |
| 21 | + | |
| 22 | +from src.config import config | |
| 23 | +from src.orchestrator import Orchestrator | |
| 24 | +from src.scheduler import LongRunner | |
| 25 | + | |
| 26 | + | |
| 27 | +def _print(obj) -> None: | |
| 28 | + print(json.dumps(obj, ensure_ascii=False, indent=2)) | |
| 29 | + | |
| 30 | + | |
| 31 | +def cmd_agent(args) -> None: | |
| 32 | + orch = Orchestrator(config) | |
| 33 | + try: | |
| 34 | + summary = orch.run_agent(args.goal, args.seed, args.max_steps) | |
| 35 | + print("\n=== Résumé de mission ===") | |
| 36 | + print(summary) | |
| 37 | + print("\n=== Statistiques ===") | |
| 38 | + _print(orch.store.stats()) | |
| 39 | + finally: | |
| 40 | + orch.close() | |
| 41 | + | |
| 42 | + | |
| 43 | +def cmd_pipeline(args) -> None: | |
| 44 | + orch = Orchestrator(config) | |
| 45 | + try: | |
| 46 | + _print(orch.run_pipeline(args.query, args.limit, args.per_site_pages)) | |
| 47 | + finally: | |
| 48 | + orch.close() | |
| 49 | + | |
| 50 | + | |
| 51 | +def cmd_drain(args) -> None: | |
| 52 | + orch = Orchestrator(config) | |
| 53 | + try: | |
| 54 | + _print(orch.drain_queue(args.max_pages)) | |
| 55 | + finally: | |
| 56 | + orch.close() | |
| 57 | + | |
| 58 | + | |
| 59 | +def cmd_run(args) -> None: | |
| 60 | + runner = LongRunner( | |
| 61 | + config, | |
| 62 | + cycle_delay=args.cycle_delay, | |
| 63 | + queue_batch=args.queue_batch, | |
| 64 | + daily_page_budget=args.daily_budget, | |
| 65 | + revisit_hours=args.revisit_hours, | |
| 66 | + session_minutes=args.session_minutes, | |
| 67 | + ) | |
| 68 | + print("ka2 démarré (Ctrl+C pour arrêter proprement).") | |
| 69 | + runner.run(max_cycles=args.max_cycles) | |
| 70 | + | |
| 71 | + | |
| 72 | +def cmd_missions(args) -> None: | |
| 73 | + orch = Orchestrator(config) | |
| 74 | + try: | |
| 75 | + if args.add: | |
| 76 | + mid = orch.store.add_mission(goal=args.add, seed=args.seed or "", priority=args.priority) | |
| 77 | + print(f"Mission #{mid} ajoutée." if mid else "Mission déjà existante.") | |
| 78 | + _print(orch.store.list_missions()) | |
| 79 | + finally: | |
| 80 | + orch.close() | |
| 81 | + | |
| 82 | + | |
| 83 | +def cmd_stats(_args) -> None: | |
| 84 | + orch = Orchestrator(config) | |
| 85 | + try: | |
| 86 | + _print(orch.store.stats()) | |
| 87 | + finally: | |
| 88 | + orch.close() | |
| 89 | + | |
| 90 | + | |
| 91 | +def cmd_export(args) -> None: | |
| 92 | + orch = Orchestrator(config) | |
| 93 | + try: | |
| 94 | + data = orch.store.export() | |
| 95 | + if args.out: | |
| 96 | + os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) | |
| 97 | + with open(args.out, "w", encoding="utf-8") as f: | |
| 98 | + json.dump(data, f, ensure_ascii=False, indent=2) | |
| 99 | + print(f"{len(data)} entités exportées vers {args.out}") | |
| 100 | + else: | |
| 101 | + _print(data) | |
| 102 | + finally: | |
| 103 | + orch.close() | |
| 104 | + | |
| 105 | + | |
| 106 | +def build_parser() -> argparse.ArgumentParser: | |
| 107 | + p = argparse.ArgumentParser(prog="ka-bot", description="Cartographe du web québécois.") | |
| 108 | + sub = p.add_subparsers(dest="cmd", required=True) | |
| 109 | + | |
| 110 | + a = sub.add_parser("agent", help="Mode agentique piloté par Claude Haiku") | |
| 111 | + a.add_argument("--goal", required=True, help="Objectif de mission") | |
| 112 | + a.add_argument("--seed", help="URL de départ optionnelle") | |
| 113 | + a.add_argument("--max-steps", type=int, default=20) | |
| 114 | + a.set_defaults(func=cmd_agent) | |
| 115 | + | |
| 116 | + pl = sub.add_parser("pipeline", help="Pipeline déterministe search->scrape->extract") | |
| 117 | + pl.add_argument("--query", required=True) | |
| 118 | + pl.add_argument("--limit", type=int, default=10) | |
| 119 | + pl.add_argument("--per-site-pages", type=int, default=3) | |
| 120 | + pl.set_defaults(func=cmd_pipeline) | |
| 121 | + | |
| 122 | + d = sub.add_parser("drain", help="Traiter la file d'URLs en attente") | |
| 123 | + d.add_argument("--max-pages", type=int, default=None) | |
| 124 | + d.set_defaults(func=cmd_drain) | |
| 125 | + | |
| 126 | + r = sub.add_parser("run", help="Bot LONG TERME : boucle perpétuelle de cartographie") | |
| 127 | + r.add_argument("--cycle-delay", type=float, default=20.0) | |
| 128 | + r.add_argument("--queue-batch", type=int, default=8) | |
| 129 | + r.add_argument("--daily-budget", type=int, default=800, help="Pages max par jour") | |
| 130 | + r.add_argument("--revisit-hours", type=float, default=72.0) | |
| 131 | + r.add_argument("--session-minutes", type=float, default=15.0, | |
| 132 | + help="Durée de session avant d'attendre « Continuer »") | |
| 133 | + r.add_argument("--max-cycles", type=int, default=None, help="Limite de cycles (test)") | |
| 134 | + r.set_defaults(func=cmd_run) | |
| 135 | + | |
| 136 | + m = sub.add_parser("missions", help="Lister / ajouter des missions") | |
| 137 | + m.add_argument("--add", help="Ajouter une mission (objectif)") | |
| 138 | + m.add_argument("--seed", help="URL de départ pour la mission") | |
| 139 | + m.add_argument("--priority", type=int, default=5) | |
| 140 | + m.set_defaults(func=cmd_missions) | |
| 141 | + | |
| 142 | + s = sub.add_parser("stats", help="Statistiques de la base") | |
| 143 | + s.set_defaults(func=cmd_stats) | |
| 144 | + | |
| 145 | + e = sub.add_parser("export", help="Exporter les entités en JSON") | |
| 146 | + e.add_argument("--out", help="Fichier de sortie (sinon stdout)") | |
| 147 | + e.set_defaults(func=cmd_export) | |
| 148 | + | |
| 149 | + return p | |
| 150 | + | |
| 151 | + | |
| 152 | +def main() -> int: | |
| 153 | + args = build_parser().parse_args() | |
| 154 | + try: | |
| 155 | + args.func(args) | |
| 156 | + return 0 | |
| 157 | + except RuntimeError as e: | |
| 158 | + print(f"Erreur : {e}", file=sys.stderr) | |
| 159 | + return 1 | |
| 160 | + except KeyboardInterrupt: | |
| 161 | + print("\nInterrompu.", file=sys.stderr) | |
| 162 | + return 130 | |
| 163 | + | |
| 164 | + | |
| 165 | +if __name__ == "__main__": | |
| 166 | + raise SystemExit(main()) | |
added
requirements.txt
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +anthropic>=0.40.0 | |
| 4 | +requests>=2.31.0 | |
| 5 | +python-dotenv>=1.0.0 | |
| 6 | +fastapi>=0.110.0 | |
| 7 | +uvicorn[standard]>=0.29.0 | |
added
src/__init__.py
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""ka-bot — cartographe du web québécois piloté par Claude Haiku 4.5 + Firecrawl.""" | |
| 4 | + | |
| 5 | +__version__ = "1.0.0" | |
| 6 | +__author__ = "Simon-Pierre Boucher" | |
| 7 | +__contact__ = "contact@spboucher.ai" | |
added
src/config.py
+74 −0
@@ -0,0 +1,74 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Configuration centrale de ka6 (chargée depuis .env). | |
| 4 | + | |
| 5 | +ka6 = bot FLAGSHIP du Groupe KA : ~3x plus robuste et performant que ka4 | |
| 6 | +(concurrence élevée, robustesse retries + coupe-circuit), avec un objectif initial | |
| 7 | +de découverte des INFLUENCEURS et CRÉATEURS DE CONTENU québécois. | |
| 8 | +""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import os | |
| 13 | +from dataclasses import dataclass, field | |
| 14 | + | |
| 15 | +from dotenv import load_dotenv | |
| 16 | + | |
| 17 | +load_dotenv() | |
| 18 | + | |
| 19 | + | |
| 20 | +def _env_bool(key: str, default: bool) -> bool: | |
| 21 | + return os.getenv(key, str(default)).strip().lower() in ("1", "true", "yes", "on") | |
| 22 | + | |
| 23 | + | |
| 24 | +@dataclass | |
| 25 | +class Config: | |
| 26 | + """Paramètres runtime. Toutes les clés proviennent de l'environnement.""" | |
| 27 | + | |
| 28 | + anthropic_api_key: str = field(default_factory=lambda: os.getenv("ANTHROPIC_API_KEY", "")) | |
| 29 | + firecrawl_api_key: str = field(default_factory=lambda: os.getenv("FIRECRAWL_API_KEY", "")) | |
| 30 | + scrapfly_api_key: str = field(default_factory=lambda: os.getenv("SCRAPFLY_API_KEY", "")) | |
| 31 | + | |
| 32 | + # Contrôleur agentique : modèle PLUS PUISSANT (planification/raisonnement) | |
| 33 | + model: str = field(default_factory=lambda: os.getenv("KA_BOT_MODEL", "claude-sonnet-5")) | |
| 34 | + # Extraction en masse : modèle rapide/économique | |
| 35 | + extract_model: str = field(default_factory=lambda: os.getenv("KA_BOT_EXTRACT_MODEL", "claude-haiku-4-5-20251001")) | |
| 36 | + db_path: str = field(default_factory=lambda: os.getenv("KA_BOT_DB", "ka6.db")) | |
| 37 | + # Crawling CONCURRENT — flagship : ~3x plus de parallélisme | |
| 38 | + concurrency: int = field(default_factory=lambda: int(os.getenv("KA_BOT_CONCURRENCY", "16"))) | |
| 39 | + # Expansion agressive : liens auto-ajoutés à la file par page | |
| 40 | + frontier_per_page: int = field(default_factory=lambda: int(os.getenv("KA_BOT_FRONTIER", "16"))) | |
| 41 | + # Robustesse : nb max de tentatives par URL avant abandon (dead-letter) | |
| 42 | + max_attempts: int = field(default_factory=lambda: int(os.getenv("KA_BOT_MAX_ATTEMPTS", "2"))) | |
| 43 | + # Coupe-circuit : abandon d'un domaine après N échecs consécutifs dans un run | |
| 44 | + domain_fail_threshold: int = field(default_factory=lambda: int(os.getenv("KA_BOT_DOMAIN_FAILS", "4"))) | |
| 45 | + | |
| 46 | + # Backend de scrape : auto | firecrawl | scrapfly | |
| 47 | + scraper_backend: str = field(default_factory=lambda: os.getenv("KA_BOT_SCRAPER", "auto").strip().lower()) | |
| 48 | + scrapfly_render_js: bool = field(default_factory=lambda: _env_bool("KA_BOT_SCRAPFLY_RENDER_JS", False)) | |
| 49 | + | |
| 50 | + firecrawl_base: str = "https://api.firecrawl.dev" | |
| 51 | + scrapfly_base: str = "https://api.scrapfly.io" | |
| 52 | + request_delay: float = field(default_factory=lambda: float(os.getenv("KA_BOT_DELAY", "0.35"))) | |
| 53 | + respect_robots: bool = field(default_factory=lambda: _env_bool("KA_BOT_RESPECT_ROBOTS", True)) | |
| 54 | + max_pages_per_run: int = field(default_factory=lambda: int(os.getenv("KA_BOT_MAX_PAGES", "320"))) | |
| 55 | + max_context_tokens: int = field(default_factory=lambda: int(os.getenv("KA_BOT_MAX_CONTEXT_TOKENS", "180000"))) | |
| 56 | + | |
| 57 | + user_agent: str = "ka6-bot/1.0 (+contact@spboucher.ai)" | |
| 58 | + | |
| 59 | + def validate(self) -> "Config": | |
| 60 | + missing = [] | |
| 61 | + if not self.anthropic_api_key: | |
| 62 | + missing.append("ANTHROPIC_API_KEY") | |
| 63 | + if not self.firecrawl_api_key: | |
| 64 | + missing.append("FIRECRAWL_API_KEY (requis pour search/map)") | |
| 65 | + if self.scraper_backend == "scrapfly" and not self.scrapfly_api_key: | |
| 66 | + missing.append("SCRAPFLY_API_KEY (backend=scrapfly)") | |
| 67 | + if missing: | |
| 68 | + raise RuntimeError( | |
| 69 | + "Clés manquantes: " + ", ".join(missing) + ". Copiez .env.example vers .env." | |
| 70 | + ) | |
| 71 | + return self | |
| 72 | + | |
| 73 | + | |
| 74 | +config = Config() | |
added
src/extractor.py
+133 −0
@@ -0,0 +1,133 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Extraction d'un GRAPHE DE CONNAISSANCES orienté CRÉATEURS / INFLUENCEURS (ka6). | |
| 4 | + | |
| 5 | +Objectif initial du flagship : cartographier l'écosystème des créateurs de contenu | |
| 6 | +et influenceurs québécois — leurs plateformes, handles, nombre d'abonnés, niche, | |
| 7 | +langues — et leurs relations (agence qui les représente, marques avec qui ils | |
| 8 | +collaborent, plateformes où ils publient, collectifs dont ils sont membres). | |
| 9 | + | |
| 10 | +On extrait aussi les entités classiques (entreprises, organisations, agences). | |
| 11 | +Données PUBLIQUES uniquement ; aucune donnée sensible ou privée. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +from typing import Any | |
| 17 | + | |
| 18 | +from anthropic import Anthropic | |
| 19 | + | |
| 20 | +GRAPH_TOOL = { | |
| 21 | + "name": "record_graph", | |
| 22 | + "description": "Enregistre les entités (dont créateurs/influenceurs) et leurs relations.", | |
| 23 | + "input_schema": { | |
| 24 | + "type": "object", | |
| 25 | + "properties": { | |
| 26 | + "entities": { | |
| 27 | + "type": "array", | |
| 28 | + "items": { | |
| 29 | + "type": "object", | |
| 30 | + "properties": { | |
| 31 | + "temp_id": {"type": "string", "description": "Identifiant local unique (e1, e2…) référencé par les relations"}, | |
| 32 | + "type": {"type": "string", | |
| 33 | + "enum": ["creator", "business", "organization", "agency", "brand", "person", "website"], | |
| 34 | + "description": "Utilise 'creator' pour un influenceur/créateur de contenu, 'agency' pour une agence d'influence/talent, 'brand' pour une marque."}, | |
| 35 | + "name": {"type": "string"}, | |
| 36 | + "canonical_name": {"type": "string"}, | |
| 37 | + "handle": {"type": "string", "description": "Pseudo principal (ex. @nomcreateur)"}, | |
| 38 | + "platform": {"type": "string", "description": "Plateforme principale (Instagram, TikTok, YouTube, Twitch, X, Facebook, Snapchat…)"}, | |
| 39 | + "followers": {"type": "string", "description": "Nb d'abonnés principal (ex. '125K', '1.2M', 34000)"}, | |
| 40 | + "niche": {"type": "string", "description": "Créneau/thématique (mode, beauté, gaming, cuisine, humour, lifestyle, sport, tech, voyage, famille, musique…)"}, | |
| 41 | + "languages": {"type": "array", "items": {"type": "string"}, "description": "Langues du contenu (fr, en…)"}, | |
| 42 | + "sector": {"type": "string", "description": "Secteur pour les entreprises/agences/marques"}, | |
| 43 | + "description": {"type": "string"}, | |
| 44 | + "website": {"type": "string"}, | |
| 45 | + "email": {"type": "string"}, | |
| 46 | + "phone": {"type": "string"}, | |
| 47 | + "city": {"type": "string"}, | |
| 48 | + "region": {"type": "string", "description": "Ville/région du Québec"}, | |
| 49 | + "social_links": { | |
| 50 | + "type": "array", | |
| 51 | + "items": { | |
| 52 | + "type": "object", | |
| 53 | + "properties": { | |
| 54 | + "platform": {"type": "string"}, | |
| 55 | + "url": {"type": "string"}, | |
| 56 | + "followers": {"type": "string", "description": "Abonnés sur CETTE plateforme si connu"}, | |
| 57 | + }, | |
| 58 | + "required": ["platform", "url"], | |
| 59 | + }, | |
| 60 | + }, | |
| 61 | + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, | |
| 62 | + }, | |
| 63 | + "required": ["temp_id", "type", "name"], | |
| 64 | + }, | |
| 65 | + }, | |
| 66 | + "relations": { | |
| 67 | + "type": "array", | |
| 68 | + "items": { | |
| 69 | + "type": "object", | |
| 70 | + "properties": { | |
| 71 | + "from": {"type": "string"}, | |
| 72 | + "to": {"type": "string"}, | |
| 73 | + "type": { | |
| 74 | + "type": "string", | |
| 75 | + "enum": ["REPRESENTED_BY", "COLLABORATES_WITH", "CREATES_ON", "SPONSORED_BY", | |
| 76 | + "PROMOTES", "MEMBER_OF", "MANAGES", "APPEARS_WITH", | |
| 77 | + "WORKS_AT", "FOUNDER_OF", "PARTNER_OF", "AFFILIATED_WITH"], | |
| 78 | + "description": "Ex : créateur REPRESENTED_BY agence ; créateur COLLABORATES_WITH marque ; créateur CREATES_ON plateforme ; créateur MEMBER_OF collectif.", | |
| 79 | + }, | |
| 80 | + "role": {"type": "string", "description": "Précision (ex. 'ambassadrice', 'campagne 2025', 'chaîne principale')"}, | |
| 81 | + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, | |
| 82 | + }, | |
| 83 | + "required": ["from", "to", "type"], | |
| 84 | + }, | |
| 85 | + }, | |
| 86 | + }, | |
| 87 | + "required": ["entities"], | |
| 88 | + }, | |
| 89 | +} | |
| 90 | + | |
| 91 | +SYSTEM = ( | |
| 92 | + "Tu es l'extracteur du bot FLAGSHIP ka6 du Groupe KA. Objectif initial : cartographier " | |
| 93 | + "l'écosystème des CRÉATEURS DE CONTENU et INFLUENCEURS québécois à partir de pages publiques " | |
| 94 | + "(répertoires, palmarès, rosters d'agences, articles, pages de marques).\n\n" | |
| 95 | + "Pour chaque créateur/influenceur (type 'creator') extrais : nom, handle (@), plateforme " | |
| 96 | + "principale, nombre d'abonnés, niche/thématique, langues, région, et TOUTES ses plateformes " | |
| 97 | + "dans social_links (avec abonnés par plateforme si indiqués).\n" | |
| 98 | + "Modélise les RELATIONS : REPRESENTED_BY (agence de talent/influence), COLLABORATES_WITH / " | |
| 99 | + "SPONSORED_BY / PROMOTES (marques), CREATES_ON (plateformes), MEMBER_OF (collectif/réseau), " | |
| 100 | + "MANAGES (gérant/agence -> créateur).\n" | |
| 101 | + "Extrais aussi agences ('agency'), marques ('brand'), entreprises/organisations pertinentes.\n" | |
| 102 | + "Règles : données PUBLIQUES uniquement ; priorise le Québec ; n'invente rien (laisse vide si " | |
| 103 | + "absent) ; donne un temp_id unique par entité et référence-le dans les relations ; confidence " | |
| 104 | + "réaliste. Réponds uniquement via l'outil record_graph." | |
| 105 | +) | |
| 106 | + | |
| 107 | + | |
| 108 | +class Extractor: | |
| 109 | + def __init__(self, client: Anthropic, model: str): | |
| 110 | + self.client = client | |
| 111 | + self.model = model | |
| 112 | + | |
| 113 | + def extract_graph(self, url: str, title: str, markdown: str) -> dict[str, Any]: | |
| 114 | + if not markdown or not markdown.strip(): | |
| 115 | + return {"entities": [], "relations": []} | |
| 116 | + content = markdown[:20000] | |
| 117 | + resp = self.client.messages.create( | |
| 118 | + model=self.model, | |
| 119 | + max_tokens=3500, | |
| 120 | + system=SYSTEM, | |
| 121 | + tools=[GRAPH_TOOL], | |
| 122 | + tool_choice={"type": "tool", "name": "record_graph"}, | |
| 123 | + messages=[{ | |
| 124 | + "role": "user", | |
| 125 | + "content": f"URL: {url}\nTitre: {title}\n\nContenu (markdown):\n{content}", | |
| 126 | + }], | |
| 127 | + ) | |
| 128 | + for block in resp.content: | |
| 129 | + if block.type == "tool_use" and block.name == "record_graph": | |
| 130 | + data = block.input or {} | |
| 131 | + return {"entities": data.get("entities", []) or [], | |
| 132 | + "relations": data.get("relations", []) or []} | |
| 133 | + return {"entities": [], "relations": []} | |
added
src/firecrawl_client.py
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Client REST Firecrawl : search, map, scrape. | |
| 4 | + | |
| 5 | +Le respect du robots.txt et le débit sont gérés en amont (voir src/robots.py et | |
| 6 | +src/scraper.py). Ce client reste une couche transport minimale. | |
| 7 | +""" | |
| 8 | + | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import time | |
| 12 | +from typing import Any, Optional | |
| 13 | + | |
| 14 | +import requests | |
| 15 | + | |
| 16 | +from .util import RateLimiter | |
| 17 | + | |
| 18 | + | |
| 19 | +class FirecrawlClient: | |
| 20 | + # ka4 : fail-fast pour ne pas bloquer le crawl concurrent | |
| 21 | + def __init__(self, api_key: str, base_url: str = "https://api.firecrawl.dev", delay: float = 1.5, | |
| 22 | + timeout: int = 45, retries: int = 2): | |
| 23 | + self.base = base_url.rstrip("/") | |
| 24 | + self.session = requests.Session() | |
| 25 | + self.session.headers.update( | |
| 26 | + {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} | |
| 27 | + ) | |
| 28 | + self.limiter = RateLimiter(delay) | |
| 29 | + self.timeout = timeout | |
| 30 | + self.retries = retries | |
| 31 | + | |
| 32 | + def _post(self, path: str, payload: dict[str, Any], retries: Optional[int] = None) -> dict[str, Any]: | |
| 33 | + url = f"{self.base}{path}" | |
| 34 | + last_err: Optional[Exception] = None | |
| 35 | + for attempt in range(retries or self.retries): | |
| 36 | + self.limiter.wait() | |
| 37 | + try: | |
| 38 | + r = self.session.post(url, json=payload, timeout=self.timeout) | |
| 39 | + if r.status_code == 429: | |
| 40 | + time.sleep(1.5 * (attempt + 1)) | |
| 41 | + continue | |
| 42 | + r.raise_for_status() | |
| 43 | + return r.json() | |
| 44 | + except Exception as e: # noqa: BLE001 | |
| 45 | + last_err = e | |
| 46 | + time.sleep(1.0 * (attempt + 1)) | |
| 47 | + raise RuntimeError(f"Firecrawl {path} a échoué: {last_err}") | |
| 48 | + | |
| 49 | + def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]: | |
| 50 | + data = self._post("/v1/search", {"query": query, "limit": limit}) | |
| 51 | + return data.get("data", []) or [] | |
| 52 | + | |
| 53 | + def map(self, url: str, search: Optional[str] = None, limit: int = 200) -> list[str]: | |
| 54 | + payload: dict[str, Any] = {"url": url, "limit": limit} | |
| 55 | + if search: | |
| 56 | + payload["search"] = search | |
| 57 | + data = self._post("/v1/map", payload) | |
| 58 | + links = data.get("links", []) or [] | |
| 59 | + return [l["url"] if isinstance(l, dict) else l for l in links] | |
| 60 | + | |
| 61 | + def scrape(self, url: str, formats: Optional[list[str]] = None) -> dict[str, Any]: | |
| 62 | + # demande aussi les liens -> alimente la frontière agressive | |
| 63 | + data = self._post("/v1/scrape", {"url": url, "formats": formats or ["markdown", "links"]}) | |
| 64 | + d = data.get("data", {}) or {} | |
| 65 | + return { | |
| 66 | + "markdown": d.get("markdown", "") or "", | |
| 67 | + "metadata": d.get("metadata", {}) or {}, | |
| 68 | + "links": d.get("links", []) or [], | |
| 69 | + "backend": "firecrawl", | |
| 70 | + } | |
added
src/llm_controller.py
+298 −0
@@ -0,0 +1,298 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Contrôleur agentique rigoureux : Claude Haiku 4.5 pilote la cartographie. | |
| 4 | + | |
| 5 | +Points clés : | |
| 6 | +- Méthodologie imposée (plan -> découverte -> collecte -> vérification). | |
| 7 | +- Garde anti-boucle : on rejette les actions déjà faites (mêmes URL/recherches). | |
| 8 | +- Journal de progression persistant (outil note_progress). | |
| 9 | +- AUTO-COMPACTION du contexte : quand l'historique dépasse un seuil de tokens, les | |
| 10 | + anciens échanges sont résumés par le modèle et remplacés par un condensé, ce qui | |
| 11 | + permet des missions très longues sans dépasser la fenêtre de contexte. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import json | |
| 17 | +from typing import Any, Callable, Optional | |
| 18 | + | |
| 19 | +from anthropic import Anthropic | |
| 20 | + | |
| 21 | +TOOLS = [ | |
| 22 | + { | |
| 23 | + "name": "update_plan", | |
| 24 | + "description": "Établis ou révise ton plan de mission : étapes, secteurs/régions à couvrir, " | |
| 25 | + "et prochaines actions. À appeler au début, puis quand la stratégie change.", | |
| 26 | + "input_schema": { | |
| 27 | + "type": "object", | |
| 28 | + "properties": { | |
| 29 | + "plan": {"type": "array", "items": {"type": "string"}}, | |
| 30 | + "rationale": {"type": "string"}, | |
| 31 | + }, | |
| 32 | + "required": ["plan"], | |
| 33 | + }, | |
| 34 | + }, | |
| 35 | + { | |
| 36 | + "name": "search_web", | |
| 37 | + "description": "Recherche web (Firecrawl). Retourne titres, URLs et extraits.", | |
| 38 | + "input_schema": { | |
| 39 | + "type": "object", | |
| 40 | + "properties": { | |
| 41 | + "query": {"type": "string"}, | |
| 42 | + "limit": {"type": "integer", "default": 8}, | |
| 43 | + }, | |
| 44 | + "required": ["query"], | |
| 45 | + }, | |
| 46 | + }, | |
| 47 | + { | |
| 48 | + "name": "map_site", | |
| 49 | + "description": "Liste rapidement les URLs d'un site (découverte). 'search' filtre par mot-clé.", | |
| 50 | + "input_schema": { | |
| 51 | + "type": "object", | |
| 52 | + "properties": {"url": {"type": "string"}, "search": {"type": "string"}}, | |
| 53 | + "required": ["url"], | |
| 54 | + }, | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + "name": "scrape_page", | |
| 58 | + "description": "Récupère le contenu d'une page ET en extrait/enregistre le graphe " | |
| 59 | + "(entités + relations typées avec rôles). Retourne un résumé.", | |
| 60 | + "input_schema": { | |
| 61 | + "type": "object", | |
| 62 | + "properties": {"url": {"type": "string"}}, | |
| 63 | + "required": ["url"], | |
| 64 | + }, | |
| 65 | + }, | |
| 66 | + { | |
| 67 | + "name": "deep_dive", | |
| 68 | + "description": "APPROFONDIT une organisation : mappe son site et scrape automatiquement ses " | |
| 69 | + "pages clés (à propos, équipe, direction, contact) pour extraire un profil complet ET les " | |
| 70 | + "relations (personnes ↔ organisation avec rôles). À privilégier sur toute org prometteuse " | |
| 71 | + "pour bâtir un vrai graphe plutôt que des entités isolées.", | |
| 72 | + "input_schema": { | |
| 73 | + "type": "object", | |
| 74 | + "properties": { | |
| 75 | + "url": {"type": "string", "description": "URL ou domaine de l'organisation"}, | |
| 76 | + "max_pages": {"type": "integer", "default": 5}, | |
| 77 | + }, | |
| 78 | + "required": ["url"], | |
| 79 | + }, | |
| 80 | + }, | |
| 81 | + { | |
| 82 | + "name": "crawl_queue", | |
| 83 | + "description": "Traite EN PARALLÈLE un lot d'URLs déjà en file (crawling concurrent). " | |
| 84 | + "La frontière se remplit automatiquement à chaque page ; sers-t'en pour avancer vite.", | |
| 85 | + "input_schema": { | |
| 86 | + "type": "object", | |
| 87 | + "properties": {"count": {"type": "integer", "default": 6}}, | |
| 88 | + }, | |
| 89 | + }, | |
| 90 | + { | |
| 91 | + "name": "enqueue_urls", | |
| 92 | + "description": "Ajoute des URLs prometteuses à la file d'exploration long terme.", | |
| 93 | + "input_schema": { | |
| 94 | + "type": "object", | |
| 95 | + "properties": {"urls": {"type": "array", "items": {"type": "string"}}}, | |
| 96 | + "required": ["urls"], | |
| 97 | + }, | |
| 98 | + }, | |
| 99 | + { | |
| 100 | + "name": "note_progress", | |
| 101 | + "description": "Consigne une note de progression (couverture, angles morts, pistes). " | |
| 102 | + "Sers-t'en pour rester rigoureux et éviter les redites.", | |
| 103 | + "input_schema": { | |
| 104 | + "type": "object", | |
| 105 | + "properties": {"note": {"type": "string"}}, | |
| 106 | + "required": ["note"], | |
| 107 | + }, | |
| 108 | + }, | |
| 109 | + { | |
| 110 | + "name": "finish", | |
| 111 | + "description": "Termine la mission avec un bilan (couverture atteinte, entités clés, reste à faire).", | |
| 112 | + "input_schema": { | |
| 113 | + "type": "object", | |
| 114 | + "properties": {"summary": {"type": "string"}}, | |
| 115 | + "required": ["summary"], | |
| 116 | + }, | |
| 117 | + }, | |
| 118 | +] | |
| 119 | + | |
| 120 | +SYSTEM = ( | |
| 121 | + "Tu es ka6, l'agent FLAGSHIP du Groupe KA. Objectif initial : cartographier à grande échelle " | |
| 122 | + "l'écosystème des CRÉATEURS DE CONTENU et INFLUENCEURS québécois (et l'industrie autour : agences " | |
| 123 | + "de talents/influence, marques, plateformes, collectifs).\n\n" | |
| 124 | + "Pour chaque créateur : nom, handle, plateformes + nombre d'abonnés, niche, langues, région, et " | |
| 125 | + "les RELATIONS (représenté par une agence, collabore avec / sponsorisé par des marques, membre d'un " | |
| 126 | + "collectif, publie sur telles plateformes).\n\n" | |
| 127 | + "MÉTHODE (agressive, flagship) :\n" | |
| 128 | + "1. PLANIFIE : update_plan (niches × plateformes, agences, palmarès, marques).\n" | |
| 129 | + "2. DÉCOUVRE en volume : search_web (ex. « top influenceurs québécois mode TikTok », " | |
| 130 | + "« agences d'influence Montréal », « créateurs YouTube Québec gaming », palmarès, répertoires).\n" | |
| 131 | + "3. APPROFONDIS : deep_dive sur les annuaires, rosters d'agences et pages de créateurs (scrape " | |
| 132 | + "parallèle) — c'est là que se trouvent handles, abonnés et relations. Outil PRINCIPAL.\n" | |
| 133 | + "4. AVANCE VITE : crawl_queue traite en parallèle la frontière qui se remplit automatiquement.\n" | |
| 134 | + "5. COMPLÈTE : scrape_page pour une page précise; enqueue_urls pour de nouvelles pistes.\n" | |
| 135 | + "6. VÉRIFIE : note_progress (couverture par niche/plateforme, qualité des relations).\n" | |
| 136 | + "7. finish quand la limite est atteinte.\n\n" | |
| 137 | + "RÈGLES : données PUBLIQUES uniquement; priorise le Québec et les LIENS créateur↔agence↔marque; " | |
| 138 | + "ne répète pas une action; une action à la fois; raisonne brièvement. robots.txt/débit gérés par les outils." | |
| 139 | +) | |
| 140 | + | |
| 141 | +ToolFn = Callable[[dict[str, Any]], dict[str, Any]] | |
| 142 | +EventFn = Callable[[str, str], None] | |
| 143 | + | |
| 144 | + | |
| 145 | +class Controller: | |
| 146 | + def __init__( | |
| 147 | + self, | |
| 148 | + client: Anthropic, | |
| 149 | + model: str, | |
| 150 | + tool_impls: dict[str, ToolFn], | |
| 151 | + on_event: Optional[EventFn] = None, | |
| 152 | + max_context_tokens: int = 120_000, | |
| 153 | + keep_last_messages: int = 6, | |
| 154 | + ): | |
| 155 | + self.client = client | |
| 156 | + self.model = model | |
| 157 | + self.tools = tool_impls | |
| 158 | + self.on_event = on_event or (lambda kind, msg: None) | |
| 159 | + self.max_context_tokens = max_context_tokens | |
| 160 | + self.keep_last_messages = keep_last_messages | |
| 161 | + # garde anti-boucle | |
| 162 | + self._done_actions: set[str] = set() | |
| 163 | + | |
| 164 | + # -- boucle principale ------------------------------------------------- | |
| 165 | + def run(self, goal: str, seed: str | None = None, max_steps: int = 40, | |
| 166 | + should_abort: Optional[Callable[[], bool]] = None) -> str: | |
| 167 | + user = f"Objectif de mission : {goal}" | |
| 168 | + if seed: | |
| 169 | + user += f"\nPoint de départ suggéré : {seed}" | |
| 170 | + messages: list[dict[str, Any]] = [{"role": "user", "content": user}] | |
| 171 | + | |
| 172 | + for step in range(max_steps): | |
| 173 | + if should_abort and should_abort(): | |
| 174 | + self.on_event("agent", "Mission interrompue par l'utilisateur.") | |
| 175 | + return "Interrompu par l'utilisateur." | |
| 176 | + messages = self._maybe_compact(messages) | |
| 177 | + resp = self.client.messages.create( | |
| 178 | + model=self.model, | |
| 179 | + max_tokens=1600, | |
| 180 | + system=SYSTEM, | |
| 181 | + tools=TOOLS, | |
| 182 | + messages=messages, | |
| 183 | + ) | |
| 184 | + messages.append({"role": "assistant", "content": resp.content}) | |
| 185 | + | |
| 186 | + tool_uses = [b for b in resp.content if b.type == "tool_use"] | |
| 187 | + for b in resp.content: | |
| 188 | + if b.type == "text" and b.text.strip(): | |
| 189 | + self.on_event("agent", b.text.strip()[:500]) | |
| 190 | + | |
| 191 | + if not tool_uses: | |
| 192 | + return self._final(resp) | |
| 193 | + | |
| 194 | + results = [] | |
| 195 | + for tu in tool_uses: | |
| 196 | + if tu.name == "finish": | |
| 197 | + summary = tu.input.get("summary", "Mission terminée.") | |
| 198 | + self.on_event("agent", f"FIN : {summary[:300]}") | |
| 199 | + return summary | |
| 200 | + | |
| 201 | + out = self._dispatch(tu.name, tu.input) | |
| 202 | + results.append( | |
| 203 | + {"type": "tool_result", "tool_use_id": tu.id, "content": _stringify(out)} | |
| 204 | + ) | |
| 205 | + messages.append({"role": "user", "content": results}) | |
| 206 | + | |
| 207 | + return "Budget d'étapes épuisé (mission non conclue explicitement)." | |
| 208 | + | |
| 209 | + # -- exécution d'un outil (avec garde anti-boucle) -------------------- | |
| 210 | + def _dispatch(self, name: str, inp: dict[str, Any]) -> Any: | |
| 211 | + key = name + ":" + json.dumps(inp, ensure_ascii=False, sort_keys=True) | |
| 212 | + if name in ("search_web", "map_site", "scrape_page", "deep_dive") and key in self._done_actions: | |
| 213 | + return {"skipped": "action déjà effectuée — change d'angle ou d'URL."} | |
| 214 | + self._done_actions.add(key) | |
| 215 | + | |
| 216 | + self.on_event("agent", f"→ {name}({_short(inp)})") | |
| 217 | + fn = self.tools.get(name) | |
| 218 | + if not fn: | |
| 219 | + return {"error": f"outil inconnu: {name}"} | |
| 220 | + try: | |
| 221 | + return fn(inp) | |
| 222 | + except Exception as e: # noqa: BLE001 | |
| 223 | + self.on_event("error", f"{name}: {e}") | |
| 224 | + return {"error": str(e)} | |
| 225 | + | |
| 226 | + # -- auto-compaction --------------------------------------------------- | |
| 227 | + def _maybe_compact(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| 228 | + if self._estimate_tokens(messages) < self.max_context_tokens: | |
| 229 | + return messages | |
| 230 | + if len(messages) <= self.keep_last_messages + 2: | |
| 231 | + return messages | |
| 232 | + | |
| 233 | + cut = self._safe_cut(messages) | |
| 234 | + if cut <= 1: | |
| 235 | + return messages | |
| 236 | + | |
| 237 | + head, tail = messages[:cut], messages[cut:] | |
| 238 | + summary = self._summarize(head) | |
| 239 | + self.on_event("info", f"Contexte compacté : {len(head)} messages résumés.") | |
| 240 | + return [{"role": "user", "content": f"[MÉMOIRE COMPACTÉE — progrès à ce jour]\n{summary}"}] + tail | |
| 241 | + | |
| 242 | + def _safe_cut(self, messages: list[dict[str, Any]]) -> int: | |
| 243 | + """Coupe à une frontière valide : juste après un message user (tool_result), | |
| 244 | + pour que le premier message conservé soit un assistant et que toute paire | |
| 245 | + tool_use/tool_result reste intacte.""" | |
| 246 | + target = len(messages) - self.keep_last_messages | |
| 247 | + cut = 0 | |
| 248 | + for i in range(min(target, len(messages) - 1)): | |
| 249 | + if messages[i]["role"] == "user": | |
| 250 | + cut = i + 1 | |
| 251 | + return cut | |
| 252 | + | |
| 253 | + def _summarize(self, head: list[dict[str, Any]]) -> str: | |
| 254 | + transcript = _stringify(head, limit=40000) | |
| 255 | + try: | |
| 256 | + resp = self.client.messages.create( | |
| 257 | + model=self.model, | |
| 258 | + max_tokens=1200, | |
| 259 | + system=( | |
| 260 | + "Résume l'avancement d'une mission de cartographie web pour poursuivre sans perdre " | |
| 261 | + "le contexte. Conserve : plan courant, secteurs/régions couverts, URLs et domaines " | |
| 262 | + "déjà traités, entités clés trouvées, pistes en attente, angles morts. Sois dense et factuel." | |
| 263 | + ), | |
| 264 | + messages=[{"role": "user", "content": f"Historique à résumer :\n{transcript}"}], | |
| 265 | + ) | |
| 266 | + return "".join(b.text for b in resp.content if b.type == "text").strip() or "(résumé vide)" | |
| 267 | + except Exception as e: # noqa: BLE001 | |
| 268 | + return f"(échec du résumé: {e})" | |
| 269 | + | |
| 270 | + def _estimate_tokens(self, messages: list[dict[str, Any]]) -> int: | |
| 271 | + return len(_stringify(messages, limit=10**9)) // 3 | |
| 272 | + | |
| 273 | + def _final(self, resp) -> str: | |
| 274 | + return "".join(b.text for b in resp.content if b.type == "text").strip() or "(fin sans appel d'outil)" | |
| 275 | + | |
| 276 | + | |
| 277 | +def _short(d: dict[str, Any]) -> str: | |
| 278 | + s = json.dumps(d, ensure_ascii=False) | |
| 279 | + return s if len(s) <= 120 else s[:117] + "..." | |
| 280 | + | |
| 281 | + | |
| 282 | +def _stringify(obj: Any, limit: int = 6000) -> str: | |
| 283 | + try: | |
| 284 | + s = json.dumps(obj, ensure_ascii=False, default=_json_default) | |
| 285 | + except Exception: | |
| 286 | + s = str(obj) | |
| 287 | + return s[:limit] | |
| 288 | + | |
| 289 | + | |
| 290 | +def _json_default(o: Any) -> Any: | |
| 291 | + # Les blocs de contenu Anthropic ne sont pas JSON-sérialisables nativement. | |
| 292 | + for attr in ("model_dump", "dict", "to_dict"): | |
| 293 | + if hasattr(o, attr): | |
| 294 | + try: | |
| 295 | + return getattr(o, attr)() | |
| 296 | + except Exception: | |
| 297 | + pass | |
| 298 | + return str(o) | |
added
src/normalize.py
+82 −0
@@ -0,0 +1,82 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Normalisation pour la résolution d'entités et le regroupement géographique. | |
| 4 | + | |
| 5 | +- normalize_name : clé canonique d'un nom (accents, ponctuation, suffixes légaux retirés). | |
| 6 | +- canonical_location : regroupe les variantes ("Montreal"/"Montréal"/"Montréal, QC") en un | |
| 7 | + libellé unique, et rattache les grandes villes du Québec à un libellé stable. | |
| 8 | +- domain : domaine racine d'une URL (pour relier des entités du même site). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import re | |
| 14 | +import unicodedata | |
| 15 | +from urllib.parse import urlparse | |
| 16 | + | |
| 17 | +_LEGAL = { | |
| 18 | + "inc", "ltee", "ltd", "limited", "limitee", "enr", "senc", "sencrl", "srl", | |
| 19 | + "corp", "corporation", "cie", "co", "llc", "llp", "sec", "sa", "sas", | |
| 20 | +} | |
| 21 | + | |
| 22 | + | |
| 23 | +def strip_accents(s: str) -> str: | |
| 24 | + return "".join(c for c in unicodedata.normalize("NFD", s or "") if unicodedata.category(c) != "Mn") | |
| 25 | + | |
| 26 | + | |
| 27 | +def normalize_name(name: str) -> str: | |
| 28 | + """Clé de résolution : minuscules, sans accents/ponctuation ni suffixe légal.""" | |
| 29 | + s = strip_accents((name or "").lower()) | |
| 30 | + s = re.sub(r"[^a-z0-9 ]+", " ", s) | |
| 31 | + toks = [t for t in s.split() if t and t not in _LEGAL] | |
| 32 | + return " ".join(toks).strip() | |
| 33 | + | |
| 34 | + | |
| 35 | +def domain(url: str | None) -> str: | |
| 36 | + if not url: | |
| 37 | + return "" | |
| 38 | + u = url.strip() | |
| 39 | + if "://" not in u: | |
| 40 | + u = "http://" + u | |
| 41 | + try: | |
| 42 | + net = urlparse(u).netloc.lower() | |
| 43 | + except Exception: | |
| 44 | + return "" | |
| 45 | + if net.startswith("www."): | |
| 46 | + net = net[4:] | |
| 47 | + return net | |
| 48 | + | |
| 49 | + | |
| 50 | +# Grandes villes / variantes fréquentes -> libellé canonique | |
| 51 | +_CITY_MAP = { | |
| 52 | + "montreal": "Montréal", "mtl": "Montréal", "ville-marie": "Montréal", | |
| 53 | + "quebec": "Québec", "quebec city": "Québec", "ville de quebec": "Québec", | |
| 54 | + "laval": "Laval", "gatineau": "Gatineau", "hull": "Gatineau", | |
| 55 | + "sherbrooke": "Sherbrooke", "trois rivieres": "Trois-Rivières", | |
| 56 | + "saguenay": "Saguenay", "chicoutimi": "Saguenay", "jonquiere": "Saguenay", | |
| 57 | + "levis": "Lévis", "longueuil": "Longueuil", "terrebonne": "Terrebonne", | |
| 58 | + "brossard": "Brossard", "repentigny": "Repentigny", "drummondville": "Drummondville", | |
| 59 | + "saint jean sur richelieu": "Saint-Jean-sur-Richelieu", "granby": "Granby", | |
| 60 | + "blainville": "Blainville", "saint jerome": "Saint-Jérôme", "mirabel": "Mirabel", | |
| 61 | + "rimouski": "Rimouski", "victoriaville": "Victoriaville", "shawinigan": "Shawinigan", | |
| 62 | + "rouyn noranda": "Rouyn-Noranda", "sept iles": "Sept-Îles", "val d or": "Val-d'Or", | |
| 63 | + "boucherville": "Boucherville", "mascouche": "Mascouche", "salaberry": "Salaberry-de-Valleyfield", | |
| 64 | + "chateauguay": "Châteauguay", "saint hyacinthe": "Saint-Hyacinthe", "sorel": "Sorel-Tracy", | |
| 65 | + "joliette": "Joliette", "magog": "Magog", "alma": "Alma", "thetford": "Thetford Mines", | |
| 66 | +} | |
| 67 | + | |
| 68 | + | |
| 69 | +def canonical_location(text: str | None) -> str: | |
| 70 | + """Retourne un libellé de lieu canonique (ville QC connue, sinon 1er segment nettoyé).""" | |
| 71 | + if not text: | |
| 72 | + return "" | |
| 73 | + base = strip_accents(text.lower()) | |
| 74 | + base = re.sub(r"[^a-z0-9 ]+", " ", base) | |
| 75 | + base = re.sub(r"\s+", " ", base).strip() | |
| 76 | + for kw, label in _CITY_MAP.items(): | |
| 77 | + if re.search(rf"\b{re.escape(kw)}\b", base): | |
| 78 | + return label | |
| 79 | + # sinon : 1er segment avant virgule, sans mentions province/pays | |
| 80 | + seg = text.split(",")[0].strip() | |
| 81 | + seg = re.sub(r"(?i)\b(qc|québec|quebec|canada)\b", "", seg).strip(" ,-") | |
| 82 | + return seg[:60] | |
added
src/orchestrator.py
+273 −0
@@ -0,0 +1,273 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Orchestrateur ka4 — version plus large / agressive / capable. | |
| 4 | + | |
| 5 | +Nouveautés vs ka2 : | |
| 6 | +- Crawling CONCURRENT : fetch (Firecrawl/Scrapfly) + extraction (Haiku) en parallèle | |
| 7 | + via un pool de threads ; les écritures SQLite restent sérialisées sur le thread | |
| 8 | + principal (une seule connexion) pour rester sûres. | |
| 9 | +- Frontière AGRESSIVE : chaque page auto-alimente la file avec les liens découverts | |
| 10 | + (nouveaux domaines priorisés) pour élargir la couverture en continu. | |
| 11 | +- Contrôleur agentique sur un modèle PLUS PUISSANT (Sonnet), extraction sur Haiku. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 17 | +from typing import Any | |
| 18 | +from urllib.parse import urlparse | |
| 19 | + | |
| 20 | +from anthropic import Anthropic | |
| 21 | + | |
| 22 | +from .config import Config | |
| 23 | +from .extractor import Extractor | |
| 24 | +from .llm_controller import Controller | |
| 25 | +from .scraper import Scraper | |
| 26 | +from .storage import Store | |
| 27 | + | |
| 28 | +_SKIP_HOSTS = ("facebook.", "instagram.", "twitter.", "x.com", "linkedin.", "youtube.", | |
| 29 | + "tiktok.", "pinterest.", "google.", "maps.", "apple.", "amazon.") | |
| 30 | +_SKIP_EXT = (".jpg", ".jpeg", ".png", ".pdf", ".zip", ".mp4", ".svg", ".gif", ".webp", | |
| 31 | + ".css", ".js", ".xml", ".ico", ".woff", ".woff2") | |
| 32 | + | |
| 33 | + | |
| 34 | +class Orchestrator: | |
| 35 | + def __init__(self, cfg: Config): | |
| 36 | + cfg.validate() | |
| 37 | + self.cfg = cfg | |
| 38 | + self.anthropic = Anthropic(api_key=cfg.anthropic_api_key) | |
| 39 | + self.fc = Scraper(cfg) | |
| 40 | + self.store = Store(cfg.db_path) | |
| 41 | + # extraction sur le modèle rapide ; contrôleur sur cfg.model (plus puissant) | |
| 42 | + self.extractor = Extractor(self.anthropic, cfg.extract_model) | |
| 43 | + self._pages_scraped = 0 | |
| 44 | + self._domain_fails: dict[str, int] = {} # coupe-circuit par domaine (robustesse) | |
| 45 | + | |
| 46 | + # -- fetch pur (THREAD-SAFE : réseau + LLM, aucun accès DB) ------------- | |
| 47 | + def _fetch_only(self, url: str) -> dict[str, Any]: | |
| 48 | + data = self.fc.scrape(url) | |
| 49 | + if data.get("blocked"): | |
| 50 | + return {"url": url, "blocked": True} | |
| 51 | + if data.get("error"): | |
| 52 | + return {"url": url, "error": data["error"]} | |
| 53 | + md = data.get("markdown", "") or "" | |
| 54 | + meta = data.get("metadata", {}) or {} | |
| 55 | + title = meta.get("title", "") or "" | |
| 56 | + graph = (self.extractor.extract_graph(url, title, md) | |
| 57 | + if md.strip() else {"entities": [], "relations": []}) | |
| 58 | + return {"url": url, "title": title, "backend": data.get("backend"), | |
| 59 | + "markdown": md, "links": data.get("links", []) or [], "graph": graph} | |
| 60 | + | |
| 61 | + # -- persistance (THREAD PRINCIPAL uniquement) ------------------------- | |
| 62 | + def _save_result(self, r: dict[str, Any]) -> dict[str, Any]: | |
| 63 | + url = r["url"] | |
| 64 | + if r.get("blocked"): | |
| 65 | + self.store.log_event("info", "robots.txt interdit", url) | |
| 66 | + return {"entities_found": 0, "relations_found": 0, "blocked": True} | |
| 67 | + if r.get("error"): | |
| 68 | + self._domain_fails[urlparse(url).netloc.lower()] = \ | |
| 69 | + self._domain_fails.get(urlparse(url).netloc.lower(), 0) + 1 | |
| 70 | + self.store.log_event("error", r["error"], url) | |
| 71 | + return {"entities_found": 0, "relations_found": 0, "error": r["error"]} | |
| 72 | + | |
| 73 | + self.store.record_source(url, r.get("title", ""), r.get("markdown", "")) | |
| 74 | + self.store.log_event("scrape", f"{r.get('title') or url} [{r.get('backend')}]", url, | |
| 75 | + {"chars": len(r.get("markdown", ""))}) | |
| 76 | + graph = r.get("graph", {}) or {} | |
| 77 | + # robustesse : le LLM peut occasionnellement renvoyer un item non-dict -> on filtre | |
| 78 | + entities = [e for e in (graph.get("entities") or []) if isinstance(e, dict)] | |
| 79 | + relations = [x for x in (graph.get("relations") or []) if isinstance(x, dict)] | |
| 80 | + idmap: dict[str, int] = {} | |
| 81 | + for e in entities: | |
| 82 | + eid = self.store.upsert_entity(e, source_url=url) | |
| 83 | + if e.get("temp_id"): | |
| 84 | + idmap[e["temp_id"]] = eid | |
| 85 | + rel = 0 | |
| 86 | + for rr in relations: | |
| 87 | + fid, tid = idmap.get(rr.get("from")), idmap.get(rr.get("to")) | |
| 88 | + if fid and tid: | |
| 89 | + self.store.add_relation(fid, tid, rr.get("type", ""), rr.get("role", ""), | |
| 90 | + source_url=url, confidence=float(rr.get("confidence", 0.6) or 0.6)) | |
| 91 | + rel += 1 | |
| 92 | + self._enqueue_frontier(url, r.get("links", [])) | |
| 93 | + self.store.log_event("extract", f"{len(idmap)} entité(s), {rel} relation(s)", url, | |
| 94 | + {"relations": rel}) | |
| 95 | + return {"entities_found": len(idmap), "relations_found": rel, | |
| 96 | + "links_on_page": (r.get("links", []) or [])[:25]} | |
| 97 | + | |
| 98 | + def _enqueue_frontier(self, url: str, links: list) -> None: | |
| 99 | + if self.cfg.frontier_per_page <= 0 or not links: | |
| 100 | + return | |
| 101 | + base = urlparse(url).netloc.lower() | |
| 102 | + picked = [] | |
| 103 | + for l in links: | |
| 104 | + u = l["url"] if isinstance(l, dict) else l | |
| 105 | + if not isinstance(u, str) or not u.startswith("http"): | |
| 106 | + continue | |
| 107 | + h = urlparse(u).netloc.lower() | |
| 108 | + if not h or any(s in h for s in _SKIP_HOSTS) or u.lower().endswith(_SKIP_EXT): | |
| 109 | + continue | |
| 110 | + picked.append(u) | |
| 111 | + # priorise les NOUVEAUX domaines (élargit la couverture) | |
| 112 | + picked.sort(key=lambda u: urlparse(u).netloc.lower() == base) | |
| 113 | + self.store.enqueue(picked[: self.cfg.frontier_per_page]) | |
| 114 | + | |
| 115 | + # -- crawling CONCURRENT ---------------------------------------------- | |
| 116 | + def _tripped(self, url: str) -> bool: | |
| 117 | + """Coupe-circuit : True si le domaine a trop échoué dans ce run.""" | |
| 118 | + return self._domain_fails.get(urlparse(url).netloc.lower(), 0) >= self.cfg.domain_fail_threshold | |
| 119 | + | |
| 120 | + def crawl_batch(self, urls: list[str]) -> dict[str, Any]: | |
| 121 | + seen, todo = set(), [] | |
| 122 | + for u in urls: | |
| 123 | + if not u or u in seen: | |
| 124 | + continue | |
| 125 | + seen.add(u) | |
| 126 | + if self._tripped(u): | |
| 127 | + continue # domaine coupé (trop d'échecs) | |
| 128 | + if not self.store.source_seen(u): | |
| 129 | + todo.append(u) | |
| 130 | + remaining = max(0, self.cfg.max_pages_per_run - self._pages_scraped) | |
| 131 | + todo = todo[:remaining] | |
| 132 | + if not todo: | |
| 133 | + return {"pages": 0, "entities_found": 0, "relations_found": 0} | |
| 134 | + results = [] | |
| 135 | + with ThreadPoolExecutor(max_workers=max(1, self.cfg.concurrency)) as ex: | |
| 136 | + futs = [ex.submit(self._fetch_only, u) for u in todo] | |
| 137 | + for f in as_completed(futs): | |
| 138 | + try: | |
| 139 | + results.append(f.result()) | |
| 140 | + except Exception as e: # noqa: BLE001 | |
| 141 | + self.store.log_event("error", f"fetch: {e}", "") | |
| 142 | + tot_e = tot_r = pages = 0 | |
| 143 | + for r in results: | |
| 144 | + out = self._save_result(r) | |
| 145 | + if not (r.get("blocked") or r.get("error")): | |
| 146 | + self._pages_scraped += 1 | |
| 147 | + pages += 1 | |
| 148 | + tot_e += out.get("entities_found", 0) | |
| 149 | + tot_r += out.get("relations_found", 0) | |
| 150 | + return {"pages": pages, "entities_found": tot_e, "relations_found": tot_r} | |
| 151 | + | |
| 152 | + # -- scrape unitaire (utilisé par l'agent) ---------------------------- | |
| 153 | + def _scrape_and_extract(self, url: str) -> dict[str, Any]: | |
| 154 | + if self._pages_scraped >= self.cfg.max_pages_per_run: | |
| 155 | + return {"stopped": "limite de pages atteinte", "entities_found": 0} | |
| 156 | + if self.store.source_seen(url): | |
| 157 | + return {"skipped": "déjà scrapé", "entities_found": 0} | |
| 158 | + r = self._fetch_only(url) | |
| 159 | + out = self._save_result(r) | |
| 160 | + if not (r.get("blocked") or r.get("error")): | |
| 161 | + self._pages_scraped += 1 | |
| 162 | + return {**out, "url": url, "title": r.get("title", ""), "backend": r.get("backend")} | |
| 163 | + | |
| 164 | + # -- crawling centré-entité (concurrent) ------------------------------ | |
| 165 | + _DEEP_KEYWORDS = ("about", "a-propos", "apropos", "propos", "equipe", "team", "notre-equipe", | |
| 166 | + "leadership", "direction", "gouvernance", "membres", "conseil", "contact", | |
| 167 | + "coordonnees", "nous-joindre", "qui-sommes", "notre-histoire", "carrieres") | |
| 168 | + | |
| 169 | + def deep_dive(self, url: str, max_pages: int = 8) -> dict[str, Any]: | |
| 170 | + parts = urlparse(url if "://" in url else "http://" + url) | |
| 171 | + root = f"{parts.scheme or 'https'}://{parts.netloc or parts.path}" | |
| 172 | + try: | |
| 173 | + links = self.fc.map(root) | |
| 174 | + except Exception as e: # noqa: BLE001 | |
| 175 | + links = [root] | |
| 176 | + self.store.log_event("error", f"map deep_dive: {e}", root) | |
| 177 | + | |
| 178 | + def score(u: str) -> int: | |
| 179 | + lu = u.lower() | |
| 180 | + return sum(2 if k in lu else 0 for k in self._DEEP_KEYWORDS) | |
| 181 | + targets = [t for t in sorted(set(links), key=score, reverse=True) if score(t) > 0][: max_pages - 1] | |
| 182 | + targets = [root] + targets | |
| 183 | + self.store.log_event("agent", f"deep_dive {parts.netloc}: {len(targets)} page(s), concurrent", root) | |
| 184 | + res = self.crawl_batch(targets) | |
| 185 | + return {"domain": parts.netloc, "pages": res["pages"], | |
| 186 | + "entities_found": res["entities_found"], "relations_found": res["relations_found"]} | |
| 187 | + | |
| 188 | + # -- mode agent (contrôleur = modèle plus puissant) ------------------- | |
| 189 | + def run_agent(self, goal: str, seed: str | None, max_steps: int, should_abort=None) -> str: | |
| 190 | + self.store.log_event("agent", f"Mission: {goal}", "") | |
| 191 | + | |
| 192 | + def t_search(inp: dict[str, Any]) -> dict[str, Any]: | |
| 193 | + res = self.fc.search(inp["query"], int(inp.get("limit", 10))) | |
| 194 | + self.store.log_event("search", inp["query"], "", {"results": len(res)}) | |
| 195 | + return {"results": [{"url": r.get("url"), "title": r.get("title"), | |
| 196 | + "desc": r.get("description")} for r in res]} | |
| 197 | + | |
| 198 | + def t_map(inp: dict[str, Any]) -> dict[str, Any]: | |
| 199 | + links = self.fc.map(inp["url"], inp.get("search")) | |
| 200 | + self.store.enqueue(links[:150]) | |
| 201 | + self.store.log_event("map", inp["url"], inp["url"], {"links": len(links)}) | |
| 202 | + return {"count": len(links), "sample": links[:40]} | |
| 203 | + | |
| 204 | + def t_scrape(inp: dict[str, Any]) -> dict[str, Any]: | |
| 205 | + return self._scrape_and_extract(inp["url"]) | |
| 206 | + | |
| 207 | + def t_deep(inp: dict[str, Any]) -> dict[str, Any]: | |
| 208 | + return self.deep_dive(inp["url"], int(inp.get("max_pages", 8))) | |
| 209 | + | |
| 210 | + def t_enqueue(inp: dict[str, Any]) -> dict[str, Any]: | |
| 211 | + return {"enqueued": self.store.enqueue(inp.get("urls", []))} | |
| 212 | + | |
| 213 | + def t_drain(inp: dict[str, Any]) -> dict[str, Any]: | |
| 214 | + return self.crawl_batch([r["url"] for r in self.store.pending_batch( | |
| 215 | + int(inp.get("count", self.cfg.concurrency)))]) | |
| 216 | + | |
| 217 | + def t_plan(inp: dict[str, Any]) -> dict[str, Any]: | |
| 218 | + self.store.log_event("agent", "PLAN: " + " | ".join(inp.get("plan", [])), "", | |
| 219 | + {"rationale": inp.get("rationale", "")}) | |
| 220 | + return {"ack": True} | |
| 221 | + | |
| 222 | + def t_note(inp: dict[str, Any]) -> dict[str, Any]: | |
| 223 | + self.store.log_event("agent", "NOTE: " + inp.get("note", ""), "") | |
| 224 | + return {"ack": True} | |
| 225 | + | |
| 226 | + controller = Controller( | |
| 227 | + self.anthropic, self.cfg.model, | |
| 228 | + {"search_web": t_search, "map_site": t_map, "scrape_page": t_scrape, | |
| 229 | + "deep_dive": t_deep, "crawl_queue": t_drain, "enqueue_urls": t_enqueue, | |
| 230 | + "update_plan": t_plan, "note_progress": t_note}, | |
| 231 | + on_event=lambda kind, msg: self.store.log_event(kind, msg, ""), | |
| 232 | + max_context_tokens=self.cfg.max_context_tokens, | |
| 233 | + ) | |
| 234 | + summary = controller.run(goal=goal, seed=seed, max_steps=max_steps, should_abort=should_abort) | |
| 235 | + self.store.log_event("agent", "Mission conclue", "", {"summary": summary}) | |
| 236 | + return summary | |
| 237 | + | |
| 238 | + # -- pipeline (concurrent) -------------------------------------------- | |
| 239 | + def run_pipeline(self, query: str, limit: int = 10, per_site_pages: int = 3) -> dict[str, Any]: | |
| 240 | + results = self.fc.search(query, limit) | |
| 241 | + urls = [r.get("url") for r in results if r.get("url")] | |
| 242 | + res = self.crawl_batch(urls) | |
| 243 | + # approfondir les domaines découverts | |
| 244 | + for u in urls[:per_site_pages]: | |
| 245 | + if self._pages_scraped >= self.cfg.max_pages_per_run: | |
| 246 | + break | |
| 247 | + self.deep_dive(u, max_pages=per_site_pages + 2) | |
| 248 | + return {"query": query, "pages_scraped": self._pages_scraped, | |
| 249 | + "entities_found": res["entities_found"], "stats": self.store.stats()} | |
| 250 | + | |
| 251 | + # -- file d'attente (drainée en concurrence) -------------------------- | |
| 252 | + def drain_queue(self, max_pages: int | None = None, should_abort=None) -> dict[str, Any]: | |
| 253 | + budget = max_pages or self.cfg.max_pages_per_run | |
| 254 | + processed = 0 | |
| 255 | + while processed < budget and self._pages_scraped < self.cfg.max_pages_per_run: | |
| 256 | + if should_abort and should_abort(): | |
| 257 | + break | |
| 258 | + rows = self.store.pending_batch(min(self.cfg.concurrency, budget - processed), | |
| 259 | + max_attempts=self.cfg.max_attempts) | |
| 260 | + if not rows: | |
| 261 | + break | |
| 262 | + self.crawl_batch([r["url"] for r in rows]) | |
| 263 | + # robustesse : succès -> done ; échec -> retry (attempts++) puis dead-letter | |
| 264 | + for r in rows: | |
| 265 | + if self.store.source_seen(r["url"]): | |
| 266 | + self.store.mark(r["id"], "done") | |
| 267 | + else: | |
| 268 | + self.store.bump_attempt(r["id"], self.cfg.max_attempts) | |
| 269 | + processed += len(rows) | |
| 270 | + return {"processed": processed, "stats": self.store.stats()} | |
| 271 | + | |
| 272 | + def close(self) -> None: | |
| 273 | + self.store.close() | |
added
src/robots.py
+59 −0
@@ -0,0 +1,59 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Cache robots.txt robuste. | |
| 4 | + | |
| 5 | +`urllib.robotparser.RobotFileParser.read()` récupère le robots.txt avec le | |
| 6 | +User-Agent `Python-urllib`, souvent bloqué (403) par les serveurs, ce qui bascule | |
| 7 | +le parseur en `disallow_all=True` (faux négatif). On récupère donc le fichier nous- | |
| 8 | +mêmes via `requests` avec un vrai User-Agent, puis on le parse. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import urllib.robotparser | |
| 14 | +from typing import Optional | |
| 15 | +from urllib.parse import urlparse | |
| 16 | + | |
| 17 | +import requests | |
| 18 | + | |
| 19 | + | |
| 20 | +class RobotsCache: | |
| 21 | + def __init__(self, user_agent: str, enabled: bool = True, timeout: int = 15): | |
| 22 | + self.user_agent = user_agent | |
| 23 | + self.enabled = enabled | |
| 24 | + self.timeout = timeout | |
| 25 | + self._cache: dict[str, Optional[urllib.robotparser.RobotFileParser]] = {} | |
| 26 | + | |
| 27 | + def _load(self, root: str) -> Optional[urllib.robotparser.RobotFileParser]: | |
| 28 | + if root in self._cache: | |
| 29 | + return self._cache[root] | |
| 30 | + rp: Optional[urllib.robotparser.RobotFileParser] = None | |
| 31 | + try: | |
| 32 | + r = requests.get( | |
| 33 | + f"{root}/robots.txt", | |
| 34 | + headers={"User-Agent": self.user_agent}, | |
| 35 | + timeout=self.timeout, | |
| 36 | + ) | |
| 37 | + if r.status_code == 200 and r.text.strip(): | |
| 38 | + rp = urllib.robotparser.RobotFileParser() | |
| 39 | + rp.parse(r.text.splitlines()) | |
| 40 | + # 4xx/5xx ou vide -> pas de règles connues -> on autorise (rp=None) | |
| 41 | + except Exception: | |
| 42 | + rp = None # robots injoignable -> on autorise, la politesse vient du rate-limit | |
| 43 | + self._cache[root] = rp | |
| 44 | + return rp | |
| 45 | + | |
| 46 | + def allowed(self, url: str) -> bool: | |
| 47 | + if not self.enabled: | |
| 48 | + return True | |
| 49 | + try: | |
| 50 | + parts = urlparse(url) | |
| 51 | + if not parts.scheme or not parts.netloc: | |
| 52 | + return True | |
| 53 | + root = f"{parts.scheme}://{parts.netloc}" | |
| 54 | + rp = self._load(root) | |
| 55 | + if rp is None: | |
| 56 | + return True | |
| 57 | + return rp.can_fetch(self.user_agent, url) | |
| 58 | + except Exception: | |
| 59 | + return True | |
added
src/scheduler.py
+223 −0
@@ -0,0 +1,223 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Bot LONG TERME : boucle persistante, reprise sur incident, budget quotidien. | |
| 4 | + | |
| 5 | +Cycle : | |
| 6 | + 1. Draine une partie de la file d'URLs découvertes (exploration en largeur). | |
| 7 | + 2. Sinon, prend la prochaine mission « due » et laisse l'agent Haiku la mener. | |
| 8 | + 3. Revisite périodique des missions (crawl perpétuel). | |
| 9 | +Respecte un budget de pages/jour et s'arrête proprement (SIGINT/SIGTERM). | |
| 10 | +L'état vit dans SQLite : le bot peut être tué et redémarré sans rien perdre. | |
| 11 | +""" | |
| 12 | + | |
| 13 | +from __future__ import annotations | |
| 14 | + | |
| 15 | +import signal | |
| 16 | +import time | |
| 17 | +from typing import Optional | |
| 18 | + | |
| 19 | +from .config import Config | |
| 20 | +from .orchestrator import Orchestrator | |
| 21 | + | |
| 22 | +# Amorçage FLAGSHIP ka6 : découverte des CRÉATEURS / INFLUENCEURS québécois. | |
| 23 | +NICHES = [ | |
| 24 | + "mode", "beauté", "maquillage", "gaming", "cuisine", "humour", "lifestyle", "sport", | |
| 25 | + "fitness", "techno", "voyage", "famille et parentalité", "musique", "art et illustration", | |
| 26 | + "décoration et rénovation", "finances personnelles", "plein air", "food et restaurants", | |
| 27 | + "danse", "actualité et politique", | |
| 28 | +] | |
| 29 | +PLATFORMS = ["TikTok", "Instagram", "YouTube", "Twitch"] | |
| 30 | +# Requêtes annuaire/agences (missions à haute densité de créateurs) | |
| 31 | +DIRECTORY_MISSIONS = [ | |
| 32 | + "Cartographier les agences d'influence et de gestion de talents au Québec et les créateurs qu'elles représentent (handle, plateformes, abonnés, niche).", | |
| 33 | + "Répertorier les palmarès et listes des meilleurs influenceurs et créateurs de contenu québécois (tous réseaux).", | |
| 34 | + "Découvrir les créateurs de contenu franco-québécois sur YouTube : chaînes, abonnés, thématique, collaborations.", | |
| 35 | + "Découvrir les influenceurs québécois sur TikTok par niche : handle, abonnés, marques partenaires.", | |
| 36 | + "Cartographier les plateformes et collectifs de créateurs au Québec (réseaux, MCN, coopératives de contenu).", | |
| 37 | + "Identifier les marques québécoises qui font du marketing d'influence et leurs créateurs ambassadeurs.", | |
| 38 | +] | |
| 39 | +DEFAULT_REGIONS = ["Québec"] # objectif initial : tout le Québec | |
| 40 | + | |
| 41 | + | |
| 42 | +class LongRunner: | |
| 43 | + # ka6 flagship : cadence très agressive et budget élevé | |
| 44 | + def __init__(self, cfg: Config, cycle_delay: float = 6.0, queue_batch: int = 40, | |
| 45 | + daily_page_budget: int = 9000, revisit_hours: float = 36.0, | |
| 46 | + session_minutes: float = 15.0): | |
| 47 | + self.cfg = cfg | |
| 48 | + self.cycle_delay = cycle_delay | |
| 49 | + self.queue_batch = queue_batch | |
| 50 | + self.daily_page_budget = daily_page_budget | |
| 51 | + self.revisit_seconds = revisit_hours * 3600 | |
| 52 | + self.session_minutes = session_minutes | |
| 53 | + self._stop = False | |
| 54 | + self._idle_logged = False | |
| 55 | + | |
| 56 | + def _install_signals(self) -> None: | |
| 57 | + def handler(signum, _frame): | |
| 58 | + self._stop = True | |
| 59 | + for sig in (signal.SIGINT, signal.SIGTERM): | |
| 60 | + try: | |
| 61 | + signal.signal(sig, handler) | |
| 62 | + except Exception: | |
| 63 | + pass | |
| 64 | + | |
| 65 | + def seed_missions(self, orch: Orchestrator, limit: int = 120) -> int: | |
| 66 | + # Pré-remplissage désactivé par défaut : le bot démarre VIDE et attend des missions | |
| 67 | + # ajoutées via le dashboard. Activer avec KA_BOT_AUTOSEED=1 pour réamorcer. | |
| 68 | + import os | |
| 69 | + if os.getenv("KA_BOT_AUTOSEED", "0").strip().lower() not in ("1", "true", "yes", "on"): | |
| 70 | + return 0 | |
| 71 | + if orch.store.list_missions(): | |
| 72 | + return 0 | |
| 73 | + n = 0 | |
| 74 | + # 1) missions annuaires/agences (haute priorité — riches en créateurs) | |
| 75 | + for goal in DIRECTORY_MISSIONS: | |
| 76 | + orch.store.add_mission(goal=goal, sector="influence", region="Québec", priority=1) | |
| 77 | + n += 1 | |
| 78 | + # 2) niche × plateforme | |
| 79 | + for niche in NICHES: | |
| 80 | + for platform in PLATFORMS: | |
| 81 | + if n >= limit: | |
| 82 | + break | |
| 83 | + goal = (f"Découvrir les créateurs de contenu et influenceurs québécois en « {niche} » " | |
| 84 | + f"sur {platform} : nom, handle, plateformes, nombre d'abonnés, niche, langues, " | |
| 85 | + f"agence qui les représente et marques avec qui ils collaborent.") | |
| 86 | + orch.store.add_mission(goal=goal, sector=niche, region=platform, priority=3) | |
| 87 | + n += 1 | |
| 88 | + orch.store.log_event("info", f"{n} missions créateurs/influenceurs initiales créées.") | |
| 89 | + return n | |
| 90 | + | |
| 91 | + def _apply_live_settings(self, orch: Orchestrator) -> None: | |
| 92 | + """Réglages modifiables en direct depuis le dashboard (table settings).""" | |
| 93 | + s = orch.store | |
| 94 | + def num(key, cur, cast): | |
| 95 | + v = s.get_setting(key, "") | |
| 96 | + try: | |
| 97 | + return cast(v) if v != "" else cur | |
| 98 | + except ValueError: | |
| 99 | + return cur | |
| 100 | + self.daily_page_budget = num("cfg_daily_budget", self.daily_page_budget, int) | |
| 101 | + self.cycle_delay = num("cfg_cycle_delay", self.cycle_delay, float) | |
| 102 | + self.queue_batch = num("cfg_queue_batch", self.queue_batch, int) | |
| 103 | + self.session_minutes = num("cfg_session_minutes", self.session_minutes, float) | |
| 104 | + orch.cfg.max_pages_per_run = num("cfg_max_pages", orch.cfg.max_pages_per_run, int) | |
| 105 | + # backend + robots + délai appliqués en direct au scraper | |
| 106 | + backend = s.get_setting("cfg_backend", "") | |
| 107 | + if backend in ("auto", "firecrawl", "scrapfly"): | |
| 108 | + orch.fc.backend = backend | |
| 109 | + robots = s.get_setting("cfg_respect_robots", "") | |
| 110 | + if robots in ("0", "1"): | |
| 111 | + orch.fc.robots.enabled = robots == "1" | |
| 112 | + delay = num("cfg_delay", None, float) | |
| 113 | + if delay is not None: | |
| 114 | + orch.fc.firecrawl.limiter.delay = max(0.0, delay) | |
| 115 | + if orch.fc.scrapfly: | |
| 116 | + orch.fc.scrapfly.limiter.delay = max(0.0, delay) | |
| 117 | + | |
| 118 | + def _start_of_day(self) -> float: | |
| 119 | + lt = time.localtime() | |
| 120 | + return time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, 0, 0, -1)) | |
| 121 | + | |
| 122 | + def run(self, max_cycles: Optional[int] = None) -> None: | |
| 123 | + self._install_signals() | |
| 124 | + orch = Orchestrator(self.cfg) | |
| 125 | + orch.store.requeue_stale_missions() | |
| 126 | + self.seed_missions(orch) | |
| 127 | + | |
| 128 | + # Fenêtre de session : dès le démarrage, le bot travaille `session_minutes`. | |
| 129 | + # Le bouton « Continuer » du dashboard prolonge run_until (voir web/app.py). | |
| 130 | + if not orch.store.get_setting("run_until", ""): | |
| 131 | + orch.store.set_setting("run_until", str(time.time() + self.session_minutes * 60)) | |
| 132 | + orch.store.log_event("info", f"Session de {int(self.session_minutes)} min démarrée dès le déploiement.") | |
| 133 | + orch.store.log_event("info", "ka6 démarré.") | |
| 134 | + | |
| 135 | + cycles = 0 | |
| 136 | + try: | |
| 137 | + while not self._stop: | |
| 138 | + if max_cycles is not None and cycles >= max_cycles: | |
| 139 | + break | |
| 140 | + cycles += 1 | |
| 141 | + self._apply_live_settings(orch) | |
| 142 | + orch.store.set_setting("bot_busy", "0") # au repos par défaut (heartbeat) | |
| 143 | + | |
| 144 | + # Pause pilotée depuis le dashboard | |
| 145 | + if orch.store.get_setting("paused", "0") == "1": | |
| 146 | + self._sleep(self.cycle_delay) | |
| 147 | + continue | |
| 148 | + | |
| 149 | + # Fenêtre de session : au-delà de run_until, on attend « Continuer » | |
| 150 | + try: | |
| 151 | + run_until = float(orch.store.get_setting("run_until", "0") or 0) | |
| 152 | + except ValueError: | |
| 153 | + run_until = 0.0 | |
| 154 | + if time.time() >= run_until: | |
| 155 | + if not self._idle_logged: | |
| 156 | + orch.store.log_event( | |
| 157 | + "info", "Session écoulée — cliquez « Continuer » pour 15 min de plus." | |
| 158 | + ) | |
| 159 | + self._idle_logged = True | |
| 160 | + self._sleep(self.cycle_delay) | |
| 161 | + continue | |
| 162 | + self._idle_logged = False | |
| 163 | + | |
| 164 | + # Budget quotidien | |
| 165 | + done_today = orch.store.pages_since(self._start_of_day()) | |
| 166 | + if done_today >= self.daily_page_budget: | |
| 167 | + orch.store.log_event("info", f"Budget quotidien atteint ({done_today}). Pause.") | |
| 168 | + self._sleep(min(1800, self.cycle_delay * 30)) | |
| 169 | + continue | |
| 170 | + | |
| 171 | + orch._pages_scraped = 0 # réinitialise la limite par cycle | |
| 172 | + # STOP = arrêt demandé (skip/archive) OU pause : interrompt le travail EN COURS | |
| 173 | + stop_fn = lambda: (orch.store.get_setting("abort_current", "0") == "1" | |
| 174 | + or orch.store.get_setting("paused", "0") == "1") | |
| 175 | + | |
| 176 | + # 0) mission FORCÉE ("lancer maintenant", priorité 0) : passe avant tout | |
| 177 | + mission = orch.store.next_forced() | |
| 178 | + | |
| 179 | + # 1) sinon, drainer la file découverte (interruptible) | |
| 180 | + if mission is None and orch.store.next_pending() is not None and not stop_fn(): | |
| 181 | + orch.store.set_setting("bot_busy", "1") | |
| 182 | + res = orch.drain_queue(max_pages=self.queue_batch, should_abort=stop_fn) | |
| 183 | + orch.store.set_setting("bot_busy", "0") | |
| 184 | + orch.store.log_event("info", f"File drainée: {res['processed']} page(s).") | |
| 185 | + orch.store.set_setting("abort_current", "0") | |
| 186 | + self._sleep(self.cycle_delay) | |
| 187 | + continue | |
| 188 | + | |
| 189 | + # 2) sinon, mission due | |
| 190 | + if mission is None: | |
| 191 | + mission = orch.store.due_mission(self.revisit_seconds) | |
| 192 | + orch.store.set_setting("abort_current", "0") # nouvelle activité → drapeau propre | |
| 193 | + if mission is None: | |
| 194 | + orch.store.log_event("info", "Aucune mission due — veille.") | |
| 195 | + self._sleep(min(1800, self.cycle_delay * 15)) | |
| 196 | + continue | |
| 197 | + | |
| 198 | + mid = mission["id"] | |
| 199 | + orch.store.set_mission_status(mid, "running") | |
| 200 | + orch.store.set_setting("active_mission_id", str(mid)) | |
| 201 | + orch.store.set_setting("bot_busy", "1") | |
| 202 | + orch.store.log_event("info", f"Mission #{mid} démarrée: {mission['goal'][:120]}") | |
| 203 | + try: | |
| 204 | + summary = orch.run_agent(mission["goal"], mission["seed"], max_steps=40, | |
| 205 | + should_abort=stop_fn) | |
| 206 | + st = "pending" if summary == "Interrompu par l'utilisateur." else "done" | |
| 207 | + orch.store.set_mission_status(mid, st, bump_run=True) | |
| 208 | + except Exception as e: # noqa: BLE001 | |
| 209 | + orch.store.set_mission_status(mid, "pending", bump_run=True) | |
| 210 | + orch.store.log_event("error", f"Mission #{mid}: {e}") | |
| 211 | + finally: | |
| 212 | + orch.store.set_setting("bot_busy", "0") | |
| 213 | + orch.store.set_setting("active_mission_id", "") | |
| 214 | + orch.store.set_setting("abort_current", "0") | |
| 215 | + self._sleep(self.cycle_delay) | |
| 216 | + finally: | |
| 217 | + orch.store.log_event("info", "ka6 long terme arrêté.") | |
| 218 | + orch.close() | |
| 219 | + | |
| 220 | + def _sleep(self, seconds: float) -> None: | |
| 221 | + end = time.time() + seconds | |
| 222 | + while time.time() < end and not self._stop: | |
| 223 | + time.sleep(min(1.0, end - time.time())) | |
added
src/scraper.py
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Dispatcher de scrape multi-backend + garde robots.txt. | |
| 4 | + | |
| 5 | +- search / map : toujours via Firecrawl. | |
| 6 | +- scrape : selon le backend choisi (auto | firecrawl | scrapfly). | |
| 7 | + En mode `auto`, Firecrawl est tenté d'abord, puis Scrapfly en repli si le | |
| 8 | + résultat est vide ou en erreur. | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +from typing import Any, Optional | |
| 14 | + | |
| 15 | +from .config import Config | |
| 16 | +from .firecrawl_client import FirecrawlClient | |
| 17 | +from .robots import RobotsCache | |
| 18 | +from .scrapfly_client import ScrapflyClient | |
| 19 | + | |
| 20 | + | |
| 21 | +class Scraper: | |
| 22 | + def __init__(self, cfg: Config): | |
| 23 | + self.cfg = cfg | |
| 24 | + self.firecrawl = FirecrawlClient(cfg.firecrawl_api_key, cfg.firecrawl_base, cfg.request_delay) | |
| 25 | + self.scrapfly = ( | |
| 26 | + ScrapflyClient(cfg.scrapfly_api_key, cfg.scrapfly_base, cfg.request_delay, cfg.scrapfly_render_js) | |
| 27 | + if cfg.scrapfly_api_key | |
| 28 | + else None | |
| 29 | + ) | |
| 30 | + self.robots = RobotsCache(cfg.user_agent, enabled=cfg.respect_robots) | |
| 31 | + self.backend = cfg.scraper_backend | |
| 32 | + | |
| 33 | + # -- découverte (Firecrawl) ------------------------------------------- | |
| 34 | + def search(self, query: str, limit: int = 10) -> list[dict[str, Any]]: | |
| 35 | + return self.firecrawl.search(query, limit) | |
| 36 | + | |
| 37 | + def map(self, url: str, search: Optional[str] = None) -> list[str]: | |
| 38 | + return self.firecrawl.map(url, search) | |
| 39 | + | |
| 40 | + # -- scrape ------------------------------------------------------------ | |
| 41 | + def scrape(self, url: str) -> dict[str, Any]: | |
| 42 | + if not self.robots.allowed(url): | |
| 43 | + return {"blocked": True, "markdown": "", "metadata": {}, "links": [], "backend": None} | |
| 44 | + | |
| 45 | + order = self._order() | |
| 46 | + last_err: Optional[Exception] = None | |
| 47 | + for backend in order: | |
| 48 | + try: | |
| 49 | + data = self._scrape_with(backend, url) | |
| 50 | + if data.get("markdown", "").strip(): | |
| 51 | + return data | |
| 52 | + except Exception as e: # noqa: BLE001 | |
| 53 | + last_err = e | |
| 54 | + if last_err: | |
| 55 | + return {"error": str(last_err), "markdown": "", "metadata": {}, "links": [], "backend": None} | |
| 56 | + return {"markdown": "", "metadata": {}, "links": [], "backend": order[-1] if order else None} | |
| 57 | + | |
| 58 | + def _order(self) -> list[str]: | |
| 59 | + if self.backend == "firecrawl": | |
| 60 | + return ["firecrawl"] | |
| 61 | + if self.backend == "scrapfly": | |
| 62 | + return ["scrapfly"] if self.scrapfly else ["firecrawl"] | |
| 63 | + # auto : Firecrawl puis Scrapfly en repli | |
| 64 | + return ["firecrawl", "scrapfly"] if self.scrapfly else ["firecrawl"] | |
| 65 | + | |
| 66 | + def _scrape_with(self, backend: str, url: str) -> dict[str, Any]: | |
| 67 | + if backend == "scrapfly": | |
| 68 | + if not self.scrapfly: | |
| 69 | + raise RuntimeError("Scrapfly non configuré (SCRAPFLY_API_KEY manquant)") | |
| 70 | + return self.scrapfly.scrape(url) | |
| 71 | + return self.firecrawl.scrape(url) | |
added
src/scrapfly_client.py
+58 −0
@@ -0,0 +1,58 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Client REST Scrapfly (https://scrapfly.io) — backend de scrape alternatif. | |
| 4 | + | |
| 5 | +Atouts : rendu JavaScript, contournement anti-bot (ASP), proxies. Utile quand | |
| 6 | +Firecrawl échoue ou renvoie une page vide (SPA, protection anti-scraping). | |
| 7 | +""" | |
| 8 | + | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import time | |
| 12 | +from typing import Any, Optional | |
| 13 | + | |
| 14 | +import requests | |
| 15 | + | |
| 16 | +from .util import RateLimiter | |
| 17 | + | |
| 18 | + | |
| 19 | +class ScrapflyClient: | |
| 20 | + def __init__(self, api_key: str, base_url: str = "https://api.scrapfly.io", delay: float = 1.5, | |
| 21 | + render_js: bool = False): | |
| 22 | + self.api_key = api_key | |
| 23 | + self.base = base_url.rstrip("/") | |
| 24 | + self.render_js = render_js | |
| 25 | + self.session = requests.Session() | |
| 26 | + self.limiter = RateLimiter(delay) | |
| 27 | + | |
| 28 | + def scrape(self, url: str, render_js: Optional[bool] = None, retries: int = 2) -> dict[str, Any]: | |
| 29 | + params = { | |
| 30 | + "key": self.api_key, | |
| 31 | + "url": url, | |
| 32 | + "format": "markdown", | |
| 33 | + "asp": "true", # Anti Scraping Protection bypass | |
| 34 | + "render_js": "true" if (self.render_js if render_js is None else render_js) else "false", | |
| 35 | + } | |
| 36 | + last_err: Optional[Exception] = None | |
| 37 | + for attempt in range(retries): | |
| 38 | + self.limiter.wait() | |
| 39 | + try: | |
| 40 | + r = self.session.get(f"{self.base}/scrape", params=params, timeout=70) | |
| 41 | + if r.status_code == 429: | |
| 42 | + time.sleep(1.5 * (attempt + 1)) | |
| 43 | + continue | |
| 44 | + r.raise_for_status() | |
| 45 | + result = (r.json() or {}).get("result", {}) or {} | |
| 46 | + content = result.get("content", "") or "" | |
| 47 | + meta = { | |
| 48 | + "title": (result.get("metadata") or {}).get("title", "") | |
| 49 | + if isinstance(result.get("metadata"), dict) | |
| 50 | + else "", | |
| 51 | + "status_code": result.get("status_code"), | |
| 52 | + "url": result.get("url", url), | |
| 53 | + } | |
| 54 | + return {"markdown": content, "metadata": meta, "links": [], "backend": "scrapfly"} | |
| 55 | + except Exception as e: # noqa: BLE001 | |
| 56 | + last_err = e | |
| 57 | + time.sleep(1.5 * (attempt + 1)) | |
| 58 | + raise RuntimeError(f"Scrapfly scrape a échoué: {last_err}") | |
added
src/storage.py
+671 −0
@@ -0,0 +1,671 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Persistance SQLite — graphe de connaissances du web québécois (v2). | |
| 4 | + | |
| 5 | +Entités enrichies et normalisées, relations typées (avec rôle + provenance), | |
| 6 | +mentions (provenance multi-sources), déduplication par résolution d'entités, | |
| 7 | +plus missions / événements / réglages / file de crawl pour le bot long terme. | |
| 8 | +""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import hashlib | |
| 13 | +import json | |
| 14 | +import sqlite3 | |
| 15 | +import time | |
| 16 | +from typing import Any, Iterable, Optional | |
| 17 | + | |
| 18 | +from .normalize import canonical_location, domain, normalize_name | |
| 19 | + | |
| 20 | + | |
| 21 | +def _to_int(v: Any) -> Optional[int]: | |
| 22 | + """Parse un nombre d'abonnés : 12000, '12,5 K', '3.2M', '1 200 abonnés' -> int.""" | |
| 23 | + if v is None or v == "": | |
| 24 | + return None | |
| 25 | + if isinstance(v, (int, float)): | |
| 26 | + return int(v) | |
| 27 | + s = str(v).lower().replace(" ", " ").strip() | |
| 28 | + import re as _re | |
| 29 | + m = _re.search(r"([\d]+(?:[.,]\d+)?)\s*([km])?", s.replace(" ", "")) | |
| 30 | + if not m: | |
| 31 | + return None | |
| 32 | + num = float(m.group(1).replace(",", ".")) | |
| 33 | + mult = {"k": 1_000, "m": 1_000_000}.get(m.group(2) or "", 1) | |
| 34 | + try: | |
| 35 | + return int(num * mult) | |
| 36 | + except (ValueError, OverflowError): | |
| 37 | + return None | |
| 38 | + | |
| 39 | + | |
| 40 | +REL_TYPES = { | |
| 41 | + "WORKS_AT", "FOUNDER_OF", "OWNS", "MEMBER_OF", "PARTNER_OF", | |
| 42 | + "SUBSIDIARY_OF", "PARENT_OF", "AFFILIATED_WITH", "LOCATED_IN", "SUPPLIER_OF", | |
| 43 | + # relations créateurs / influenceurs | |
| 44 | + "REPRESENTED_BY", "COLLABORATES_WITH", "CREATES_ON", "SPONSORED_BY", "PROMOTES", | |
| 45 | + "MANAGES", "APPEARS_WITH", | |
| 46 | +} | |
| 47 | + | |
| 48 | +SCHEMA = """ | |
| 49 | +CREATE TABLE IF NOT EXISTS entities ( | |
| 50 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 51 | + type TEXT NOT NULL, | |
| 52 | + name TEXT NOT NULL, | |
| 53 | + description TEXT, | |
| 54 | + url TEXT, | |
| 55 | + location TEXT, | |
| 56 | + email TEXT, | |
| 57 | + phone TEXT, | |
| 58 | + confidence REAL DEFAULT 0.5, | |
| 59 | + raw_json TEXT, | |
| 60 | + created_at REAL NOT NULL, | |
| 61 | + updated_at REAL NOT NULL, | |
| 62 | + UNIQUE(type, name, url) | |
| 63 | +); | |
| 64 | + | |
| 65 | +CREATE TABLE IF NOT EXISTS social_links ( | |
| 66 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 67 | + entity_id INTEGER NOT NULL, platform TEXT NOT NULL, url TEXT NOT NULL, | |
| 68 | + UNIQUE(entity_id, url), | |
| 69 | + FOREIGN KEY(entity_id) REFERENCES entities(id) ON DELETE CASCADE | |
| 70 | +); | |
| 71 | + | |
| 72 | +CREATE TABLE IF NOT EXISTS relations ( | |
| 73 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 74 | + from_entity INTEGER NOT NULL, to_entity INTEGER NOT NULL, relation_type TEXT NOT NULL, | |
| 75 | + UNIQUE(from_entity, to_entity, relation_type) | |
| 76 | +); | |
| 77 | + | |
| 78 | +CREATE TABLE IF NOT EXISTS entity_mentions ( | |
| 79 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 80 | + entity_id INTEGER NOT NULL, source_url TEXT NOT NULL, seen_at REAL NOT NULL, | |
| 81 | + UNIQUE(entity_id, source_url), | |
| 82 | + FOREIGN KEY(entity_id) REFERENCES entities(id) ON DELETE CASCADE | |
| 83 | +); | |
| 84 | + | |
| 85 | +CREATE TABLE IF NOT EXISTS sources ( | |
| 86 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 87 | + url TEXT NOT NULL UNIQUE, title TEXT, content_hash TEXT, scraped_at REAL NOT NULL | |
| 88 | +); | |
| 89 | + | |
| 90 | +CREATE TABLE IF NOT EXISTS crawl_queue ( | |
| 91 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 92 | + url TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'pending', | |
| 93 | + depth INTEGER DEFAULT 0, added_at REAL NOT NULL | |
| 94 | +); | |
| 95 | + | |
| 96 | +CREATE TABLE IF NOT EXISTS missions ( | |
| 97 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 98 | + goal TEXT NOT NULL, seed TEXT, sector TEXT, region TEXT, | |
| 99 | + priority INTEGER DEFAULT 5, status TEXT NOT NULL DEFAULT 'pending', | |
| 100 | + runs_count INTEGER DEFAULT 0, last_run REAL, created_at REAL NOT NULL, | |
| 101 | + UNIQUE(goal, region) | |
| 102 | +); | |
| 103 | + | |
| 104 | +CREATE TABLE IF NOT EXISTS events ( | |
| 105 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 106 | + ts REAL NOT NULL, kind TEXT NOT NULL, message TEXT, url TEXT, data_json TEXT | |
| 107 | +); | |
| 108 | + | |
| 109 | +CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT); | |
| 110 | + | |
| 111 | +CREATE TABLE IF NOT EXISTS archives ( | |
| 112 | + id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT, created_at REAL NOT NULL, | |
| 113 | + entities INTEGER, relations INTEGER, sources INTEGER, payload TEXT | |
| 114 | +); | |
| 115 | + | |
| 116 | +CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type); | |
| 117 | +CREATE INDEX IF NOT EXISTS idx_queue_status ON crawl_queue(status); | |
| 118 | +CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts); | |
| 119 | +CREATE INDEX IF NOT EXISTS idx_rel_from ON relations(from_entity); | |
| 120 | +CREATE INDEX IF NOT EXISTS idx_rel_to ON relations(to_entity); | |
| 121 | +""" | |
| 122 | + | |
| 123 | +# Colonnes ajoutées par migration aux DB existantes | |
| 124 | +_ENTITY_COLS = [ | |
| 125 | + ("canonical_name", "TEXT"), ("norm_name", "TEXT"), ("domain", "TEXT"), ("sector", "TEXT"), | |
| 126 | + ("address", "TEXT"), ("city", "TEXT"), ("region", "TEXT"), ("postal_code", "TEXT"), | |
| 127 | + ("neq", "TEXT"), ("founded", "TEXT"), ("size", "TEXT"), ("tags", "TEXT"), | |
| 128 | + ("source_url", "TEXT"), ("first_seen", "REAL"), ("last_seen", "REAL"), | |
| 129 | + # champs CRÉATEURS / INFLUENCEURS (ka6) | |
| 130 | + ("niche", "TEXT"), ("handle", "TEXT"), ("platform", "TEXT"), ("followers", "INTEGER"), | |
| 131 | + ("languages", "TEXT"), | |
| 132 | +] | |
| 133 | +_RELATION_COLS = [("role", "TEXT"), ("source_url", "TEXT"), ("confidence", "REAL")] | |
| 134 | + | |
| 135 | + | |
| 136 | +class Store: | |
| 137 | + def __init__(self, path: str): | |
| 138 | + self.conn = sqlite3.connect(path, timeout=30, check_same_thread=False) | |
| 139 | + self.conn.row_factory = sqlite3.Row | |
| 140 | + self.conn.execute("PRAGMA foreign_keys = ON") | |
| 141 | + self.conn.execute("PRAGMA journal_mode = WAL") | |
| 142 | + self.conn.execute("PRAGMA busy_timeout = 30000") | |
| 143 | + self.conn.executescript(SCHEMA) | |
| 144 | + self._migrate() | |
| 145 | + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_entities_norm ON entities(norm_name)") | |
| 146 | + self.conn.execute("CREATE INDEX IF NOT EXISTS idx_entities_domain ON entities(domain)") | |
| 147 | + self.conn.commit() | |
| 148 | + | |
| 149 | + def _migrate(self) -> None: | |
| 150 | + ecols = {r["name"] for r in self.conn.execute("PRAGMA table_info(entities)")} | |
| 151 | + for name, typ in _ENTITY_COLS: | |
| 152 | + if name not in ecols: | |
| 153 | + self.conn.execute(f"ALTER TABLE entities ADD COLUMN {name} {typ}") | |
| 154 | + rcols = {r["name"] for r in self.conn.execute("PRAGMA table_info(relations)")} | |
| 155 | + for name, typ in _RELATION_COLS: | |
| 156 | + if name not in rcols: | |
| 157 | + self.conn.execute(f"ALTER TABLE relations ADD COLUMN {name} {typ}") | |
| 158 | + # social_links.followers (abonnés par plateforme) + crawl_queue.attempts (robustesse) | |
| 159 | + scols = {r["name"] for r in self.conn.execute("PRAGMA table_info(social_links)")} | |
| 160 | + if "followers" not in scols: | |
| 161 | + self.conn.execute("ALTER TABLE social_links ADD COLUMN followers INTEGER") | |
| 162 | + qcols = {r["name"] for r in self.conn.execute("PRAGMA table_info(crawl_queue)")} | |
| 163 | + if "attempts" not in qcols: | |
| 164 | + self.conn.execute("ALTER TABLE crawl_queue ADD COLUMN attempts INTEGER DEFAULT 0") | |
| 165 | + self.conn.commit() | |
| 166 | + self._backfill() | |
| 167 | + | |
| 168 | + def _backfill(self) -> None: | |
| 169 | + """Normalise les entités héritées (norm_name/domain/region) pour la dédup et l'explorer.""" | |
| 170 | + rows = self.conn.execute( | |
| 171 | + "SELECT id, name, url, location FROM entities WHERE norm_name IS NULL" | |
| 172 | + ).fetchall() | |
| 173 | + for r in rows: | |
| 174 | + nn = normalize_name(r["name"]) or domain(r["url"]) | |
| 175 | + self.conn.execute( | |
| 176 | + "UPDATE entities SET norm_name=?, domain=?, region=COALESCE(region,?) WHERE id=?", | |
| 177 | + (nn, domain(r["url"]) or None, canonical_location(r["location"]) or None, r["id"]), | |
| 178 | + ) | |
| 179 | + if rows: | |
| 180 | + self.conn.commit() | |
| 181 | + | |
| 182 | + # -- résolution + upsert d'entités ------------------------------------- | |
| 183 | + def _resolve(self, etype: str, nn: str, dom: str, region: str) -> Optional[int]: | |
| 184 | + if not nn: | |
| 185 | + return None | |
| 186 | + c = self.conn | |
| 187 | + if dom: | |
| 188 | + r = c.execute( | |
| 189 | + "SELECT id FROM entities WHERE type=? AND norm_name=? AND domain=? LIMIT 1", | |
| 190 | + (etype, nn, dom), | |
| 191 | + ).fetchone() | |
| 192 | + if r: | |
| 193 | + return r["id"] | |
| 194 | + if region: | |
| 195 | + r = c.execute( | |
| 196 | + "SELECT id FROM entities WHERE type=? AND norm_name=? AND region=? " | |
| 197 | + "AND (domain IS NULL OR domain='') LIMIT 1", | |
| 198 | + (etype, nn, region), | |
| 199 | + ).fetchone() | |
| 200 | + if r: | |
| 201 | + return r["id"] | |
| 202 | + r = c.execute( | |
| 203 | + "SELECT id FROM entities WHERE type=? AND norm_name=? " | |
| 204 | + "AND (domain IS NULL OR domain='') AND (region IS NULL OR region='') LIMIT 1", | |
| 205 | + (etype, nn), | |
| 206 | + ).fetchone() | |
| 207 | + return r["id"] if r else None | |
| 208 | + | |
| 209 | + def upsert_entity(self, e: dict[str, Any], source_url: Optional[str] = None) -> int: | |
| 210 | + now = time.time() | |
| 211 | + etype = (e.get("type") or "website").strip().lower() | |
| 212 | + name = (e.get("name") or "").strip() | |
| 213 | + website = (e.get("website") or e.get("url") or "").strip() or None | |
| 214 | + dom = domain(website) | |
| 215 | + region = canonical_location(e.get("region") or e.get("city") or e.get("location") or "") | |
| 216 | + city = (e.get("city") or "").strip() or None | |
| 217 | + nn = normalize_name(name) or dom | |
| 218 | + if not name: | |
| 219 | + name = website or "inconnu" | |
| 220 | + conf = float(e.get("confidence", 0.5) or 0.5) | |
| 221 | + tags = json.dumps(e.get("tags"), ensure_ascii=False) if e.get("tags") else None | |
| 222 | + langs = e.get("languages") | |
| 223 | + languages = ", ".join(langs) if isinstance(langs, list) else (langs or None) | |
| 224 | + followers = _to_int(e.get("followers")) | |
| 225 | + | |
| 226 | + eid = self._resolve(etype, nn, dom, region) | |
| 227 | + vals = { | |
| 228 | + "canonical_name": e.get("canonical_name") or name, | |
| 229 | + "description": e.get("description"), "url": website, "domain": dom or None, | |
| 230 | + "sector": e.get("sector"), "address": e.get("address"), "city": city, | |
| 231 | + "region": region or None, "location": e.get("location") or region or city, | |
| 232 | + "email": e.get("email"), "phone": e.get("phone"), "postal_code": e.get("postal_code"), | |
| 233 | + "neq": e.get("neq"), "founded": e.get("founded"), "size": e.get("size"), | |
| 234 | + "tags": tags, "confidence": conf, "source_url": source_url, | |
| 235 | + "niche": e.get("niche"), "handle": e.get("handle"), "platform": e.get("platform"), | |
| 236 | + "followers": followers, "languages": languages, | |
| 237 | + } | |
| 238 | + if eid: | |
| 239 | + self.conn.execute( | |
| 240 | + """UPDATE entities SET | |
| 241 | + canonical_name=COALESCE(canonical_name,?), description=COALESCE(description,?), | |
| 242 | + url=COALESCE(url,?), domain=COALESCE(NULLIF(domain,''),?), sector=COALESCE(sector,?), | |
| 243 | + address=COALESCE(address,?), city=COALESCE(city,?), region=COALESCE(region,?), | |
| 244 | + location=COALESCE(location,?), email=COALESCE(email,?), phone=COALESCE(phone,?), | |
| 245 | + postal_code=COALESCE(postal_code,?), neq=COALESCE(neq,?), founded=COALESCE(founded,?), | |
| 246 | + size=COALESCE(size,?), tags=COALESCE(tags,?), confidence=MAX(IFNULL(confidence,0),?), | |
| 247 | + source_url=COALESCE(source_url,?), niche=COALESCE(niche,?), handle=COALESCE(handle,?), | |
| 248 | + platform=COALESCE(platform,?), followers=MAX(IFNULL(followers,0),?), | |
| 249 | + languages=COALESCE(languages,?), last_seen=?, updated_at=? WHERE id=?""", | |
| 250 | + ( | |
| 251 | + vals["canonical_name"], vals["description"], vals["url"], vals["domain"], | |
| 252 | + vals["sector"], vals["address"], vals["city"], vals["region"], vals["location"], | |
| 253 | + vals["email"], vals["phone"], vals["postal_code"], vals["neq"], vals["founded"], | |
| 254 | + vals["size"], vals["tags"], vals["confidence"], vals["source_url"], | |
| 255 | + vals["niche"], vals["handle"], vals["platform"], followers or 0, vals["languages"], | |
| 256 | + now, now, eid, | |
| 257 | + ), | |
| 258 | + ) | |
| 259 | + else: | |
| 260 | + cur = self.conn.execute( | |
| 261 | + """INSERT INTO entities(type, name, canonical_name, norm_name, description, url, domain, | |
| 262 | + sector, address, city, region, location, email, phone, postal_code, neq, founded, | |
| 263 | + size, tags, confidence, raw_json, source_url, niche, handle, platform, followers, | |
| 264 | + languages, first_seen, last_seen, created_at, updated_at) | |
| 265 | + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", | |
| 266 | + ( | |
| 267 | + etype, name, vals["canonical_name"], nn, vals["description"], vals["url"], vals["domain"], | |
| 268 | + vals["sector"], vals["address"], vals["city"], vals["region"], vals["location"], | |
| 269 | + vals["email"], vals["phone"], vals["postal_code"], vals["neq"], vals["founded"], | |
| 270 | + vals["size"], vals["tags"], conf, json.dumps(e, ensure_ascii=False), source_url, | |
| 271 | + vals["niche"], vals["handle"], vals["platform"], followers, vals["languages"], | |
| 272 | + now, now, now, now, | |
| 273 | + ), | |
| 274 | + ) | |
| 275 | + eid = cur.lastrowid | |
| 276 | + | |
| 277 | + for link in e.get("social_links", []) or []: | |
| 278 | + self.add_social_link(eid, link.get("platform", "?"), link.get("url", ""), | |
| 279 | + _to_int(link.get("followers"))) | |
| 280 | + if source_url: | |
| 281 | + self.conn.execute( | |
| 282 | + "INSERT OR IGNORE INTO entity_mentions(entity_id, source_url, seen_at) VALUES(?,?,?)", | |
| 283 | + (eid, source_url, now), | |
| 284 | + ) | |
| 285 | + self.conn.commit() | |
| 286 | + return eid | |
| 287 | + | |
| 288 | + def add_social_link(self, entity_id: int, platform: str, url: str, | |
| 289 | + followers: Optional[int] = None) -> None: | |
| 290 | + url = (url or "").strip() | |
| 291 | + if not url: | |
| 292 | + return | |
| 293 | + self.conn.execute( | |
| 294 | + "INSERT INTO social_links(entity_id, platform, url, followers) VALUES(?,?,?,?) " | |
| 295 | + "ON CONFLICT(entity_id, url) DO UPDATE SET followers=COALESCE(excluded.followers, followers)", | |
| 296 | + (entity_id, (platform or "?").strip().lower(), url, followers), | |
| 297 | + ) | |
| 298 | + | |
| 299 | + # -- relations --------------------------------------------------------- | |
| 300 | + def add_relation(self, from_id: int, to_id: int, rel_type: str, role: str = "", | |
| 301 | + source_url: str = "", confidence: float = 0.6) -> None: | |
| 302 | + rel_type = (rel_type or "").strip().upper() | |
| 303 | + if not from_id or not to_id or from_id == to_id or rel_type not in REL_TYPES: | |
| 304 | + return | |
| 305 | + self.conn.execute( | |
| 306 | + """INSERT INTO relations(from_entity, to_entity, relation_type, role, source_url, confidence) | |
| 307 | + VALUES(?,?,?,?,?,?) | |
| 308 | + ON CONFLICT(from_entity, to_entity, relation_type) DO UPDATE SET | |
| 309 | + role=COALESCE(NULLIF(excluded.role,''), role), | |
| 310 | + source_url=COALESCE(NULLIF(excluded.source_url,''), source_url), | |
| 311 | + confidence=MAX(IFNULL(confidence,0), excluded.confidence)""", | |
| 312 | + (from_id, to_id, rel_type, role or None, source_url or None, confidence), | |
| 313 | + ) | |
| 314 | + self.conn.commit() | |
| 315 | + | |
| 316 | + def entity_relations(self, entity_id: int) -> list[dict[str, Any]]: | |
| 317 | + rows = self.conn.execute( | |
| 318 | + """SELECT r.relation_type, r.role, r.confidence, r.from_entity, r.to_entity, | |
| 319 | + e.id oid, e.name oname, e.type otype, e.region oregion, e.sector osector | |
| 320 | + FROM relations r | |
| 321 | + JOIN entities e ON e.id = CASE WHEN r.from_entity=? THEN r.to_entity ELSE r.from_entity END | |
| 322 | + WHERE r.from_entity=? OR r.to_entity=? | |
| 323 | + ORDER BY r.confidence DESC""", | |
| 324 | + (entity_id, entity_id, entity_id), | |
| 325 | + ).fetchall() | |
| 326 | + out = [] | |
| 327 | + for r in rows: | |
| 328 | + outgoing = r["from_entity"] == entity_id | |
| 329 | + out.append({ | |
| 330 | + "id": r["oid"], "name": r["oname"], "type": r["otype"], | |
| 331 | + "region": r["oregion"], "sector": r["osector"], | |
| 332 | + "relation": r["relation_type"], "role": r["role"], | |
| 333 | + "direction": "out" if outgoing else "in", | |
| 334 | + "confidence": r["confidence"], | |
| 335 | + }) | |
| 336 | + return out | |
| 337 | + | |
| 338 | + # -- sources / file ---------------------------------------------------- | |
| 339 | + def record_source(self, url: str, title: str, content: str) -> None: | |
| 340 | + h = hashlib.sha256((content or "").encode("utf-8")).hexdigest() | |
| 341 | + self.conn.execute( | |
| 342 | + "INSERT OR REPLACE INTO sources(url, title, content_hash, scraped_at) VALUES(?,?,?,?)", | |
| 343 | + (url, title, h, time.time()), | |
| 344 | + ) | |
| 345 | + self.conn.commit() | |
| 346 | + | |
| 347 | + def source_seen(self, url: str) -> bool: | |
| 348 | + return self.conn.execute("SELECT 1 FROM sources WHERE url=?", (url,)).fetchone() is not None | |
| 349 | + | |
| 350 | + def enqueue(self, urls: Iterable[str], depth: int = 0) -> int: | |
| 351 | + n = 0 | |
| 352 | + for u in urls: | |
| 353 | + u = (u or "").strip() | |
| 354 | + if not u: | |
| 355 | + continue | |
| 356 | + n += self.conn.execute( | |
| 357 | + "INSERT OR IGNORE INTO crawl_queue(url, status, depth, added_at) VALUES(?,?,?,?)", | |
| 358 | + (u, "pending", depth, time.time()), | |
| 359 | + ).rowcount | |
| 360 | + self.conn.commit() | |
| 361 | + return n | |
| 362 | + | |
| 363 | + def next_pending(self) -> Optional[sqlite3.Row]: | |
| 364 | + return self.conn.execute( | |
| 365 | + "SELECT * FROM crawl_queue WHERE status='pending' ORDER BY depth, id LIMIT 1" | |
| 366 | + ).fetchone() | |
| 367 | + | |
| 368 | + def pending_batch(self, n: int, max_attempts: int = 99) -> list[sqlite3.Row]: | |
| 369 | + return self.conn.execute( | |
| 370 | + "SELECT * FROM crawl_queue WHERE status='pending' AND IFNULL(attempts,0) < ? " | |
| 371 | + "ORDER BY depth, id LIMIT ?", (max_attempts, max(1, n)), | |
| 372 | + ).fetchall() | |
| 373 | + | |
| 374 | + def mark(self, queue_id: int, status: str) -> None: | |
| 375 | + self.conn.execute("UPDATE crawl_queue SET status=? WHERE id=?", (status, queue_id)) | |
| 376 | + self.conn.commit() | |
| 377 | + | |
| 378 | + def bump_attempt(self, queue_id: int, max_attempts: int) -> None: | |
| 379 | + """Incrémente le compteur de tentatives ; passe en 'error' au-delà du seuil (dead-letter).""" | |
| 380 | + self.conn.execute( | |
| 381 | + "UPDATE crawl_queue SET attempts=IFNULL(attempts,0)+1, " | |
| 382 | + "status=CASE WHEN IFNULL(attempts,0)+1 >= ? THEN 'error' ELSE 'pending' END WHERE id=?", | |
| 383 | + (max_attempts, queue_id), | |
| 384 | + ) | |
| 385 | + self.conn.commit() | |
| 386 | + | |
| 387 | + # -- settings ---------------------------------------------------------- | |
| 388 | + def get_setting(self, key: str, default: str = "") -> str: | |
| 389 | + row = self.conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() | |
| 390 | + return row["value"] if row else default | |
| 391 | + | |
| 392 | + def set_setting(self, key: str, value: str) -> None: | |
| 393 | + self.conn.execute( | |
| 394 | + "INSERT INTO settings(key, value) VALUES(?,?) " | |
| 395 | + "ON CONFLICT(key) DO UPDATE SET value=excluded.value", | |
| 396 | + (key, value), | |
| 397 | + ) | |
| 398 | + self.conn.commit() | |
| 399 | + | |
| 400 | + # -- missions ---------------------------------------------------------- | |
| 401 | + def add_mission(self, goal: str, seed: str = "", sector: str = "", region: str = "", | |
| 402 | + priority: int = 5) -> int: | |
| 403 | + cur = self.conn.execute( | |
| 404 | + """INSERT OR IGNORE INTO missions(goal, seed, sector, region, priority, status, created_at) | |
| 405 | + VALUES(?,?,?,?,?, 'pending', ?)""", | |
| 406 | + (goal, seed or None, sector or None, region or None, priority, time.time()), | |
| 407 | + ) | |
| 408 | + self.conn.commit() | |
| 409 | + return cur.lastrowid or 0 | |
| 410 | + | |
| 411 | + def next_mission(self) -> Optional[sqlite3.Row]: | |
| 412 | + return self.conn.execute( | |
| 413 | + "SELECT * FROM missions WHERE status='pending' ORDER BY priority, last_run IS NOT NULL, " | |
| 414 | + "IFNULL(last_run, 0), id LIMIT 1" | |
| 415 | + ).fetchone() | |
| 416 | + | |
| 417 | + def due_mission(self, revisit_seconds: float) -> Optional[sqlite3.Row]: | |
| 418 | + threshold = time.time() - revisit_seconds | |
| 419 | + return self.conn.execute( | |
| 420 | + "SELECT * FROM missions WHERE status IN ('pending','done') " | |
| 421 | + "AND (last_run IS NULL OR last_run <= ?) " | |
| 422 | + "ORDER BY priority, IFNULL(last_run, 0), id LIMIT 1", | |
| 423 | + (threshold,), | |
| 424 | + ).fetchone() | |
| 425 | + | |
| 426 | + def set_mission_status(self, mission_id: int, status: str, bump_run: bool = False) -> None: | |
| 427 | + if bump_run: | |
| 428 | + self.conn.execute( | |
| 429 | + "UPDATE missions SET status=?, runs_count=runs_count+1, last_run=? WHERE id=?", | |
| 430 | + (status, time.time(), mission_id), | |
| 431 | + ) | |
| 432 | + else: | |
| 433 | + self.conn.execute("UPDATE missions SET status=? WHERE id=?", (status, mission_id)) | |
| 434 | + self.conn.commit() | |
| 435 | + | |
| 436 | + def list_missions(self) -> list[dict[str, Any]]: | |
| 437 | + return [dict(r) for r in self.conn.execute("SELECT * FROM missions ORDER BY priority, id")] | |
| 438 | + | |
| 439 | + def delete_mission(self, mission_id: int) -> None: | |
| 440 | + self.conn.execute("DELETE FROM missions WHERE id=?", (mission_id,)) | |
| 441 | + self.conn.commit() | |
| 442 | + | |
| 443 | + def prioritize_mission(self, mission_id: int) -> None: | |
| 444 | + """Passe la mission tout en haut de la file et la rend « due » immédiatement.""" | |
| 445 | + self.conn.execute( | |
| 446 | + "UPDATE missions SET priority=0, status='pending', last_run=NULL WHERE id=?", | |
| 447 | + (mission_id,), | |
| 448 | + ) | |
| 449 | + self.conn.commit() | |
| 450 | + | |
| 451 | + def next_forced(self) -> Optional[sqlite3.Row]: | |
| 452 | + """Mission « lancer maintenant » (priorité 0) à exécuter avant tout le reste.""" | |
| 453 | + return self.conn.execute( | |
| 454 | + "SELECT * FROM missions WHERE status='pending' AND priority <= 0 " | |
| 455 | + "ORDER BY IFNULL(last_run, 0), id LIMIT 1" | |
| 456 | + ).fetchone() | |
| 457 | + | |
| 458 | + def requeue_stale_missions(self) -> None: | |
| 459 | + self.conn.execute("UPDATE missions SET status='pending' WHERE status='running'") | |
| 460 | + self.conn.commit() | |
| 461 | + | |
| 462 | + def pages_since(self, since_ts: float) -> int: | |
| 463 | + return self.conn.execute( | |
| 464 | + "SELECT COUNT(*) n FROM sources WHERE scraped_at >= ?", (since_ts,) | |
| 465 | + ).fetchone()["n"] | |
| 466 | + | |
| 467 | + # -- événements -------------------------------------------------------- | |
| 468 | + def log_event(self, kind: str, message: str = "", url: str = "", data: Any = None) -> None: | |
| 469 | + self.conn.execute( | |
| 470 | + "INSERT INTO events(ts, kind, message, url, data_json) VALUES(?,?,?,?,?)", | |
| 471 | + (time.time(), kind, message, url or None, | |
| 472 | + json.dumps(data, ensure_ascii=False) if data is not None else None), | |
| 473 | + ) | |
| 474 | + self.conn.commit() | |
| 475 | + | |
| 476 | + def recent_events(self, limit: int = 100, after_id: int = 0) -> list[dict[str, Any]]: | |
| 477 | + rows = self.conn.execute( | |
| 478 | + "SELECT * FROM events WHERE id > ? ORDER BY id DESC LIMIT ?", (after_id, limit) | |
| 479 | + ).fetchall() | |
| 480 | + return [dict(r) for r in rows] | |
| 481 | + | |
| 482 | + # -- lecture / agrégats ------------------------------------------------ | |
| 483 | + def get_entity(self, entity_id: int) -> Optional[dict[str, Any]]: | |
| 484 | + e = self.conn.execute("SELECT * FROM entities WHERE id=?", (entity_id,)).fetchone() | |
| 485 | + if not e: | |
| 486 | + return None | |
| 487 | + d = dict(e) | |
| 488 | + d["social_links"] = [ | |
| 489 | + {"platform": r["platform"], "url": r["url"], "followers": r["followers"]} | |
| 490 | + for r in self.conn.execute( | |
| 491 | + "SELECT platform, url, followers FROM social_links WHERE entity_id=?", (entity_id,) | |
| 492 | + ) | |
| 493 | + ] | |
| 494 | + d["sources"] = [ | |
| 495 | + r["source_url"] for r in self.conn.execute( | |
| 496 | + "SELECT source_url FROM entity_mentions WHERE entity_id=? ORDER BY seen_at DESC LIMIT 15", | |
| 497 | + (entity_id,), | |
| 498 | + ) | |
| 499 | + ] | |
| 500 | + return d | |
| 501 | + | |
| 502 | + def distinct_locations(self) -> list[dict[str, Any]]: | |
| 503 | + rows = self.conn.execute( | |
| 504 | + "SELECT COALESCE(NULLIF(region,''), location) loc, COUNT(*) n FROM entities " | |
| 505 | + "WHERE COALESCE(NULLIF(region,''), location) IS NOT NULL " | |
| 506 | + "AND COALESCE(NULLIF(region,''), location)<>'' GROUP BY loc ORDER BY n DESC" | |
| 507 | + ).fetchall() | |
| 508 | + return [{"location": r["loc"], "count": r["n"]} for r in rows] | |
| 509 | + | |
| 510 | + def platform_counts(self) -> list[dict[str, Any]]: | |
| 511 | + rows = self.conn.execute( | |
| 512 | + "SELECT platform, COUNT(*) n FROM social_links GROUP BY platform ORDER BY n DESC" | |
| 513 | + ).fetchall() | |
| 514 | + return [{"platform": r["platform"], "count": r["n"]} for r in rows] | |
| 515 | + | |
| 516 | + def degrees(self) -> dict[int, int]: | |
| 517 | + deg: dict[int, int] = {} | |
| 518 | + for r in self.conn.execute("SELECT from_entity a, to_entity b FROM relations"): | |
| 519 | + deg[r["a"]] = deg.get(r["a"], 0) + 1 | |
| 520 | + deg[r["b"]] = deg.get(r["b"], 0) + 1 | |
| 521 | + return deg | |
| 522 | + | |
| 523 | + def top_connected(self, limit: int = 8) -> list[dict[str, Any]]: | |
| 524 | + deg = self.degrees() | |
| 525 | + if not deg: | |
| 526 | + return [] | |
| 527 | + top = sorted(deg.items(), key=lambda kv: kv[1], reverse=True)[:limit] | |
| 528 | + out = [] | |
| 529 | + for eid, d in top: | |
| 530 | + e = self.conn.execute( | |
| 531 | + "SELECT id, name, type, sector, region FROM entities WHERE id=?", (eid,) | |
| 532 | + ).fetchone() | |
| 533 | + if e: | |
| 534 | + out.append({**dict(e), "degree": d}) | |
| 535 | + return out | |
| 536 | + | |
| 537 | + def graph(self, limit: int = 140, etype: str = "", region: str = "", sector: str = "", | |
| 538 | + connected_only: bool = True) -> dict[str, Any]: | |
| 539 | + q = "SELECT id, name, type, sector, region, location FROM entities WHERE 1=1" | |
| 540 | + args: list[Any] = [] | |
| 541 | + if etype: | |
| 542 | + q += " AND type=?" | |
| 543 | + args.append(etype) | |
| 544 | + if region: | |
| 545 | + q += " AND COALESCE(NULLIF(region,''), location)=?" | |
| 546 | + args.append(region) | |
| 547 | + if sector: | |
| 548 | + q += " AND sector LIKE ?" | |
| 549 | + args.append(f"%{sector}%") | |
| 550 | + rows = self.conn.execute(q, args).fetchall() | |
| 551 | + deg = self.degrees() | |
| 552 | + if connected_only: | |
| 553 | + rows = [r for r in rows if deg.get(r["id"], 0) > 0] | |
| 554 | + rows = sorted(rows, key=lambda r: deg.get(r["id"], 0), reverse=True)[:limit] | |
| 555 | + ids = {r["id"] for r in rows} | |
| 556 | + nodes = [{"id": r["id"], "name": r["name"], "type": r["type"], | |
| 557 | + "degree": deg.get(r["id"], 0)} for r in rows] | |
| 558 | + edges = [] | |
| 559 | + for r in self.conn.execute( | |
| 560 | + "SELECT from_entity a, to_entity b, relation_type t, role FROM relations" | |
| 561 | + ): | |
| 562 | + if r["a"] in ids and r["b"] in ids: | |
| 563 | + edges.append({"s": r["a"], "t": r["b"], "type": r["t"], "role": r["role"]}) | |
| 564 | + return {"nodes": nodes, "edges": edges, "truncated": len(ids)} | |
| 565 | + | |
| 566 | + def sector_counts(self) -> list[dict[str, Any]]: | |
| 567 | + rows = self.conn.execute( | |
| 568 | + "SELECT sector, COUNT(*) n FROM entities WHERE sector IS NOT NULL AND sector<>'' " | |
| 569 | + "GROUP BY sector ORDER BY n DESC" | |
| 570 | + ).fetchall() | |
| 571 | + return [{"sector": r["sector"], "count": r["n"]} for r in rows] | |
| 572 | + | |
| 573 | + def stats(self) -> dict[str, Any]: | |
| 574 | + c = self.conn | |
| 575 | + by_type = {r["type"]: r["n"] for r in c.execute("SELECT type, COUNT(*) n FROM entities GROUP BY type")} | |
| 576 | + return { | |
| 577 | + "entities": c.execute("SELECT COUNT(*) n FROM entities").fetchone()["n"], | |
| 578 | + "by_type": by_type, | |
| 579 | + "relations": c.execute("SELECT COUNT(*) n FROM relations").fetchone()["n"], | |
| 580 | + "social_links": c.execute("SELECT COUNT(*) n FROM social_links").fetchone()["n"], | |
| 581 | + "sources": c.execute("SELECT COUNT(*) n FROM sources").fetchone()["n"], | |
| 582 | + "queue_pending": c.execute( | |
| 583 | + "SELECT COUNT(*) n FROM crawl_queue WHERE status='pending'" | |
| 584 | + ).fetchone()["n"], | |
| 585 | + } | |
| 586 | + | |
| 587 | + def export(self) -> list[dict[str, Any]]: | |
| 588 | + out = [] | |
| 589 | + for e in self.conn.execute("SELECT * FROM entities ORDER BY type, name"): | |
| 590 | + d = dict(e) | |
| 591 | + d["social_links"] = [ | |
| 592 | + {"platform": r["platform"], "url": r["url"], "followers": r["followers"]} | |
| 593 | + for r in self.conn.execute( | |
| 594 | + "SELECT platform, url, followers FROM social_links WHERE entity_id=?", (e["id"],) | |
| 595 | + ) | |
| 596 | + ] | |
| 597 | + out.append(d) | |
| 598 | + return out | |
| 599 | + | |
| 600 | + # -- archives (snapshot + reset + réutilisation) ----------------------- | |
| 601 | + def _snapshot(self) -> dict[str, Any]: | |
| 602 | + c = self.conn | |
| 603 | + return { | |
| 604 | + "entities": self.export(), | |
| 605 | + "relations": [dict(r) for r in c.execute( | |
| 606 | + "SELECT from_entity, to_entity, relation_type, role, source_url, confidence FROM relations")], | |
| 607 | + "missions": [dict(r) for r in c.execute( | |
| 608 | + "SELECT goal, seed, sector, region, priority FROM missions")], | |
| 609 | + "sources": [dict(r) for r in c.execute("SELECT url, title FROM sources")], | |
| 610 | + } | |
| 611 | + | |
| 612 | + def archive_and_reset(self, label: str) -> dict[str, Any]: | |
| 613 | + """Sauvegarde tout le graphe dans l'historique puis remet l'espace de travail à VIDE.""" | |
| 614 | + st = self.stats() | |
| 615 | + payload = json.dumps(self._snapshot(), ensure_ascii=False) | |
| 616 | + lab = (label or "").strip() or time.strftime("Archive %Y-%m-%d %H:%M", time.localtime()) | |
| 617 | + cur = self.conn.execute( | |
| 618 | + "INSERT INTO archives(label, created_at, entities, relations, sources, payload) VALUES(?,?,?,?,?,?)", | |
| 619 | + (lab, time.time(), st["entities"], st["relations"], st["sources"], payload), | |
| 620 | + ) | |
| 621 | + for t in ("relations", "social_links", "entity_mentions", "entities", "sources", | |
| 622 | + "crawl_queue", "events", "missions"): | |
| 623 | + self.conn.execute(f"DELETE FROM {t}") | |
| 624 | + self.conn.execute("DELETE FROM settings WHERE key IN ('active_mission_id','abort_current')") | |
| 625 | + self.conn.commit() | |
| 626 | + return {"id": cur.lastrowid, "label": lab, "entities": st["entities"], "relations": st["relations"]} | |
| 627 | + | |
| 628 | + def list_archives(self) -> list[dict[str, Any]]: | |
| 629 | + return [dict(r) for r in self.conn.execute( | |
| 630 | + "SELECT id, label, created_at, entities, relations, sources FROM archives ORDER BY id DESC")] | |
| 631 | + | |
| 632 | + def get_archive(self, archive_id: int) -> Optional[dict[str, Any]]: | |
| 633 | + r = self.conn.execute("SELECT * FROM archives WHERE id=?", (archive_id,)).fetchone() | |
| 634 | + if not r: | |
| 635 | + return None | |
| 636 | + d = dict(r) | |
| 637 | + d["data"] = json.loads(d.pop("payload") or "{}") | |
| 638 | + return d | |
| 639 | + | |
| 640 | + def delete_archive(self, archive_id: int) -> None: | |
| 641 | + self.conn.execute("DELETE FROM archives WHERE id=?", (archive_id,)) | |
| 642 | + self.conn.commit() | |
| 643 | + | |
| 644 | + def restore_archive(self, archive_id: int) -> dict[str, Any]: | |
| 645 | + """Recharge une archive dans l'espace de travail (remap des identifiants).""" | |
| 646 | + a = self.get_archive(archive_id) | |
| 647 | + if not a: | |
| 648 | + return {"ok": False} | |
| 649 | + data = a["data"] | |
| 650 | + idmap: dict[Any, int] = {} | |
| 651 | + for e in data.get("entities", []): | |
| 652 | + old = e.get("id") | |
| 653 | + nid = self.upsert_entity(dict(e)) | |
| 654 | + if old is not None: | |
| 655 | + idmap[old] = nid | |
| 656 | + rel = 0 | |
| 657 | + for r in data.get("relations", []): | |
| 658 | + f, t = idmap.get(r.get("from_entity")), idmap.get(r.get("to_entity")) | |
| 659 | + if f and t: | |
| 660 | + self.add_relation(f, t, r.get("relation_type", ""), r.get("role") or "", | |
| 661 | + r.get("source_url") or "", float(r.get("confidence") or 0.6)) | |
| 662 | + rel += 1 | |
| 663 | + for m in data.get("missions", []): | |
| 664 | + self.add_mission(m.get("goal", ""), m.get("seed") or "", m.get("sector") or "", | |
| 665 | + m.get("region") or "", int(m.get("priority") or 5)) | |
| 666 | + for s in data.get("sources", []): | |
| 667 | + self.record_source(s.get("url", ""), s.get("title") or "", "") | |
| 668 | + return {"ok": True, "entities": len(idmap), "relations": rel} | |
| 669 | + | |
| 670 | + def close(self) -> None: | |
| 671 | + self.conn.close() | |
added
src/util.py
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Petits utilitaires partagés.""" | |
| 4 | + | |
| 5 | +from __future__ import annotations | |
| 6 | + | |
| 7 | +import time | |
| 8 | + | |
| 9 | + | |
| 10 | +class RateLimiter: | |
| 11 | + """Impose un délai minimal entre deux requêtes (politesse serveur).""" | |
| 12 | + | |
| 13 | + def __init__(self, delay: float): | |
| 14 | + self.delay = max(0.0, delay) | |
| 15 | + self._last = 0.0 | |
| 16 | + | |
| 17 | + def wait(self) -> None: | |
| 18 | + elapsed = time.time() - self._last | |
| 19 | + if elapsed < self.delay: | |
| 20 | + time.sleep(self.delay - elapsed) | |
| 21 | + self._last = time.time() | |
added
web/__init__.py
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""Plateforme web de suivi de ka2.""" | |
added
web/app.py
+1335 −0
@@ -0,0 +1,1335 @@ | ||
| 1 | +# Author: Simon-Pierre Boucher | |
| 2 | +# Contact: contact@spboucher.ai | |
| 3 | +"""API + dashboard de suivi de ka2 (FastAPI). | |
| 4 | + | |
| 5 | +Interface alignée sur le système de design du Groupe KA (néo-brutalisme éditorial : | |
| 6 | +papier chaud, encre, accent lime, bordures nettes, ombres dures décalées). | |
| 7 | +Temps réel via SSE, réglages personnalisables (backend, budget, session, accent). | |
| 8 | +Le bot de crawl tourne dans un processus séparé partageant la même base SQLite (WAL). | |
| 9 | +""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import asyncio | |
| 14 | +import json | |
| 15 | +import time | |
| 16 | +from typing import Optional | |
| 17 | + | |
| 18 | +from fastapi import FastAPI, Request | |
| 19 | +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse | |
| 20 | + | |
| 21 | +from src.config import config | |
| 22 | +from src.storage import Store | |
| 23 | + | |
| 24 | +app = FastAPI(title="ka2 — cartographe du web québécois", version="2.0.0") | |
| 25 | + | |
| 26 | +SESSION_SECONDS = 15 * 60 # bloc de session (bouton « Continuer ») | |
| 27 | + | |
| 28 | +# Réglages personnalisables : nom public -> (clé en base, défaut effectif, type) | |
| 29 | +CONFIG_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 | +} | |
| 39 | + | |
| 40 | + | |
| 41 | +def store() -> Store: | |
| 42 | + return Store(config.db_path) | |
| 43 | + | |
| 44 | + | |
| 45 | +def _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 u | |
| 57 | + | |
| 58 | + | |
| 59 | +def _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.0 | |
| 64 | + remaining = max(0, int(run_until - time.time())) | |
| 65 | + return {"run_until": run_until, "session_remaining": remaining, "session_active": remaining > 0} | |
| 66 | + | |
| 67 | + | |
| 68 | +@app.get("/health") | |
| 69 | +def health(): | |
| 70 | + return {"status": "ok", "service": "ka2"} | |
| 71 | + | |
| 72 | + | |
| 73 | +@app.get("/api/stats") | |
| 74 | +def 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 data | |
| 82 | + finally: | |
| 83 | + s.close() | |
| 84 | + | |
| 85 | + | |
| 86 | +@app.get("/api/events") | |
| 87 | +def 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() | |
| 93 | + | |
| 94 | + | |
| 95 | +def _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 rows | |
| 116 | + | |
| 117 | + | |
| 118 | +@app.get("/api/entities") | |
| 119 | +def 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() | |
| 135 | + | |
| 136 | + | |
| 137 | +@app.get("/api/export.json") | |
| 138 | +def 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 _json | |
| 146 | + return StreamingResponse( | |
| 147 | + iter([_json.dumps(rows, ensure_ascii=False, indent=2)]), | |
| 148 | + media_type="application/json", | |
| 149 | + headers={"Content-Disposition": "attachment; filename=ka2-entites.json"}, | |
| 150 | + ) | |
| 151 | + | |
| 152 | + | |
| 153 | +@app.get("/api/export.csv") | |
| 154 | +def 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 _csv | |
| 162 | + import io as _io | |
| 163 | + 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=ka2-entites.csv"}, | |
| 173 | + ) | |
| 174 | + | |
| 175 | + | |
| 176 | +@app.get("/api/explore/summary") | |
| 177 | +def 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() | |
| 192 | + | |
| 193 | + | |
| 194 | +@app.get("/api/graph") | |
| 195 | +def 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() | |
| 203 | + | |
| 204 | + | |
| 205 | +@app.get("/api/entity/{entity_id}") | |
| 206 | +def 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 graphe | |
| 214 | + e["relations"] = s.entity_relations(entity_id) | |
| 215 | + # Contexte secondaire : autres entités de la même région | |
| 216 | + 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 | + continue | |
| 223 | + 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 e | |
| 229 | + | |
| 230 | + | |
| 231 | +@app.get("/api/missions") | |
| 232 | +def api_missions(): | |
| 233 | + s = store() | |
| 234 | + try: | |
| 235 | + return {"missions": s.list_missions()} | |
| 236 | + finally: | |
| 237 | + s.close() | |
| 238 | + | |
| 239 | + | |
| 240 | +@app.post("/api/missions") | |
| 241 | +def 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() | |
| 254 | + | |
| 255 | + | |
| 256 | +@app.post("/api/missions/{mission_id}/{op}") | |
| 257 | +def 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 cours | |
| 265 | + 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() | |
| 274 | + | |
| 275 | + | |
| 276 | +@app.post("/api/archive") | |
| 277 | +def archive_now(payload: dict): | |
| 278 | + s = store() | |
| 279 | + try: | |
| 280 | + # 1) stopper l'activité en cours (pause + interruption) pour éviter la course | |
| 281 | + 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 vider | |
| 284 | + for _ in range(40): | |
| 285 | + if s.get_setting("bot_busy", "0") != "1": | |
| 286 | + break | |
| 287 | + time.sleep(0.5) | |
| 288 | + # 3) snapshot + reset | |
| 289 | + 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 res | |
| 292 | + finally: | |
| 293 | + s.close() | |
| 294 | + | |
| 295 | + | |
| 296 | +@app.get("/api/archives") | |
| 297 | +def api_archives(): | |
| 298 | + s = store() | |
| 299 | + try: | |
| 300 | + return {"archives": s.list_archives()} | |
| 301 | + finally: | |
| 302 | + s.close() | |
| 303 | + | |
| 304 | + | |
| 305 | +@app.post("/api/archive/{archive_id}/{op}") | |
| 306 | +def 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() | |
| 317 | + | |
| 318 | + | |
| 319 | +@app.get("/api/archive/{archive_id}/export.json") | |
| 320 | +def 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 _json | |
| 329 | + 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 | + ) | |
| 334 | + | |
| 335 | + | |
| 336 | +@app.post("/api/control/{action}") | |
| 337 | +def 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 suite | |
| 346 | + 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.0 | |
| 353 | + 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 out | |
| 363 | + finally: | |
| 364 | + s.close() | |
| 365 | + | |
| 366 | + | |
| 367 | +@app.get("/api/config") | |
| 368 | +def 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 default | |
| 376 | + except ValueError: | |
| 377 | + out[name] = default | |
| 378 | + return out | |
| 379 | + finally: | |
| 380 | + s.close() | |
| 381 | + | |
| 382 | + | |
| 383 | +@app.post("/api/config") | |
| 384 | +def 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 | + continue | |
| 391 | + key, _default, cast = CONFIG_KEYS[name] | |
| 392 | + try: | |
| 393 | + val = cast(value) | |
| 394 | + except (ValueError, TypeError): | |
| 395 | + continue | |
| 396 | + s.set_setting(key, str(val)) | |
| 397 | + applied[name] = val | |
| 398 | + 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() | |
| 403 | + | |
| 404 | + | |
| 405 | +@app.get("/api/stream") | |
| 406 | +async def stream(request: Request): | |
| 407 | + """Flux SSE : pousse stats + nouveaux événements (remplace le polling lourd).""" | |
| 408 | + | |
| 409 | + async def gen(): | |
| 410 | + last_id = 0 | |
| 411 | + while True: | |
| 412 | + if await request.is_disconnected(): | |
| 413 | + break | |
| 414 | + 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) | |
| 427 | + | |
| 428 | + return StreamingResponse( | |
| 429 | + gen(), | |
| 430 | + media_type="text/event-stream", | |
| 431 | + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}, | |
| 432 | + ) | |
| 433 | + | |
| 434 | + | |
| 435 | +@app.get("/ka2.css") | |
| 436 | +def ka2_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"}) | |
| 441 | + | |
| 442 | + | |
| 443 | +@app.get("/", response_class=HTMLResponse) | |
| 444 | +def index(): | |
| 445 | + return HTML | |
| 446 | + | |
| 447 | + | |
| 448 | +@app.get("/explore", response_class=HTMLResponse) | |
| 449 | +def explore(): | |
| 450 | + return EXPLORE_HTML | |
| 451 | + | |
| 452 | + | |
| 453 | +# ========================================================================= | |
| 454 | +# Dashboard mono-fichier — design system Groupe KA (néo-brutalisme éditorial) | |
| 455 | +# ========================================================================= | |
| 456 | +HTML = 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>ka2 · 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='%23d9f26b' text-anchor='middle' dominant-baseline='central'%3Ek2%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:#1c5c41;--green-deep:#123f2e; | |
| 471 | + --lime:#d9f26b;--lime-soft:#f0f9d2; | |
| 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} | |
| 482 | +html,body{margin:0} | |
| 483 | +body{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} | |
| 485 | +body::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)} | |
| 488 | +a{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} | |
| 490 | +h1,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)} | |
| 495 | + | |
| 496 | +/* Header */ | |
| 497 | +header{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} | |
| 510 | + | |
| 511 | +/* 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} | |
| 526 | + | |
| 527 | +/* 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%)}} | |
| 533 | + | |
| 534 | +main{padding:26px 0 60px} | |
| 535 | + | |
| 536 | +/* 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))} | |
| 544 | + | |
| 545 | +/* 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} | |
| 551 | + | |
| 552 | +/* 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)} | |
| 566 | + | |
| 567 | +/* Tableaux */ | |
| 568 | +.tblwrap{overflow:auto;max-height:56vh} | |
| 569 | +table{width:100%;border-collapse:collapse;font-size:13.5px} | |
| 570 | +th,td{text-align:left;padding:11px 16px;vertical-align:top} | |
| 571 | +th{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} | |
| 573 | +td{border-bottom:1px solid var(--line)} | |
| 574 | +tr: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)} | |
| 585 | + | |
| 586 | +/* 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} | |
| 589 | +input,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)} | |
| 591 | +input:focus,select:focus{outline:2px solid var(--green);outline-offset:1px;box-shadow:3px 3px 0 rgba(28,92,65,.25)} | |
| 592 | +input::placeholder{color:var(--ink-3)} | |
| 593 | + | |
| 594 | +/* 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} | |
| 612 | + | |
| 613 | +footer{border-top:2px solid var(--ink);background:var(--ink);color:rgba(245,243,238,.75);margin-top:20px;position:relative;z-index:1} | |
| 614 | +footer .wrap{padding:26px 24px;font-family:var(--font-mono);font-size:11px;line-height:1.9;text-transform:uppercase;letter-spacing:.06em} | |
| 615 | +footer b{color:var(--lime)} | |
| 616 | +footer .lk{color:var(--lime)} | |
| 617 | + | |
| 618 | +/* 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> | |
| 649 | + | |
| 650 | +<header> | |
| 651 | + <div class="wrap hdr"> | |
| 652 | + <a class="brand" href="/"> | |
| 653 | + <div class="logo">k2</div> | |
| 654 | + <div> | |
| 655 | + <div class="wordmark"><span class="kn">ka</span><span class="kx">2</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> | |
| 672 | + | |
| 673 | +<main class="wrap"> | |
| 674 | + <div class="kpis" id="kpis"></div> | |
| 675 | + | |
| 676 | + <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> | |
| 681 | + | |
| 682 | + <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> | |
| 696 | + | |
| 697 | + <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> | |
| 709 | + | |
| 710 | + <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> | |
| 715 | + | |
| 716 | +<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> | |
| 720 | + | |
| 721 | +<footer><div class="wrap"> | |
| 722 | + <b>ka·2</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 noire | |
| 725 | +</div></footer> | |
| 726 | + | |
| 727 | +<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> | |
| 755 | + | |
| 756 | +<script> | |
| 757 | +const ACCENTS=[["#d9f26b","lime · défaut"],["#1c5c41","vert"],["#0ea5e9","cyan"],["#e23744","rouge"],["#f59e0b","ambre"],["#7c3aed","violet"]]; | |
| 758 | +const $=id=>document.getElementById(id); | |
| 759 | +const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); | |
| 760 | +const hhmm=ts=>new Date(ts*1000).toLocaleTimeString('fr-CA',{hour12:false,hour:'2-digit',minute:'2-digit',second:'2-digit'}); | |
| 761 | +const fmt=n=>(n||0).toLocaleString('fr-CA'); | |
| 762 | +const mmss=s=>{s=Math.max(0,s|0);return String((s/60)|0).padStart(2,'0')+':'+String(s%60).padStart(2,'0')}; | |
| 763 | +async function j(u,o){const r=await fetch(u,o);return r.json()} | |
| 764 | +let remaining=0,lastId=0,lastEntCount=-1,feedEls=0,activeMid=''; | |
| 765 | + | |
| 766 | +/* Accent personnalisable */ | |
| 767 | +function 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('ka2-accent',hex); | |
| 771 | + document.querySelectorAll('.sw').forEach(e=>e.classList.toggle('sel',e.dataset.hex===hex)); | |
| 772 | +} | |
| 773 | +function 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('ka2-accent')||ACCENTS[0][0]); | |
| 776 | +} | |
| 777 | + | |
| 778 | +/* KPIs + ticker + état (depuis le flux) */ | |
| 779 | +const 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)']]; | |
| 782 | +function 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 | +} | |
| 808 | +function 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 | +} | |
| 814 | +function 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 | +} | |
| 825 | + | |
| 826 | +/* Entités + missions (rafraîchis quand utile) */ | |
| 827 | +let entTimer=null; | |
| 828 | +async 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 | +} | |
| 841 | +async 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 | +} | |
| 855 | +async 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 | +} | |
| 860 | +async 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 | +} | |
| 866 | +async function ctl(a){const r=await j('/api/control/'+a,{method:'POST'});if(r.session_remaining!=null){remaining=r.session_remaining;renderTimer();}} | |
| 867 | + | |
| 868 | +/* Archives : archiver+vider, historique, restaurer/exporter/supprimer */ | |
| 869 | +async 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 | +} | |
| 877 | +async 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 | +} | |
| 890 | +async 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 | +} | |
| 897 | + | |
| 898 | +/* Réglages */ | |
| 899 | +function openDrawer(){$('drawer').classList.add('open');$('scrim').classList.add('open');loadConfig();} | |
| 900 | +function closeDrawer(){$('drawer').classList.remove('open');$('scrim').classList.remove('open');} | |
| 901 | +async 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 | +} | |
| 907 | +async 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 | +} | |
| 914 | + | |
| 915 | +/* Recherche debouncée */ | |
| 916 | +$('fq').addEventListener('input',()=>{clearTimeout(entTimer);entTimer=setTimeout(loadEntities,300);}); | |
| 917 | + | |
| 918 | +/* Temps réel : SSE, repli sur polling si indisponible */ | |
| 919 | +function 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 | +} | |
| 926 | +function 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 | +} | |
| 931 | +buildSwatches();loadMissions();loadArchives();startSSE(); | |
| 932 | +setInterval(loadMissions,20000);setInterval(loadArchives,30000); | |
| 933 | +setInterval(()=>{if(remaining>0){remaining--;renderTimer();}},1000); | |
| 934 | +</script></body></html>""" | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | +# ========================================================================= | |
| 940 | +# Page « Répertoire structuré » v3 — 3 vues (cartes / tableau / graphe global) | |
| 941 | +# + facettes (type, secteur, région), tri, insights, export CSV/JSON. | |
| 942 | +# ========================================================================= | |
| 943 | +EXPLORE_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>ka2 · 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='%23d9f26b' text-anchor='middle' dominant-baseline='central'%3Ek2%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="/ka2.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> | |
| 1033 | + | |
| 1034 | +<header> | |
| 1035 | + <div class="wrap hdr"> | |
| 1036 | + <a class="brand" href="/"> | |
| 1037 | + <div class="logo">k2</div> | |
| 1038 | + <div><div class="wordmark"><span class="kn">ka</span><span class="kx">2</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> | |
| 1044 | + | |
| 1045 | +<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> | |
| 1051 | + | |
| 1052 | + <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> | |
| 1065 | + | |
| 1066 | + <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> | |
| 1081 | + | |
| 1082 | + <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:#1c5c41"></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> | |
| 1105 | + | |
| 1106 | +<footer><div class="wrap"> | |
| 1107 | + <b>ka·2</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) / LPRPDE | |
| 1110 | +</div></footer> | |
| 1111 | + | |
| 1112 | +<div class="scrim" id="scrim" onclick="closeD()"></div> | |
| 1113 | +<aside class="drawer wide" id="drawer"><div class="det" id="det"></div></aside> | |
| 1114 | + | |
| 1115 | +<script> | |
| 1116 | +const $=id=>document.getElementById(id); | |
| 1117 | +const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); | |
| 1118 | +const fmt=n=>(n||0).toLocaleString('fr-CA'); | |
| 1119 | +const TYPES={creator:'Créateur',agency:'Agence',brand:'Marque',business:'Entreprise',organization:'Organisation',person:'Personne',website:'Site web'}; | |
| 1120 | +const 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||'')}; | |
| 1121 | +const 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 à'}; | |
| 1124 | +const COL={business:'#141814',organization:'#e8a33d',person:'#1c5c41',website:'#8b928c'}; | |
| 1125 | +const ini=n=>{const t=(n||'?').trim();return (t[0]||'?').toUpperCase()+(t.split(' ')[1]?t.split(' ')[1][0].toUpperCase():'')}; | |
| 1126 | +const _dom=u=>{try{return new URL(u).hostname.replace(/^www\./,'')}catch(e){return ''}}; | |
| 1127 | +async function j(u){const r=await fetch(u);return r.json()} | |
| 1128 | +let state={view:'cards',type:'',sector:'',region:'',q:'',sort:'degree'},items=[],offset=0,total=0,tmr=null; | |
| 1129 | +const LIMIT=60; | |
| 1130 | + | |
| 1131 | +function 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();} | |
| 1134 | + | |
| 1135 | +/* Résumé / facettes / insights */ | |
| 1136 | +async 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 | +} | |
| 1148 | +function 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 | +} | |
| 1153 | +function setFacet(key,val){state[key]=(state[key]===val)?'':val;loadSummary();reload();} | |
| 1154 | +function resetFilters(){state.type=state.sector=state.region=state.q='';$('fq').value='';loadSummary();reload();} | |
| 1155 | + | |
| 1156 | +/* Vues */ | |
| 1157 | +function 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 | +} | |
| 1165 | +function updateExport(){const q=qs();$('expCsv').href='/api/export.csv?'+q;$('expJson').href='/api/export.json?'+q;} | |
| 1166 | + | |
| 1167 | +async function reload(){updateExport();offset=0;items=[]; | |
| 1168 | + if(state.view==='graph'){loadGraph();return;} | |
| 1169 | + await loadPage();} | |
| 1170 | +async 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 | +} | |
| 1177 | +function loadMore(){loadPage();} | |
| 1178 | +function render(){state.view==='table'?renderTable():renderCards();} | |
| 1179 | + | |
| 1180 | +function 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 | +} | |
| 1188 | +function 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 | +} | |
| 1203 | +function th(key,lab){return `<th class="${state.sort===key?'on':''}" onclick="state.sort='${key}';reload()">${lab}</th>`} | |
| 1204 | +function 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 | +} | |
| 1215 | + | |
| 1216 | +/* ---------- Vue GRAPHE GLOBAL (force-directed) ---------- */ | |
| 1217 | +let gt={x:0,y:0,s:1},NODES=[],POS={}; | |
| 1218 | +async 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 | +} | |
| 1236 | +function 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 | +} | |
| 1254 | +function applyT(){$('gwrap').setAttribute('transform',`translate(${gt.x},${gt.y}) scale(${gt.s})`);} | |
| 1255 | +function zoom(f){gt.s=Math.max(.3,Math.min(4,gt.s*f));applyT();} | |
| 1256 | +function 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 | +})(); | |
| 1278 | + | |
| 1279 | +/* ---------- Détail (drawer) + ego-graph ---------- */ | |
| 1280 | +function chip(url,label,stop){return `<a class="chip" href="${esc(url)}" target="_blank" rel="noopener"${stop?' onclick="event.stopPropagation()"':''}>${esc(label)}</a>`} | |
| 1281 | +function 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="#1c5c41" text-anchor="middle">${esc(lab)}</text>`;} | |
| 1292 | + const fill=n.type==='person'?'#d9f26b':'#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="#d9f26b" 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 | +} | |
| 1300 | +async 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 | +} | |
| 1329 | +function closeD(){$('drawer').classList.remove('open');$('scrim').classList.remove('open');} | |
| 1330 | + | |
| 1331 | +$('fq').addEventListener('input',e=>{clearTimeout(tmr);state.q=e.target.value;tmr=setTimeout(reload,300);}); | |
| 1332 | +document.addEventListener('keydown',e=>{if(e.key==='Escape')closeD();}); | |
| 1333 | +$('fsort').value=state.sort; | |
| 1334 | +loadSummary();updateExport();reload(); | |
| 1335 | +</script></body></html>""" | |
| 1336 | ||