# Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Bot LONG TERME : boucle persistante, reprise sur incident, budget quotidien. Cycle : 1. Draine une partie de la file d'URLs découvertes (exploration en largeur). 2. Sinon, prend la prochaine mission « due » et laisse l'agent Haiku la mener. 3. Revisite périodique des missions (crawl perpétuel). Respecte un budget de pages/jour et s'arrête proprement (SIGINT/SIGTERM). L'état vit dans SQLite : le bot peut être tué et redémarré sans rien perdre. """ from __future__ import annotations import signal import time from typing import Optional from .config import Config from .orchestrator import Orchestrator # Amorçage FLAGSHIP ka6 : découverte des CRÉATEURS / INFLUENCEURS québécois. NICHES = [ "mode", "beauté", "maquillage", "gaming", "cuisine", "humour", "lifestyle", "sport", "fitness", "techno", "voyage", "famille et parentalité", "musique", "art et illustration", "décoration et rénovation", "finances personnelles", "plein air", "food et restaurants", "danse", "actualité et politique", ] PLATFORMS = ["TikTok", "Instagram", "YouTube", "Twitch"] # Requêtes annuaire/agences (missions à haute densité de créateurs) DIRECTORY_MISSIONS = [ "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).", "Répertorier les palmarès et listes des meilleurs influenceurs et créateurs de contenu québécois (tous réseaux).", "Découvrir les créateurs de contenu franco-québécois sur YouTube : chaînes, abonnés, thématique, collaborations.", "Découvrir les influenceurs québécois sur TikTok par niche : handle, abonnés, marques partenaires.", "Cartographier les plateformes et collectifs de créateurs au Québec (réseaux, MCN, coopératives de contenu).", "Identifier les marques québécoises qui font du marketing d'influence et leurs créateurs ambassadeurs.", ] DEFAULT_REGIONS = ["Québec"] # objectif initial : tout le Québec class LongRunner: # ka6 flagship : cadence très agressive et budget élevé def __init__(self, cfg: Config, cycle_delay: float = 6.0, queue_batch: int = 40, daily_page_budget: int = 9000, revisit_hours: float = 36.0, session_minutes: float = 15.0): self.cfg = cfg self.cycle_delay = cycle_delay self.queue_batch = queue_batch self.daily_page_budget = daily_page_budget self.revisit_seconds = revisit_hours * 3600 self.session_minutes = session_minutes self._stop = False self._idle_logged = False def _install_signals(self) -> None: def handler(signum, _frame): self._stop = True for sig in (signal.SIGINT, signal.SIGTERM): try: signal.signal(sig, handler) except Exception: pass def seed_missions(self, orch: Orchestrator, limit: int = 120) -> int: # Pré-remplissage désactivé par défaut : le bot démarre VIDE et attend des missions # ajoutées via le dashboard. Activer avec KA_BOT_AUTOSEED=1 pour réamorcer. import os if os.getenv("KA_BOT_AUTOSEED", "0").strip().lower() not in ("1", "true", "yes", "on"): return 0 if orch.store.list_missions(): return 0 n = 0 # 1) missions annuaires/agences (haute priorité — riches en créateurs) for goal in DIRECTORY_MISSIONS: orch.store.add_mission(goal=goal, sector="influence", region="Québec", priority=1) n += 1 # 2) niche × plateforme for niche in NICHES: for platform in PLATFORMS: if n >= limit: break goal = (f"Découvrir les créateurs de contenu et influenceurs québécois en « {niche} » " f"sur {platform} : nom, handle, plateformes, nombre d'abonnés, niche, langues, " f"agence qui les représente et marques avec qui ils collaborent.") orch.store.add_mission(goal=goal, sector=niche, region=platform, priority=3) n += 1 orch.store.log_event("info", f"{n} missions créateurs/influenceurs initiales créées.") return n def _apply_live_settings(self, orch: Orchestrator) -> None: """Réglages modifiables en direct depuis le dashboard (table settings).""" s = orch.store def num(key, cur, cast): v = s.get_setting(key, "") try: return cast(v) if v != "" else cur except ValueError: return cur self.daily_page_budget = num("cfg_daily_budget", self.daily_page_budget, int) self.cycle_delay = num("cfg_cycle_delay", self.cycle_delay, float) self.queue_batch = num("cfg_queue_batch", self.queue_batch, int) self.session_minutes = num("cfg_session_minutes", self.session_minutes, float) orch.cfg.max_pages_per_run = num("cfg_max_pages", orch.cfg.max_pages_per_run, int) # backend + robots + délai appliqués en direct au scraper backend = s.get_setting("cfg_backend", "") if backend in ("auto", "firecrawl", "scrapfly"): orch.fc.backend = backend robots = s.get_setting("cfg_respect_robots", "") if robots in ("0", "1"): orch.fc.robots.enabled = robots == "1" delay = num("cfg_delay", None, float) if delay is not None: orch.fc.firecrawl.limiter.delay = max(0.0, delay) if orch.fc.scrapfly: orch.fc.scrapfly.limiter.delay = max(0.0, delay) def _start_of_day(self) -> float: lt = time.localtime() return time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, 0, 0, -1)) def run(self, max_cycles: Optional[int] = None) -> None: self._install_signals() orch = Orchestrator(self.cfg) orch.store.requeue_stale_missions() self.seed_missions(orch) # Fenêtre de session : dès le démarrage, le bot travaille `session_minutes`. # Le bouton « Continuer » du dashboard prolonge run_until (voir web/app.py). if not orch.store.get_setting("run_until", ""): orch.store.set_setting("run_until", str(time.time() + self.session_minutes * 60)) orch.store.log_event("info", f"Session de {int(self.session_minutes)} min démarrée dès le déploiement.") orch.store.log_event("info", "ka6 démarré.") cycles = 0 try: while not self._stop: if max_cycles is not None and cycles >= max_cycles: break cycles += 1 self._apply_live_settings(orch) orch.store.set_setting("bot_busy", "0") # au repos par défaut (heartbeat) # Pause pilotée depuis le dashboard if orch.store.get_setting("paused", "0") == "1": self._sleep(self.cycle_delay) continue # Fenêtre de session : au-delà de run_until, on attend « Continuer » try: run_until = float(orch.store.get_setting("run_until", "0") or 0) except ValueError: run_until = 0.0 if time.time() >= run_until: if not self._idle_logged: orch.store.log_event( "info", "Session écoulée — cliquez « Continuer » pour 15 min de plus." ) self._idle_logged = True self._sleep(self.cycle_delay) continue self._idle_logged = False # Budget quotidien done_today = orch.store.pages_since(self._start_of_day()) if done_today >= self.daily_page_budget: orch.store.log_event("info", f"Budget quotidien atteint ({done_today}). Pause.") self._sleep(min(1800, self.cycle_delay * 30)) continue orch._pages_scraped = 0 # réinitialise la limite par cycle # STOP = arrêt demandé (skip/archive) OU pause : interrompt le travail EN COURS stop_fn = lambda: (orch.store.get_setting("abort_current", "0") == "1" or orch.store.get_setting("paused", "0") == "1") # 0) mission FORCÉE ("lancer maintenant", priorité 0) : passe avant tout mission = orch.store.next_forced() # 1) sinon, drainer la file découverte (interruptible) if mission is None and orch.store.next_pending() is not None and not stop_fn(): orch.store.set_setting("bot_busy", "1") res = orch.drain_queue(max_pages=self.queue_batch, should_abort=stop_fn) orch.store.set_setting("bot_busy", "0") orch.store.log_event("info", f"File drainée: {res['processed']} page(s).") orch.store.set_setting("abort_current", "0") self._sleep(self.cycle_delay) continue # 2) sinon, mission due if mission is None: mission = orch.store.due_mission(self.revisit_seconds) orch.store.set_setting("abort_current", "0") # nouvelle activité → drapeau propre if mission is None: orch.store.log_event("info", "Aucune mission due — veille.") self._sleep(min(1800, self.cycle_delay * 15)) continue mid = mission["id"] orch.store.set_mission_status(mid, "running") orch.store.set_setting("active_mission_id", str(mid)) orch.store.set_setting("bot_busy", "1") orch.store.log_event("info", f"Mission #{mid} démarrée: {mission['goal'][:120]}") try: summary = orch.run_agent(mission["goal"], mission["seed"], max_steps=40, should_abort=stop_fn) st = "pending" if summary == "Interrompu par l'utilisateur." else "done" orch.store.set_mission_status(mid, st, bump_run=True) except Exception as e: # noqa: BLE001 orch.store.set_mission_status(mid, "pending", bump_run=True) orch.store.log_event("error", f"Mission #{mid}: {e}") finally: orch.store.set_setting("bot_busy", "0") orch.store.set_setting("active_mission_id", "") orch.store.set_setting("abort_current", "0") self._sleep(self.cycle_delay) finally: orch.store.log_event("info", "ka6 long terme arrêté.") orch.close() def _sleep(self, seconds: float) -> None: end = time.time() + seconds while time.time() < end and not self._stop: time.sleep(min(1.0, end - time.time()))