SPB Git

spb/ka4 Public

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

Python 97.8% Shell 2.2%
11.0 KB · 224 lines python
Raw Blame History
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