diff --git a/README.md b/README.md index c7a3c38..f261aef 100644 --- a/README.md +++ b/README.md @@ -64,15 +64,39 @@ et de l'accumuler dans un PDF, au fil de la navigation. cliquable vers chaque section — liens internes PDF standard (annotations `/Link` avec destination directe), utilisables dans n'importe quel lecteur PDF. -- **Détection de liens de "suite"** : les liens dont le texte ressemble à - *suivant / next / chapitre suivant / lire la suite / part 2* etc. (ou une - balise ``) sont surlignés en orange et listés dans le - panneau, pour enchaîner facilement la lecture et la capture sans les - chercher manuellement. La détection tourne en continu (`MutationObserver` - JS, résultats poussés vers Python via le pont QWebChannel) plutôt qu'une - seule fois au chargement — nécessaire car beaucoup de sites (SPA, - pagination injectée après coup en JavaScript) ajoutent ce genre de lien - bien après le signal de fin de chargement. +- **Détection de liens de "suite"** : les liens dont le texte — ou le texte + d'un conteneur voisin proche (icône + légende, ex. "Partie Suivante" à + côté du lien) — ressemble à *suivant / next / chapitre suivant / lire la + suite / part 2* etc. (ou une balise ``) sont surlignés + en orange et listés dans le panneau, pour enchaîner facilement la + lecture et la capture sans les chercher manuellement. La détection + tourne en continu (`MutationObserver` JS, résultats poussés vers Python + via le pont QWebChannel) plutôt qu'une seule fois au chargement — + nécessaire car beaucoup de sites (SPA, pagination injectée après coup en + JavaScript) ajoutent ce genre de lien bien après le signal de fin de + chargement. +- **Scraping assisté par IA locale** (llama-cpp-python, aucune donnée + envoyée en ligne) : configurez un modèle via *"⚙️ Configurer l'IA + locale"* (fichier `.gguf` de votre choix — non fourni avec l'appli, + liens de téléchargement vérifiés dans la section + [Installation](#modèles-gguf-recommandés) ci-dessous). Trois usages, + combinables : + - **🤖 Extraire (IA)** : demande au modèle d'extraire le texte narratif + principal de la page (menus/pubs/commentaires écartés) et l'ajoute au + PDF — alternative à la sélection manuelle/au mode survol, utile sur + les pages au contenu trop fragmenté pour un clic unique. + - **🤖 Lien suivant (IA)** : demande au modèle de désigner, parmi *tous* + les liens de la page, celui qui mène le plus probablement à la suite — + complète la détection heuristique quand celle-ci ne trouve rien + (structures de page trop inhabituelles pour un motif texte). + - **🔁 Scraping automatique (IA)** : combine les deux en boucle — + extrait, capture, trouve et suit le lien suivant, répète — jusqu'à un + nombre de chapitres maximum fixé au démarrage (garde-fou demandé à + chaque lancement) ou jusqu'à ce qu'aucune suite ne soit trouvée ; une + détection de boucle (retour vers une page déjà visitée) arrête aussi + le processus automatiquement. Bouton "⏹ Arrêter" disponible à tout + moment. L'inférence tourne sur un thread dédié pour ne jamais geler + l'interface. ## Installation @@ -88,6 +112,41 @@ pip install -r requirements.txt python run.py ``` +`llama-cpp-python` compile une extension native au moment de l'installation +(pas de wheel précompilé pour toutes les configurations) — l'installation +peut donc prendre plusieurs minutes selon la machine. Les fonctionnalités +IA restent optionnelles : sans modèle configuré, le reste de l'application +fonctionne normalement (les boutons IA affichent un message clair invitant +à configurer un modèle plutôt que d'échouer silencieusement). + +### IA locale (optionnel) + +1. Téléchargez un modèle au format **GGUF** (liens vérifiés ci-dessous — + le fichier n'est pas fourni avec l'appli, à télécharger une seule fois). +2. Dans PdfScrapper, ouvrez *"⚙️ Configurer l'IA locale"* et indiquez le + chemin du fichier `.gguf`. +3. Les boutons *"🤖 Extraire (IA)"*, *"🤖 Lien suivant (IA)"* et + *"🔁 Scraping automatique (IA)"* sont alors utilisables. + +#### Modèles GGUF recommandés + +Existence, nom de fichier exact et taille vérifiés au moment de la +rédaction. Prendre la variante **Q4_K_M** (bon compromis qualité/vitesse/ +taille pour du CPU) sauf indication contraire — c'est le lien direct +donné ci-dessous. + +| Modèle | Taille (Q4_K_M) | Pourquoi celui-ci | Téléchargement | +|---|---|---|---| +| **Qwen3-4B-Instruct-2507** (recommandé par défaut) | ~2,5 Go | Le plus récent de la liste, très bon suivi d'instructions (utile pour la sortie JSON du choix de lien) et bon en français, reste rapide sur CPU | [lien direct](https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF/resolve/main/Qwen3-4B-Instruct-2507-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/unsloth/Qwen3-4B-Instruct-2507-GGUF) | +| **Qwen2.5-3B-Instruct** (le plus léger) | ~1,9 Go | Le plus petit/rapide de la liste, correct en français, suffisant pour dépanner sur une machine modeste | [lien direct](https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF) | +| **Mistral-7B-Instruct-v0.3** (meilleur français) | ~4,4 Go | Modèle Mistral AI (français), référence historique en qualité de français ; demande plus de RAM/CPU | [lien direct](https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF) | +| **Llama-3.2-3B-Instruct** (alternative légère) | ~2,0 Go | Généraliste léger, bonne alternative si Qwen ne convient pas | [lien direct](https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF) | +| **Qwen2.5-7B-Instruct** (meilleure qualité) | ~4,7 Go | Si ≥ 8 Go de RAM disponibles et qu'on veut la meilleure qualité d'extraction | [lien direct](https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf) · [page du modèle](https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF) | + +Pour une machine avec peu de RAM, préférer les modèles 3-4B. Pour plus de +qualité (et si le CPU/la RAM suit), les 7B donnent de meilleurs résultats +sur l'extraction de contenu comme sur le choix du lien suivant. + ## Structure du projet ``` @@ -100,8 +159,12 @@ pdfscrapper/ pdf_style.py # dataclass PdfStyle (réglages de mise en forme du PDF) pdf_builder.py # génération du PDF (reportlab) : style, fond, sommaire/liens preview_window.py # fenêtre d'aperçu en direct (QtPdf), style et correction du texte - models.py # dataclass Clip (un extrait capturé) -run.py # lance l'application + ai_config.py # dataclass AiConfig, persistée via QSettings + ai_settings_dialog.py # fenêtre de configuration du modèle IA local (.gguf) + ai_worker.py # exécution des appels IA sur un thread dédié (non bloquant) + local_ai.py # inférence llama-cpp-python : extraction + choix du lien suivant + models.py # dataclass Clip (un extrait capturé) +run.py # lance l'application ``` ## Pistes d'évolution diff --git a/pdfscrapper/ai_config.py b/pdfscrapper/ai_config.py new file mode 100644 index 0000000..f6d7cb5 --- /dev/null +++ b/pdfscrapper/ai_config.py @@ -0,0 +1,58 @@ +"""Configuration de l'IA locale, persistée via QSettings (registre Windows +/ fichier ini selon l'OS — mécanisme standard Qt, pas de fichier à gérer +nous-mêmes). + +Le modèle (fichier .gguf) n'est jamais embarqué dans le dépôt : trop +volumineux, et le choix dépend du matériel de chacun. L'utilisateur pointe +vers son propre fichier via la fenêtre de configuration (voir +ai_settings_dialog.py). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from PySide6.QtCore import QSettings + +_ORG = "PdfScrapper" +_APP = "PdfScrapper" + + +@dataclass +class AiConfig: + model_path: str = "" + n_ctx: int = 4096 + n_threads: int = 0 # 0 = auto (os.cpu_count()) + max_tokens: int = 800 + + @property + def is_configured(self) -> bool: + return bool(self.model_path) and os.path.isfile(self.model_path) + + +def load_ai_config() -> AiConfig: + settings = QSettings(_ORG, _APP) + settings.beginGroup("local_ai") + try: + config = AiConfig( + model_path=settings.value("model_path", "", type=str), + n_ctx=settings.value("n_ctx", 4096, type=int), + n_threads=settings.value("n_threads", 0, type=int), + max_tokens=settings.value("max_tokens", 800, type=int), + ) + finally: + settings.endGroup() + return config + + +def save_ai_config(config: AiConfig) -> None: + settings = QSettings(_ORG, _APP) + settings.beginGroup("local_ai") + try: + settings.setValue("model_path", config.model_path) + settings.setValue("n_ctx", config.n_ctx) + settings.setValue("n_threads", config.n_threads) + settings.setValue("max_tokens", config.max_tokens) + finally: + settings.endGroup() diff --git a/pdfscrapper/ai_settings_dialog.py b/pdfscrapper/ai_settings_dialog.py new file mode 100644 index 0000000..d416cc9 --- /dev/null +++ b/pdfscrapper/ai_settings_dialog.py @@ -0,0 +1,97 @@ +"""Fenêtre de configuration de l'IA locale : chemin du modèle .gguf et +quelques paramètres d'inférence de base.""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from .ai_config import AiConfig + + +class AiSettingsDialog(QDialog): + def __init__(self, config: AiConfig, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("Configurer l'IA locale") + self.resize(520, 260) + self.config = config + + layout = QVBoxLayout(self) + + info = QLabel( + "Le scraping assisté par IA tourne entièrement en local via " + "llama-cpp-python : aucune donnée n'est envoyée en ligne.\n" + "Indiquez le chemin d'un modèle au format GGUF (par ex. un petit " + "modèle instruct type Qwen2.5-3B-Instruct ou Llama-3.2-3B-Instruct, " + "téléchargé au préalable depuis Hugging Face)." + ) + info.setWordWrap(True) + layout.addWidget(info) + + form = QFormLayout() + + path_row = QWidget() + path_layout = QHBoxLayout(path_row) + path_layout.setContentsMargins(0, 0, 0, 0) + self.model_path_edit = QLineEdit(config.model_path) + browse_btn = QPushButton("Parcourir…") + browse_btn.clicked.connect(self._browse) + path_layout.addWidget(self.model_path_edit) + path_layout.addWidget(browse_btn) + form.addRow("Fichier modèle (.gguf)", path_row) + + self.n_ctx_spin = QSpinBox() + self.n_ctx_spin.setRange(512, 32768) + self.n_ctx_spin.setSingleStep(512) + self.n_ctx_spin.setValue(config.n_ctx) + self.n_ctx_spin.setToolTip("Taille de la fenêtre de contexte (tokens).") + form.addRow("Contexte (n_ctx)", self.n_ctx_spin) + + self.n_threads_spin = QSpinBox() + self.n_threads_spin.setRange(0, 64) + self.n_threads_spin.setValue(config.n_threads) + self.n_threads_spin.setToolTip("0 = détection automatique du nombre de threads CPU.") + form.addRow("Threads CPU (0 = auto)", self.n_threads_spin) + + self.max_tokens_spin = QSpinBox() + self.max_tokens_spin.setRange(64, 4096) + self.max_tokens_spin.setSingleStep(64) + self.max_tokens_spin.setValue(config.max_tokens) + self.max_tokens_spin.setToolTip("Longueur maximale par défaut d'une réponse générée.") + form.addRow("Tokens max. par réponse", self.max_tokens_spin) + + layout.addLayout(form) + layout.addStretch(1) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, "Choisir un modèle GGUF", self.model_path_edit.text(), "Modèles GGUF (*.gguf)" + ) + if path: + self.model_path_edit.setText(path) + + def updated_config(self) -> AiConfig: + return AiConfig( + model_path=self.model_path_edit.text().strip(), + n_ctx=self.n_ctx_spin.value(), + n_threads=self.n_threads_spin.value(), + max_tokens=self.max_tokens_spin.value(), + ) diff --git a/pdfscrapper/ai_worker.py b/pdfscrapper/ai_worker.py new file mode 100644 index 0000000..03e0c7d --- /dev/null +++ b/pdfscrapper/ai_worker.py @@ -0,0 +1,35 @@ +"""Exécution des appels IA locale en arrière-plan. + +L'inférence llama-cpp-python est bloquante et peut prendre plusieurs +secondes (voire dizaines de secondes pour le premier appel, le temps de +charger le modèle) — elle ne doit jamais tourner sur le thread Qt +principal sous peine de geler toute l'interface. +""" + +from __future__ import annotations + +from typing import Callable + +from PySide6.QtCore import QThread, Signal + + +class AiTaskThread(QThread): + """Exécute `fn(*args, **kwargs)` sur un thread dédié et remonte le + résultat (ou l'erreur) via un signal Qt, thread-safe par construction.""" + + succeeded = Signal(object) + failed = Signal(str) + + def __init__(self, fn: Callable, *args, **kwargs) -> None: + super().__init__() + self._fn = fn + self._args = args + self._kwargs = kwargs + + def run(self) -> None: + try: + result = self._fn(*self._args, **self._kwargs) + except Exception as exc: # noqa: BLE001 — remonté proprement à l'UI + self.failed.emit(str(exc)) + else: + self.succeeded.emit(result) diff --git a/pdfscrapper/local_ai.py b/pdfscrapper/local_ai.py new file mode 100644 index 0000000..93d0909 --- /dev/null +++ b/pdfscrapper/local_ai.py @@ -0,0 +1,173 @@ +"""Intégration IA locale (llama-cpp-python) pour assister le scraping. + +Deux capacités, combinables : + - pick_next_link : désigne, parmi les liens candidats d'une page, celui + qui mène le plus probablement à la suite de l'histoire/du chapitre — + en complément (ou remplacement, sur les pages où il échoue) de + l'heuristique regex de link_detector.py, qui reste rapide/gratuite + mais fragile face aux structures de page inhabituelles. + - extract_main_content : extrait le texte narratif principal d'une page + (mode lecture), en écartant menus/pubs/commentaires — une alternative + au mode "capture de bloc au survol" quand le contenu est trop + fragmenté pour être capturé en un clic. + +Le modèle (fichier .gguf) n'est pas embarqué dans ce dépôt — l'utilisateur +fournit son propre fichier via la fenêtre de configuration IA (voir +ai_settings_dialog.py). Le chargement est paresseux (au premier appel +réel) et mis en cache : un modèle de quelques Go peut prendre plusieurs +secondes à charger, d'où l'exécution systématique en arrière-plan côté +appelant (voir ai_worker.py) pour ne jamais geler l'interface. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .ai_config import AiConfig + + +class LocalAiError(RuntimeError): + """Erreur lisible, destinée à être affichée telle quelle à l'utilisateur.""" + + +@dataclass +class LinkCandidate: + text: str + href: str + + +_NEXT_LINK_SYSTEM_PROMPT = ( + "Tu aides à identifier, parmi une liste de liens présents sur une page web, " + "celui qui mène le plus probablement à la SUITE de l'histoire ou du chapitre " + "actuellement affiché (par ex. la partie/le chapitre suivant). Si aucun lien " + "ne semble mener à une suite (ex. ce sont des liens de menu, de pub, ou vers " + "d'autres histoires sans rapport), réponds qu'aucun ne convient.\n\n" + "Réponds STRICTEMENT avec un objet JSON sur une seule ligne, de la forme " + '{"index": } ou {"index": null}. Aucun autre texte.' +) + +_EXTRACT_SYSTEM_PROMPT = ( + "Tu extrais le texte narratif principal (l'histoire/l'article) d'un texte brut " + "issu d'une page web, qui contient probablement aussi du texte hors-sujet " + "(menus, publicités, commentaires, liens de navigation, avertissements). " + "Renvoie UNIQUEMENT ce texte narratif, tel quel, sans le résumer, le traduire " + "ni le commenter, en conservant sa mise en forme en paragraphes. Ne renvoie " + "rien d'autre : pas de préambule, pas d'explication." +) + + +class LocalAi: + """Enveloppe autour de llama-cpp-python, avec chargement paresseux du modèle.""" + + def __init__(self, config: AiConfig) -> None: + self.config = config + self._llm = None # instancié à la demande, voir _ensure_loaded() + + def reset(self) -> None: + """Invalide le modèle chargé (ex. après changement de configuration).""" + self._llm = None + + def is_configured(self) -> bool: + return self.config.is_configured + + # ------------------------------------------------------------------ # + # Chargement du modèle + # ------------------------------------------------------------------ # + def _ensure_loaded(self): + if self._llm is not None: + return self._llm + + if not self.config.model_path: + raise LocalAiError( + "Aucun modèle IA configuré. Ouvrez « ⚙️ Configurer l'IA locale » " + "et indiquez le chemin d'un fichier .gguf." + ) + + try: + from llama_cpp import Llama + except ImportError as exc: + raise LocalAiError( + "Le paquet llama-cpp-python n'est pas installé.\n" + "Installez-le avec : pip install llama-cpp-python" + ) from exc + + try: + self._llm = Llama( + model_path=self.config.model_path, + n_ctx=self.config.n_ctx, + n_threads=self.config.n_threads or None, + verbose=False, + ) + except Exception as exc: # noqa: BLE001 — remonté proprement à l'UI + raise LocalAiError(f"Échec du chargement du modèle IA :\n{exc}") from exc + + return self._llm + + # ------------------------------------------------------------------ # + # Capacités + # ------------------------------------------------------------------ # + def pick_next_link( + self, candidates: list[LinkCandidate], page_title: str + ) -> LinkCandidate | None: + """Interroge le modèle pour désigner le lien menant à la suite, + parmi `candidates`. Retourne None si aucun ne convient.""" + if not candidates: + return None + + llm = self._ensure_loaded() + numbered = "\n".join(f"{i}. {c.text!r}" for i, c in enumerate(candidates)) + user_prompt = f"Titre de la page actuelle : {page_title!r}\n\nLiens candidats :\n{numbered}" + + raw = self._complete(llm, _NEXT_LINK_SYSTEM_PROMPT, user_prompt, max_tokens=60) + index = self._parse_index(raw, len(candidates)) + return candidates[index] if index is not None else None + + def extract_main_content(self, page_title: str, raw_text: str, max_input_chars: int = 6000) -> str: + """Demande au modèle d'extraire le texte narratif principal d'un + texte brut de page (déjà tronqué à `max_input_chars` caractères — + les petits modèles locaux ont une fenêtre de contexte limitée).""" + llm = self._ensure_loaded() + trimmed = raw_text.strip()[:max_input_chars] + if not trimmed: + raise LocalAiError("Aucun texte à analyser sur cette page.") + + user_prompt = f"Titre de la page : {page_title!r}\n\n--- TEXTE BRUT ---\n{trimmed}" + result = self._complete( + llm, + _EXTRACT_SYSTEM_PROMPT, + user_prompt, + max_tokens=max(self.config.max_tokens, len(trimmed) // 3), + ) + result = result.strip() + if not result: + raise LocalAiError("L'IA n'a renvoyé aucun contenu exploitable pour cette page.") + return result + + # ------------------------------------------------------------------ # + def _complete(self, llm, system_prompt: str, user_prompt: str, max_tokens: int) -> str: + try: + response = llm.create_chat_completion( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + max_tokens=max_tokens, + temperature=0.1, + ) + return response["choices"][0]["message"]["content"] or "" + except LocalAiError: + raise + except Exception as exc: # noqa: BLE001 — remonté proprement à l'UI + raise LocalAiError(f"Échec de l'inférence IA :\n{exc}") from exc + + @staticmethod + def _parse_index(raw: str, count: int) -> int | None: + match = re.search(r'"index"\s*:\s*(null|\d+)', raw) + if not match or match.group(1) == "null": + return None + try: + index = int(match.group(1)) + except ValueError: + return None + return index if 0 <= index < count else None diff --git a/pdfscrapper/main_window.py b/pdfscrapper/main_window.py index cd8624f..c24bcdd 100644 --- a/pdfscrapper/main_window.py +++ b/pdfscrapper/main_window.py @@ -6,7 +6,7 @@ import json from datetime import datetime from pathlib import Path -from PySide6.QtCore import QStandardPaths, QUrl, Qt +from PySide6.QtCore import QStandardPaths, QTimer, QUrl, Qt from PySide6.QtGui import QAction, QKeySequence from PySide6.QtWebChannel import QWebChannel from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineScript @@ -29,14 +29,52 @@ from PySide6.QtWidgets import ( QWidget, ) +from .ai_config import AiConfig, load_ai_config, save_ai_config +from .ai_settings_dialog import AiSettingsDialog +from .ai_worker import AiTaskThread from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE from .bridge import Bridge, build_bridge_init_script from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS +from .local_ai import LinkCandidate, LocalAi, LocalAiError from .models import Clip from .pdf_builder import build_pdf from .pdf_style import PdfStyle from .preview_window import PdfPreviewWindow +# Texte brut de la page, tronqué et débarrassé des zones techniques +# évidentes (script/style/nav) avant d'être envoyé au modèle local pour +# extraction — réduit le bruit et la taille du prompt. +PAGE_TEXT_FOR_AI_JS = """ +(function () { + var clone = document.body.cloneNode(true); + clone.querySelectorAll('script, style, nav, header, footer').forEach(function (el) { + el.remove(); + }); + return (clone.innerText || clone.textContent || '').trim(); +})(); +""" + +# Liste large de liens candidats (texte + href), pour la sélection IA du +# lien "suivant" — plus permissive que link_detector.py (pas de filtrage +# par mots-clés), puisque c'est justement ce que l'IA doit juger. +ALL_LINKS_FOR_AI_JS = """ +(function () { + var seen = {}; + var links = []; + document.querySelectorAll('a[href]').forEach(function (a) { + var text = (a.innerText || a.textContent || '').trim(); + if (!text || text.length > 100) { return; } + if (seen[a.href]) { return; } + seen[a.href] = true; + links.push({ text: text, href: a.href }); + }); + return JSON.stringify(links.slice(0, 150)); +})(); +""" + +AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS = 20 +AUTO_SCRAPE_SETTLE_DELAY_MS = 1500 # laisse le temps au contenu dynamique de se charger + HOME_URL = "https://www.google.com/" @@ -60,6 +98,14 @@ class MainWindow(QMainWindow): self.pdf_style = PdfStyle() self.preview_window: PdfPreviewWindow | None = None + self.ai_config = load_ai_config() + self.local_ai = LocalAi(self.ai_config) + self._ai_thread: AiTaskThread | None = None + self.auto_scrape_active = False + self.auto_scrape_visited_urls: set[str] = set() + self.auto_scrape_max_chapters = AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS + self.auto_scrape_count = 0 + self._build_webview() self._build_toolbar() self._build_sidebar() @@ -238,6 +284,40 @@ class MainWindow(QMainWindow): preview_btn.clicked.connect(self.open_pdf_preview) layout.addWidget(preview_btn) + layout.addWidget(_separator_label("Scraping assisté par IA locale")) + + ai_settings_btn = QPushButton("⚙️ Configurer l'IA locale") + ai_settings_btn.clicked.connect(self.open_ai_settings) + layout.addWidget(ai_settings_btn) + + ai_buttons = QHBoxLayout() + self.ai_extract_btn = QPushButton("🤖 Extraire (IA)") + self.ai_extract_btn.setToolTip( + "Demande à l'IA locale d'extraire le texte narratif principal de la page " + "courante et de l'ajouter au PDF (alternative à la sélection manuelle / au " + "mode survol)." + ) + self.ai_extract_btn.clicked.connect(self.run_ai_extract) + self.ai_next_link_btn = QPushButton("🤖 Lien suivant (IA)") + self.ai_next_link_btn.setToolTip( + "Demande à l'IA locale de désigner, parmi tous les liens de la page, celui " + "qui mène le plus probablement à la suite — utile quand la détection " + "automatique (mots-clés) ne trouve rien." + ) + self.ai_next_link_btn.clicked.connect(self.run_ai_pick_next_link) + ai_buttons.addWidget(self.ai_extract_btn) + ai_buttons.addWidget(self.ai_next_link_btn) + layout.addLayout(ai_buttons) + + self.auto_scrape_btn = QPushButton("🔁 Démarrer le scraping automatique (IA)") + self.auto_scrape_btn.setToolTip( + "Boucle : extrait le contenu de la page courante (IA), capture le PDF, " + "trouve et suit le lien suivant (IA), répète — jusqu'à la limite de " + "chapitres fixée ou jusqu'à ce qu'aucune suite ne soit trouvée." + ) + self.auto_scrape_btn.clicked.connect(self._toggle_auto_scrape) + layout.addWidget(self.auto_scrape_btn) + layout.addWidget(_separator_label("Liens suggérés sur cette page")) self.suggested_list = QListWidget() self.suggested_list.itemDoubleClicked.connect(self._navigate_to_suggested) @@ -485,6 +565,251 @@ class MainWindow(QMainWindow): self.preview_window.raise_() self.preview_window.activateWindow() + # ------------------------------------------------------------------ # + # IA locale — configuration + # ------------------------------------------------------------------ # + def open_ai_settings(self) -> None: + dialog = AiSettingsDialog(self.ai_config, self) + if dialog.exec() == AiSettingsDialog.DialogCode.Accepted: + self.ai_config = dialog.updated_config() + save_ai_config(self.ai_config) + self.local_ai.config = self.ai_config + self.local_ai.reset() # force le rechargement si le modèle a changé + self.statusBar().showMessage("Configuration IA enregistrée.", 3000) + + def _require_ai_configured(self) -> bool: + if self.local_ai.is_configured(): + return True + confirm = QMessageBox.question( + self, + "IA locale non configurée", + "Aucun modèle IA n'est configuré. Ouvrir les réglages maintenant ?", + ) + if confirm == QMessageBox.StandardButton.Yes: + self.open_ai_settings() + return False + + # ------------------------------------------------------------------ # + # IA locale — extraction de contenu + # ------------------------------------------------------------------ # + def run_ai_extract(self) -> None: + if not self._require_ai_configured(): + return + self.statusBar().showMessage("IA : lecture de la page…", 3000) + self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._on_page_text_for_ai_extract) + + def _on_page_text_for_ai_extract(self, page_text: str | None) -> None: + if not page_text or not page_text.strip(): + QMessageBox.warning(self, "IA locale", "Aucun texte exploitable sur cette page.") + return + title = self.webview.title() or self.webview.url().toString() + self._run_ai_task( + self.local_ai.extract_main_content, + args=(title, page_text), + on_success=self._on_ai_extract_succeeded, + busy_message="IA : extraction du contenu en cours (peut prendre du temps sur CPU)…", + ) + + def _on_ai_extract_succeeded(self, extracted_text: object) -> None: + self._add_clip(str(extracted_text)) + self.statusBar().showMessage("Extrait ajouté au PDF (IA).", 3000) + + # ------------------------------------------------------------------ # + # IA locale — suggestion du lien suivant + # ------------------------------------------------------------------ # + def run_ai_pick_next_link(self) -> None: + if not self._require_ai_configured(): + return + self.statusBar().showMessage("IA : lecture des liens de la page…", 3000) + self.webview.page().runJavaScript(ALL_LINKS_FOR_AI_JS, self._on_links_for_ai_pick) + + def _on_links_for_ai_pick(self, links_json: str | None) -> None: + try: + raw_links = json.loads(links_json) if links_json else [] + except (TypeError, ValueError): + raw_links = [] + if not raw_links: + QMessageBox.warning(self, "IA locale", "Aucun lien exploitable sur cette page.") + return + + candidates = [LinkCandidate(text=l["text"], href=l["href"]) for l in raw_links] + title = self.webview.title() or self.webview.url().toString() + self._run_ai_task( + self.local_ai.pick_next_link, + args=(candidates, title), + on_success=self._on_ai_pick_next_link_succeeded, + busy_message="IA : recherche du lien suivant en cours…", + ) + + def _on_ai_pick_next_link_succeeded(self, candidate: object) -> None: + if candidate is None: + QMessageBox.information( + self, "IA locale", "L'IA n'a identifié aucun lien menant à une suite sur cette page." + ) + return + candidate = candidate # type: LinkCandidate + confirm = QMessageBox.question( + self, + "Lien suivant suggéré par l'IA", + f"L'IA suggère :\n\n« {candidate.text} »\n{candidate.href}\n\nY aller maintenant ?", + ) + if confirm == QMessageBox.StandardButton.Yes: + self.webview.setUrl(QUrl(candidate.href)) + + # ------------------------------------------------------------------ # + # IA locale — exécution en arrière-plan + # ------------------------------------------------------------------ # + def _run_ai_task(self, fn, args: tuple, on_success, busy_message: str) -> None: + self.statusBar().showMessage(busy_message) + self._set_ai_buttons_enabled(False) + + self._ai_thread = AiTaskThread(fn, *args) + self._ai_thread.succeeded.connect(lambda result: self._on_ai_task_succeeded(on_success, result)) + self._ai_thread.failed.connect(self._on_ai_task_failed) + self._ai_thread.finished.connect(lambda: self._set_ai_buttons_enabled(True)) + self._ai_thread.start() + + def _on_ai_task_succeeded(self, on_success, result: object) -> None: + on_success(result) + + def _on_ai_task_failed(self, message: str) -> None: + self.statusBar().clearMessage() + QMessageBox.warning(self, "IA locale", message) + if self.auto_scrape_active: + self._stop_auto_scrape(f"Scraping automatique arrêté : {message}") + + def _set_ai_buttons_enabled(self, enabled: bool) -> None: + self.ai_extract_btn.setEnabled(enabled) + self.ai_next_link_btn.setEnabled(enabled) + if enabled and not self.auto_scrape_active: + self.auto_scrape_btn.setEnabled(True) + + # ------------------------------------------------------------------ # + # IA locale — scraping automatique (boucle) + # ------------------------------------------------------------------ # + def _toggle_auto_scrape(self) -> None: + if self.auto_scrape_active: + self._stop_auto_scrape("Scraping automatique arrêté.") + return + + if not self._require_ai_configured(): + return + + max_chapters, ok = QInputDialog.getInt( + self, + "Scraping automatique", + "Nombre maximum de chapitres à capturer automatiquement :", + self.auto_scrape_max_chapters, + 1, + 100, + ) + if not ok: + return + + self.auto_scrape_max_chapters = max_chapters + self.auto_scrape_active = True + self.auto_scrape_visited_urls = set() + self.auto_scrape_count = 0 + self.auto_scrape_btn.setText("⏹ Arrêter le scraping automatique") + self.statusBar().showMessage("Scraping automatique démarré…", 3000) + self._auto_scrape_extract_current_page() + + def _stop_auto_scrape(self, message: str) -> None: + self.auto_scrape_active = False + self.auto_scrape_btn.setText("🔁 Démarrer le scraping automatique (IA)") + self.statusBar().showMessage(message, 6000) + + def _auto_scrape_extract_current_page(self) -> None: + if not self.auto_scrape_active: + return + self.auto_scrape_visited_urls.add(self.webview.url().toString()) + self.statusBar().showMessage( + f"Scraping automatique : extraction du chapitre {self.auto_scrape_count + 1}" + f"/{self.auto_scrape_max_chapters}…" + ) + self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._auto_scrape_on_page_text) + + def _auto_scrape_on_page_text(self, page_text: str | None) -> None: + if not self.auto_scrape_active: + return + if not page_text or not page_text.strip(): + self._stop_auto_scrape("Scraping automatique arrêté : page sans texte exploitable.") + return + title = self.webview.title() or self.webview.url().toString() + self._run_ai_task( + self.local_ai.extract_main_content, + args=(title, page_text), + on_success=self._auto_scrape_on_extracted, + busy_message=f"Scraping automatique : extraction IA du chapitre {self.auto_scrape_count + 1}…", + ) + + def _auto_scrape_on_extracted(self, extracted_text: object) -> None: + if not self.auto_scrape_active: + return + self._add_clip(str(extracted_text)) + self.auto_scrape_count += 1 + + if self.auto_scrape_count >= self.auto_scrape_max_chapters: + self._stop_auto_scrape( + f"Scraping automatique terminé : limite de {self.auto_scrape_max_chapters} " + "chapitres atteinte." + ) + return + + self.webview.page().runJavaScript(ALL_LINKS_FOR_AI_JS, self._auto_scrape_on_links) + + def _auto_scrape_on_links(self, links_json: str | None) -> None: + if not self.auto_scrape_active: + return + try: + raw_links = json.loads(links_json) if links_json else [] + except (TypeError, ValueError): + raw_links = [] + if not raw_links: + self._stop_auto_scrape("Scraping automatique terminé : plus aucun lien sur la page.") + return + + candidates = [LinkCandidate(text=l["text"], href=l["href"]) for l in raw_links] + title = self.webview.title() or self.webview.url().toString() + self._run_ai_task( + self.local_ai.pick_next_link, + args=(candidates, title), + on_success=self._auto_scrape_on_next_link_found, + busy_message="Scraping automatique : recherche du chapitre suivant (IA)…", + ) + + def _auto_scrape_on_next_link_found(self, candidate: object) -> None: + if not self.auto_scrape_active: + return + if candidate is None: + self._stop_auto_scrape( + f"Scraping automatique terminé : {self.auto_scrape_count} chapitre(s) capturé(s), " + "aucune suite trouvée." + ) + return + + candidate = candidate # type: LinkCandidate + if candidate.href in self.auto_scrape_visited_urls: + self._stop_auto_scrape( + "Scraping automatique arrêté : le lien suivant pointe vers une page déjà visitée " + "(boucle détectée)." + ) + return + + def on_next_page_loaded(ok: bool) -> None: + self.webview.loadFinished.disconnect(on_next_page_loaded) + if not self.auto_scrape_active: + return + if not ok: + self._stop_auto_scrape("Scraping automatique arrêté : échec du chargement de la page suivante.") + return + # Laisse le temps au contenu chargé en JS de s'installer avant + # de relire la page (voir link_detector.py pour le même souci). + QTimer.singleShot(AUTO_SCRAPE_SETTLE_DELAY_MS, self._auto_scrape_extract_current_page) + + self.webview.loadFinished.connect(on_next_page_loaded) + self.webview.setUrl(QUrl(candidate.href)) + # ------------------------------------------------------------------ # # Export PDF # ------------------------------------------------------------------ # diff --git a/requirements.txt b/requirements.txt index 72f3b9d..ed20b23 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PySide6>=6.6 reportlab>=4.0 +llama-cpp-python>=0.3