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