spb/ka4 Public
ka4 — explorateur structuré du web québécois (édition agressive, Groupe KA). Crawling concurrent, Claude Sonnet+Haiku, Firecrawl/Scrapfly, graphe de connaissances.
Python 97.8%
Shell 2.2%
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3"""Bot LONG TERME : boucle persistante, reprise sur incident, budget quotidien.45Cycle :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).9Respecte un budget de pages/jour et s'arrête proprement (SIGINT/SIGTERM).10L'état vit dans SQLite : le bot peut être tué et redémarré sans rien perdre.11"""1213from __future__ import annotations1415import signal16import time17from typing import Optional1819from .config import Config20from .orchestrator import Orchestrator2122# Amorçage FLAGSHIP ka6 : découverte des CRÉATEURS / INFLUENCEURS québécois.23NICHES = [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]29PLATFORMS = ["TikTok", "Instagram", "YouTube", "Twitch"]30# Requêtes annuaire/agences (missions à haute densité de créateurs)31DIRECTORY_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]39DEFAULT_REGIONS = ["Québec"] # objectif initial : tout le Québec404142class 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 = cfg48 self.cycle_delay = cycle_delay49 self.queue_batch = queue_batch50 self.daily_page_budget = daily_page_budget51 self.revisit_seconds = revisit_hours * 360052 self.session_minutes = session_minutes53 self._stop = False54 self._idle_logged = False5556 def _install_signals(self) -> None:57 def handler(signum, _frame):58 self._stop = True59 for sig in (signal.SIGINT, signal.SIGTERM):60 try:61 signal.signal(sig, handler)62 except Exception:63 pass6465 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 missions67 # ajoutées via le dashboard. Activer avec KA_BOT_AUTOSEED=1 pour réamorcer.68 import os69 if os.getenv("KA_BOT_AUTOSEED", "0").strip().lower() not in ("1", "true", "yes", "on"):70 return 071 if orch.store.list_missions():72 return 073 n = 074 # 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 += 178 # 2) niche × plateforme79 for niche in NICHES:80 for platform in PLATFORMS:81 if n >= limit:82 break83 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 += 188 orch.store.log_event("info", f"{n} missions créateurs/influenceurs initiales créées.")89 return n9091 def _apply_live_settings(self, orch: Orchestrator) -> None:92 """Réglages modifiables en direct depuis le dashboard (table settings)."""93 s = orch.store94 def num(key, cur, cast):95 v = s.get_setting(key, "")96 try:97 return cast(v) if v != "" else cur98 except ValueError:99 return cur100 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 scraper106 backend = s.get_setting("cfg_backend", "")107 if backend in ("auto", "firecrawl", "scrapfly"):108 orch.fc.backend = backend109 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)117118 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))121122 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)127128 # 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é.")134135 cycles = 0136 try:137 while not self._stop:138 if max_cycles is not None and cycles >= max_cycles:139 break140 cycles += 1141 self._apply_live_settings(orch)142 orch.store.set_setting("bot_busy", "0") # au repos par défaut (heartbeat)143144 # Pause pilotée depuis le dashboard145 if orch.store.get_setting("paused", "0") == "1":146 self._sleep(self.cycle_delay)147 continue148149 # 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.0154 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 = True160 self._sleep(self.cycle_delay)161 continue162 self._idle_logged = False163164 # Budget quotidien165 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 continue170171 orch._pages_scraped = 0 # réinitialise la limite par cycle172 # STOP = arrêt demandé (skip/archive) OU pause : interrompt le travail EN COURS173 stop_fn = lambda: (orch.store.get_setting("abort_current", "0") == "1"174 or orch.store.get_setting("paused", "0") == "1")175176 # 0) mission FORCÉE ("lancer maintenant", priorité 0) : passe avant tout177 mission = orch.store.next_forced()178179 # 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 continue188189 # 2) sinon, mission due190 if mission is None:191 mission = orch.store.due_mission(self.revisit_seconds)192 orch.store.set_setting("abort_current", "0") # nouvelle activité → drapeau propre193 if mission is None:194 orch.store.log_event("info", "Aucune mission due — veille.")195 self._sleep(min(1800, self.cycle_delay * 15))196 continue197198 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: BLE001209 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()219220 def _sleep(self, seconds: float) -> None:221 end = time.time() + seconds222 while time.time() < end and not self._stop:223 time.sleep(min(1.0, end - time.time()))224