adding AI integration

This commit is contained in:
ange committed 2026-08-12 11:02:03 +02:00
1 parent 068f84bf1d
commit 699cb3eea9
7 files changed
+764 -12

No files matched your search

+74 -11
View File
@@ -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 `<link rel="next">`) 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 `<link rel="next">`) 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
+58
View File
@@ -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()
+97
View File
@@ -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(),
)
+35
View File
@@ -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)
+173
View File
@@ -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": <numéro du lien choisi>} 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
+326 -1
View File
@@ -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
# ------------------------------------------------------------------ #
+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6
reportlab>=4.0
llama-cpp-python>=0.3