fix errors
This commit is contained in:
1 parent
2427c3525e
commit
df11fa97bf
7 files changed
+795
-77
No files matched your search
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(QT_QPA_PLATFORM=offscreen QTWEBENGINE_DISABLE_SANDBOX=1 PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe -c ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -116,15 +116,78 @@ et de l'accumuler dans un PDF, au fil de la navigation.
|
||||
- **🤖 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).
|
||||
(structures de page trop inhabituelles pour un motif texte). Pour
|
||||
chaque lien, le texte du **conteneur parent proche** est aussi transmis
|
||||
au modèle quand il diffère du texte du lien lui-même — utile pour les
|
||||
liens de pagination réduits à une icône/un chiffre sans texte propre
|
||||
(ex. "2", "›"), fréquents quand un chapitre est réparti sur plusieurs
|
||||
pages web. La **fin du contenu de la page actuelle** (déjà extrait,
|
||||
voir 🤖 Extraire (IA) — ou relu pour l'occasion pour le bouton manuel)
|
||||
est aussi transmise : un texte qui s'interrompt en plein milieu d'une
|
||||
scène est un indice fort de pagination interne, qu'un simple texte de
|
||||
lien ne suffit pas toujours à révéler. Le modèle indique aussi si le
|
||||
lien choisi mène à la suite du **même chapitre** (pagination interne)
|
||||
ou à un **nouveau chapitre**.
|
||||
- **🔁 Scraping auto (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. Le bouton devient "⏹ Arrêter le
|
||||
scraping" tant qu'il tourne. L'inférence tourne sur un thread dédié
|
||||
pour ne jamais geler l'interface.
|
||||
chaque lancement, **0 = illimité**, voir ci-dessous) 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. Le
|
||||
bouton devient "⏹ Arrêter le scraping" tant qu'il tourne. L'inférence
|
||||
tourne sur un thread dédié (en streaming) pour ne jamais geler
|
||||
l'interface, avec un **délai d'inactivité** (2 min sans le moindre
|
||||
fragment de réponse généré) au-delà duquel le scraping s'arrête
|
||||
proprement plutôt que de rester bloqué indéfiniment si l'IA ne répond
|
||||
vraiment plus jamais (modèle figé, fichier corrompu...) — chaque
|
||||
fragment généré réarme ce délai, donc une analyse simplement lente
|
||||
(mais qui progresse encore) n'est jamais interrompue à tort, contrairement
|
||||
à une limite sur la durée totale de l'appel. Les éventuelles erreurs
|
||||
d'inférence n'affichent plus de boîte de dialogue bloquante pendant un
|
||||
scraping automatique (juste un arrêt + un message dans le journal),
|
||||
pour ne jamais donner l'impression que l'appli s'est figée faute d'un
|
||||
clic sur une fenêtre passée inaperçue. Une **fenêtre de suivi** (non
|
||||
modale, se réaffiche à chaque lancement) s'ouvre en même temps :
|
||||
journal horodaté de chaque étape (lecture de la page,
|
||||
extraction IA, recherche puis choix du lien suivant, arrêt et sa
|
||||
raison...), barre de progression "chapitre X/N" (animation
|
||||
indéterminée en mode illimité), et son propre bouton *"⏹ Arrêter le
|
||||
scraping"*. La fermer (X) la masque seulement — le scraping continue
|
||||
en arrière-plan si on ne l'arrête pas, la fenêtre se rouvrant au
|
||||
prochain démarrage de scraping. **Chapitres multipages liés
|
||||
automatiquement** : quand le modèle juge que la page suivante est la
|
||||
suite du même chapitre (voir ci-dessus), l'extrait qui vient d'être
|
||||
capturé est automatiquement marqué 🔗 (même mécanisme que la liaison
|
||||
manuelle, voir *Lier plusieurs extraits en un seul chapitre*
|
||||
ci-dessous) — les pages d'un même chapitre réparti sur plusieurs URLs
|
||||
ressortent donc comme une seule section dans le PDF final, sans action
|
||||
manuelle.
|
||||
- **Détection des tables des matières** : avant de tenter d'en extraire
|
||||
le contenu, chaque page est d'abord analysée pour savoir si c'est une
|
||||
table des matières / liste de chapitres plutôt qu'un chapitre en soi
|
||||
(page d'accueil d'une série, sommaire...). Si oui : aucun faux
|
||||
"chapitre" n'est capturé depuis cette liste de liens, et la recherche
|
||||
du lien suivant vise explicitement le **premier chapitre** de la liste
|
||||
plutôt qu'une suite arbitraire parmi tous les chapitres listés — sans
|
||||
cette détection, le scraping automatique tend à se perdre dès qu'il
|
||||
démarre sur une page de ce type. S'applique aussi bien au scraping
|
||||
automatique qu'au bouton manuel *"🤖 Lien suivant (IA)"*.
|
||||
- **Limite "0" = illimité / détection automatique** : entrer 0 comme
|
||||
nombre maximum de chapitres tente d'abord de déterminer le nombre
|
||||
total de chapitres à partir du titre/texte de la page de départ (ex.
|
||||
une table des matières, "Chapitre 3 sur 12"...) avant même de
|
||||
commencer — trouvé, ce nombre devient la limite ; indéterminable, le
|
||||
scraping tourne sans limite fixe et s'arrête de lui-même dès qu'aucune
|
||||
suite n'est trouvée (voir garde-fous ci-dessus).
|
||||
- **Prompt personnalisable** : le prompt système utilisé pour le choix du
|
||||
lien suivant (Lien suivant (IA) et Scraping auto) est éditable dans
|
||||
*"⚙️ Configurer l'IA locale"* — utile pour affiner le comportement sur
|
||||
un site particulièrement atypique. Un bouton *"↺ Réinitialiser au
|
||||
défaut"* restaure le texte intégré à l'appli. Laisser le champ tel
|
||||
quel revient à utiliser ce prompt par défaut ; le modifier doit
|
||||
conserver le format de réponse JSON décrit dans le texte
|
||||
(`index`/`same_chapter`), sous peine de casser l'analyse de la réponse
|
||||
du modèle.
|
||||
|
||||
Tous les boutons ci-dessus (projet, capture, aperçu, IA, export) sont
|
||||
regroupés dans la **barre d'outils** en haut de la fenêtre ; le panneau
|
||||
@@ -197,6 +260,7 @@ pdfscrapper/
|
||||
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
|
||||
auto_scrape_dialog.py # fenêtre de suivi du scraping auto (journal + progression)
|
||||
models.py # dataclass Clip (un extrait capturé) + regroupement en chapitres
|
||||
project_io.py # sauvegarde/chargement de projet (.pdfscrap) + pointeur de session
|
||||
run.py # lance l'application
|
||||
|
||||
@@ -27,6 +27,11 @@ class AiConfig:
|
||||
n_ctx: int = 4096
|
||||
n_threads: int = 0 # 0 = auto (os.cpu_count())
|
||||
max_tokens: int = 800
|
||||
# Vide = utilise le prompt intégré par défaut (voir
|
||||
# local_ai.DEFAULT_NEXT_LINK_PROMPT) ; sinon remplace entièrement le
|
||||
# prompt système envoyé au modèle pour le choix du "lien suivant" —
|
||||
# réglable depuis ai_settings_dialog.py.
|
||||
next_link_prompt: str = ""
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
@@ -42,6 +47,7 @@ def load_ai_config() -> AiConfig:
|
||||
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),
|
||||
next_link_prompt=settings.value("next_link_prompt", "", type=str),
|
||||
)
|
||||
finally:
|
||||
settings.endGroup()
|
||||
@@ -56,5 +62,6 @@ def save_ai_config(config: AiConfig) -> None:
|
||||
settings.setValue("n_ctx", config.n_ctx)
|
||||
settings.setValue("n_threads", config.n_threads)
|
||||
settings.setValue("max_tokens", config.max_tokens)
|
||||
settings.setValue("next_link_prompt", config.next_link_prompt)
|
||||
finally:
|
||||
settings.endGroup()
|
||||
@@ -13,18 +13,20 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .ai_config import AiConfig
|
||||
from .local_ai import DEFAULT_NEXT_LINK_PROMPT
|
||||
|
||||
|
||||
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.resize(560, 520)
|
||||
self.config = config
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
@@ -72,7 +74,32 @@ class AiSettingsDialog(QDialog):
|
||||
form.addRow("Tokens max. par réponse", self.max_tokens_spin)
|
||||
|
||||
layout.addLayout(form)
|
||||
layout.addStretch(1)
|
||||
|
||||
prompt_header = QHBoxLayout()
|
||||
prompt_label = QLabel("Prompt — choix du lien suivant")
|
||||
prompt_reset_btn = QPushButton("↺ Réinitialiser au défaut")
|
||||
prompt_reset_btn.setToolTip("Remplace le texte ci-dessous par le prompt intégré par défaut.")
|
||||
prompt_reset_btn.clicked.connect(self._reset_next_link_prompt)
|
||||
prompt_header.addWidget(prompt_label)
|
||||
prompt_header.addStretch(1)
|
||||
prompt_header.addWidget(prompt_reset_btn)
|
||||
layout.addLayout(prompt_header)
|
||||
|
||||
prompt_info = QLabel(
|
||||
"Prompt système envoyé au modèle pour choisir, parmi les liens d'une page, "
|
||||
"celui qui mène à la suite (utilisé par \"🤖 Lien suivant (IA)\" et le "
|
||||
"scraping automatique). Modifiable pour affiner le comportement sur un site "
|
||||
"particulier — mais le format de réponse JSON attendu "
|
||||
'(index/same_chapter) décrit dans le texte doit être conservé, sous peine '
|
||||
"de casser l'analyse de la réponse du modèle."
|
||||
)
|
||||
prompt_info.setWordWrap(True)
|
||||
prompt_info.setStyleSheet("color: grey; font-size: 9pt;")
|
||||
layout.addWidget(prompt_info)
|
||||
|
||||
self.next_link_prompt_edit = QTextEdit()
|
||||
self.next_link_prompt_edit.setPlainText(config.next_link_prompt.strip() or DEFAULT_NEXT_LINK_PROMPT)
|
||||
layout.addWidget(self.next_link_prompt_edit, stretch=1)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
@@ -88,10 +115,14 @@ class AiSettingsDialog(QDialog):
|
||||
if path:
|
||||
self.model_path_edit.setText(path)
|
||||
|
||||
def _reset_next_link_prompt(self) -> None:
|
||||
self.next_link_prompt_edit.setPlainText(DEFAULT_NEXT_LINK_PROMPT)
|
||||
|
||||
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(),
|
||||
next_link_prompt=self.next_link_prompt_edit.toPlainText().strip(),
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Fenêtre de suivi du scraping automatique (IA) : journal horodaté des
|
||||
étapes (extraction, recherche du lien suivant, navigation...) et
|
||||
progression, en complément des messages transitoires de la barre de
|
||||
statut — voir MainWindow._toggle_auto_scrape / _auto_scrape_*.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Callable
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class AutoScrapeDialog(QDialog):
|
||||
"""Non modale : reste affichée sans bloquer la navigation ni le reste
|
||||
de l'appli pendant que le scraping automatique tourne. Une seule
|
||||
instance est réutilisée d'un lancement à l'autre (voir
|
||||
MainWindow._toggle_auto_scrape) — fermer la fenêtre (X) la masque
|
||||
seulement, elle n'arrête pas le scraping en cours."""
|
||||
|
||||
def __init__(self, parent: QWidget | None, on_stop: Callable[[], None]) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Scraping automatique (IA)")
|
||||
self.resize(560, 420)
|
||||
self.setWindowModality(Qt.WindowModality.NonModal)
|
||||
self._on_stop = on_stop
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.status_label = QLabel("En attente…")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setRange(0, 1)
|
||||
self.progress_bar.setValue(0)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
self.log_view = QTextEdit()
|
||||
self.log_view.setReadOnly(True)
|
||||
layout.addWidget(self.log_view, stretch=1)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch(1)
|
||||
self.stop_button = QPushButton("⏹ Arrêter le scraping")
|
||||
self.stop_button.clicked.connect(self._on_stop)
|
||||
buttons.addWidget(self.stop_button)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def reset(self, max_chapters: int) -> None:
|
||||
self.log_view.clear()
|
||||
self.set_progress(0, max_chapters)
|
||||
|
||||
def set_progress(self, count: int, max_chapters: int) -> None:
|
||||
if max_chapters > 0:
|
||||
self.progress_bar.setRange(0, max_chapters)
|
||||
self.progress_bar.setValue(count)
|
||||
self.status_label.setText(f"Chapitre {count}/{max_chapters}")
|
||||
else:
|
||||
# 0 = pas de limite fixe : Qt anime automatiquement une barre
|
||||
# "indéterminée" quand min == max == 0.
|
||||
self.progress_bar.setRange(0, 0)
|
||||
self.status_label.setText(f"Chapitre {count} (illimité)")
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
self.log_view.append(f"[{timestamp}] {message}")
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802 (nom imposé par Qt)
|
||||
event.ignore()
|
||||
self.hide()
|
||||
+274
-29
@@ -1,15 +1,31 @@
|
||||
"""Intégration IA locale (llama-cpp-python) pour assister le scraping.
|
||||
|
||||
Deux capacités, combinables :
|
||||
Quatre 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.
|
||||
mais fragile face aux structures de page inhabituelles. Indique aussi
|
||||
si la suite trouvée est la pagination interne du même chapitre (pages
|
||||
d'un même chapitre réparties sur plusieurs URLs) ou un nouveau
|
||||
chapitre — voir NextLinkResult. Sait aussi viser spécifiquement le
|
||||
premier chapitre depuis une table des matières (voir from_toc).
|
||||
- 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.
|
||||
- estimate_chapter_count : tente de déterminer le nombre total de
|
||||
chapitres d'une histoire à partir de sa page de départ, pour le mode
|
||||
"0 chapitre = limite détectée automatiquement" du scraping
|
||||
automatique (voir MainWindow._toggle_auto_scrape). Best-effort : ne
|
||||
lève jamais d'exception, retourne None si indéterminable.
|
||||
- is_table_of_contents : détecte si la page actuelle est une table des
|
||||
matières/liste de chapitres plutôt que le contenu narratif d'un
|
||||
chapitre — sans quoi le scraping automatique tenterait d'en "extraire
|
||||
le contenu narratif" (résultat vide/aberrant capturé comme un faux
|
||||
chapitre) et choisirait un lien "suivant" arbitraire parmi tous les
|
||||
chapitres listés plutôt que viser le premier. Best-effort, comme
|
||||
estimate_chapter_count.
|
||||
|
||||
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
|
||||
@@ -23,6 +39,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .ai_config import AiConfig
|
||||
|
||||
@@ -35,18 +52,68 @@ class LocalAiError(RuntimeError):
|
||||
class LinkCandidate:
|
||||
text: str
|
||||
href: str
|
||||
# Texte du conteneur parent immédiat du lien, quand il diffère du
|
||||
# texte du lien lui-même — capte les cas où l'indice "suivant" est une
|
||||
# légende/icône voisine plutôt que le texte du lien (ex. pagination
|
||||
# avec une simple flèche sans texte propre). Voir ALL_LINKS_FOR_AI_JS
|
||||
# dans main_window.py, qui applique la même logique que
|
||||
# link_detector.py pour l'heuristique regex.
|
||||
context: str = ""
|
||||
|
||||
|
||||
_NEXT_LINK_SYSTEM_PROMPT = (
|
||||
@dataclass
|
||||
class NextLinkResult:
|
||||
"""Résultat de pick_next_link() : le lien choisi (ou None si aucun ne
|
||||
convient), et — quand un lien est choisi — si l'IA l'estime être une
|
||||
simple suite du MÊME chapitre (pagination interne, ex. page 2/3 d'un
|
||||
chapitre réparti sur plusieurs pages web) ou un NOUVEAU chapitre.
|
||||
Utilisé par le scraping automatique pour relier automatiquement les
|
||||
extraits d'un même chapitre (voir MainWindow._auto_scrape_on_next_link_found
|
||||
et models.group_into_chapters)."""
|
||||
|
||||
candidate: LinkCandidate | None
|
||||
same_chapter: bool = False
|
||||
|
||||
|
||||
# Prompt par défaut, utilisé sauf si l'utilisateur en a défini un autre
|
||||
# dans AiConfig.next_link_prompt (voir ai_settings_dialog.py — un champ
|
||||
# libre y permet d'affiner ce texte, ex. pour un site particulièrement
|
||||
# atypique). Le format de réponse JSON attendu (index + same_chapter) est
|
||||
# décrit explicitement : un prompt personnalisé doit le conserver pour que
|
||||
# _parse_next_link_response() continue à fonctionner.
|
||||
DEFAULT_NEXT_LINK_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"
|
||||
"celui qui mène le plus probablement à la SUITE de ce qui est affiché. Cela "
|
||||
"inclut aussi bien un lien vers le chapitre/la partie suivante qu'un simple "
|
||||
"lien de pagination vers la page suivante D'UN MÊME CHAPITRE réparti sur "
|
||||
"plusieurs pages web (ex. texte '2', '›', 'Page suivante', 'Suite', ou une "
|
||||
"flèche sans texte propre — dans ce cas le \"contexte_proche\" fourni pour "
|
||||
"ce lien peut donner l'indice). Dans tous ces cas, choisis ce lien : c'est "
|
||||
"la suite logique de la lecture.\n\n"
|
||||
"Indique aussi si ce lien mène à la suite du MÊME chapitre (pagination "
|
||||
"interne, pas de nouveau titre/chapitre) ou à un NOUVEAU chapitre distinct, "
|
||||
"via le champ same_chapter (true/false) — utile pour savoir si les deux "
|
||||
"pages doivent être fusionnées en une seule section dans le document final. "
|
||||
"Si on te fournit un extrait de la fin du contenu de la page actuelle, "
|
||||
"utilise-le comme indice supplémentaire pour ce choix : un texte qui "
|
||||
"s'interrompt en plein milieu d'une scène/d'une phrase suggère une simple "
|
||||
"pagination interne (same_chapter=true) ; un texte qui se termine "
|
||||
"proprement (dénouement, dialogue conclu, \"Fin du chapitre\"...) suggère "
|
||||
"plutôt un nouveau chapitre (same_chapter=false).\n\n"
|
||||
"Si aucun lien ne semble mener à une suite (menu, pub, 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.'
|
||||
'{"index": <numéro du lien choisi>, "same_chapter": true|false} ou '
|
||||
'{"index": null}. Aucun autre texte.'
|
||||
)
|
||||
|
||||
# Nombre de caractères de fin de page transmis à pick_next_link — la FIN
|
||||
# du contenu (pas le début) est ce qui renseigne le mieux sur une coupure
|
||||
# probable en milieu de chapitre. Volontairement court : ce n'est qu'un
|
||||
# indice complémentaire, pas le texte à analyser en profondeur (ça, c'est
|
||||
# le rôle d'extract_main_content).
|
||||
_NEXT_LINK_CONTENT_EXCERPT_CHARS = 800
|
||||
|
||||
_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 "
|
||||
@@ -56,6 +123,32 @@ _EXTRACT_SYSTEM_PROMPT = (
|
||||
"rien d'autre : pas de préambule, pas d'explication."
|
||||
)
|
||||
|
||||
_ESTIMATE_CHAPTER_COUNT_PROMPT = (
|
||||
"Tu aides à estimer le nombre TOTAL de chapitres d'une histoire, à partir du "
|
||||
"titre et du texte brut de sa page de départ (page d'accueil du récit, table "
|
||||
"des matières, description...). Cherche un indice EXPLICITE et fiable (ex. "
|
||||
"\"Chapitre 3 sur 12\", une liste/table des matières numérotée, \"Histoire "
|
||||
"terminée — 15 chapitres\", \"Chapters: 20/20\"). Ne devine JAMAIS un nombre "
|
||||
"en l'absence d'un tel indice explicite dans le texte fourni — réponds alors "
|
||||
"que c'est indéterminable.\n\n"
|
||||
"Réponds STRICTEMENT avec un objet JSON sur une seule ligne, de la forme "
|
||||
'{"count": <nombre entier total de chapitres>} ou {"count": null}. Aucun '
|
||||
"autre texte."
|
||||
)
|
||||
|
||||
_IS_TABLE_OF_CONTENTS_PROMPT = (
|
||||
"Tu détermines si une page web est une TABLE DES MATIÈRES / liste de "
|
||||
"chapitres d'une histoire — essentiellement une liste de titres/liens vers "
|
||||
"des chapitres, avec peu ou pas de texte narratif propre — plutôt que le "
|
||||
"contenu narratif d'un chapitre lui-même. Une page d'accueil de série avec "
|
||||
"un résumé suivi d'une liste de chapitres compte aussi comme une table des "
|
||||
"matières. Si le texte fourni raconte une histoire (dialogues, description "
|
||||
"d'actions, narration suivie), ce n'est PAS une table des matières, même "
|
||||
"s'il contient par ailleurs quelques liens de navigation.\n\n"
|
||||
"Réponds STRICTEMENT avec un objet JSON sur une seule ligne, de la forme "
|
||||
'{"is_toc": true} ou {"is_toc": false}. Aucun autre texte.'
|
||||
)
|
||||
|
||||
|
||||
class LocalAi:
|
||||
"""Enveloppe autour de llama-cpp-python, avec chargement paresseux du modèle."""
|
||||
@@ -108,22 +201,76 @@ class LocalAi:
|
||||
# Capacités
|
||||
# ------------------------------------------------------------------ #
|
||||
def pick_next_link(
|
||||
self, candidates: list[LinkCandidate], page_title: str
|
||||
) -> LinkCandidate | None:
|
||||
self,
|
||||
candidates: list[LinkCandidate],
|
||||
page_title: str,
|
||||
current_content: str = "",
|
||||
from_toc: bool = False,
|
||||
on_activity: Callable[[], None] | None = None,
|
||||
) -> NextLinkResult:
|
||||
"""Interroge le modèle pour désigner le lien menant à la suite,
|
||||
parmi `candidates`. Retourne None si aucun ne convient."""
|
||||
parmi `candidates`, et s'il s'agit d'une suite du même chapitre ou
|
||||
d'un nouveau chapitre. `candidate` vaut None si aucun ne convient.
|
||||
|
||||
`current_content` : texte de la page actuellement affichée (idéalement
|
||||
déjà extrait/nettoyé, voir extract_main_content) — sans lui, le
|
||||
modèle ne voit que les titres/textes des liens, ce qui est souvent
|
||||
insuffisant pour juger si la page s'interrompt en plein chapitre ou
|
||||
se termine proprement (voir MainWindow._auto_scrape_on_links, qui
|
||||
réutilise le texte déjà extrait du dernier extrait capturé). Seule
|
||||
la FIN du texte est transmise (voir _NEXT_LINK_CONTENT_EXCERPT_CHARS)
|
||||
: c'est elle qui renseigne sur une coupure probable, pas le début.
|
||||
|
||||
`from_toc` : la page actuelle est une table des matières (voir
|
||||
is_table_of_contents) — le choix ne se fait alors plus par rapport
|
||||
à une "suite" d'un contenu affiché (il n'y en a pas), mais en visant
|
||||
explicitement le TOUT PREMIER chapitre de la liste. `current_content`
|
||||
est ignoré dans ce cas (pas de contenu narratif à juger), et le
|
||||
résultat force same_chapter=False (quitter une table des matières
|
||||
pour un chapitre est toujours un nouveau chapitre).
|
||||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
return NextLinkResult(candidate=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}"
|
||||
lines = []
|
||||
for i, c in enumerate(candidates):
|
||||
line = f"{i}. texte={c.text!r}"
|
||||
if c.context and c.context != c.text:
|
||||
line += f" contexte_proche={c.context!r}"
|
||||
lines.append(line)
|
||||
numbered = "\n".join(lines)
|
||||
|
||||
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
|
||||
user_prompt = f"Titre de la page actuelle : {page_title!r}\n"
|
||||
if from_toc:
|
||||
user_prompt += (
|
||||
"\nCette page est une TABLE DES MATIÈRES / liste de chapitres, pas le "
|
||||
"contenu narratif d'un chapitre : choisis le lien qui mène au TOUT "
|
||||
"PREMIER chapitre de l'histoire (le premier dans l'ordre de lecture, "
|
||||
"généralement le plus petit numéro), pas une suite quelconque.\n"
|
||||
)
|
||||
else:
|
||||
excerpt = current_content.strip()[-_NEXT_LINK_CONTENT_EXCERPT_CHARS:]
|
||||
if excerpt:
|
||||
user_prompt += f"\nFin du contenu de cette page :\n…{excerpt}\n"
|
||||
user_prompt += f"\nLiens candidats :\n{numbered}"
|
||||
|
||||
def extract_main_content(self, page_title: str, raw_text: str, max_input_chars: int = 6000) -> str:
|
||||
system_prompt = self.config.next_link_prompt.strip() or DEFAULT_NEXT_LINK_PROMPT
|
||||
raw = self._complete(llm, system_prompt, user_prompt, max_tokens=80, on_activity=on_activity)
|
||||
index, same_chapter = self._parse_next_link_response(raw, len(candidates))
|
||||
if index is None:
|
||||
return NextLinkResult(candidate=None)
|
||||
if from_toc:
|
||||
same_chapter = False
|
||||
return NextLinkResult(candidate=candidates[index], same_chapter=same_chapter)
|
||||
|
||||
def extract_main_content(
|
||||
self,
|
||||
page_title: str,
|
||||
raw_text: str,
|
||||
max_input_chars: int = 6000,
|
||||
on_activity: Callable[[], None] | None = None,
|
||||
) -> 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)."""
|
||||
@@ -138,36 +285,134 @@ class LocalAi:
|
||||
_EXTRACT_SYSTEM_PROMPT,
|
||||
user_prompt,
|
||||
max_tokens=max(self.config.max_tokens, len(trimmed) // 3),
|
||||
on_activity=on_activity,
|
||||
)
|
||||
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:
|
||||
def estimate_chapter_count(
|
||||
self,
|
||||
page_title: str,
|
||||
raw_text: str,
|
||||
max_input_chars: int = 4000,
|
||||
on_activity: Callable[[], None] | None = None,
|
||||
) -> int | None:
|
||||
"""Tente de déterminer le nombre total de chapitres d'une histoire
|
||||
à partir du titre/texte de sa page de départ (voir
|
||||
MainWindow._toggle_auto_scrape, quand l'utilisateur choisit "0
|
||||
chapitre" = limite à détecter automatiquement). Ne lève jamais
|
||||
d'exception : retourne None si indéterminable OU en cas d'échec de
|
||||
l'inférence — cette détection est un simple bonus, son échec ne
|
||||
doit jamais empêcher le scraping automatique de démarrer."""
|
||||
try:
|
||||
response = llm.create_chat_completion(
|
||||
llm = self._ensure_loaded()
|
||||
trimmed = raw_text.strip()[:max_input_chars]
|
||||
if not trimmed:
|
||||
return None
|
||||
|
||||
user_prompt = f"Titre de la page : {page_title!r}\n\n--- TEXTE BRUT ---\n{trimmed}"
|
||||
raw = self._complete(
|
||||
llm, _ESTIMATE_CHAPTER_COUNT_PROMPT, user_prompt, max_tokens=40, on_activity=on_activity
|
||||
)
|
||||
except LocalAiError:
|
||||
return None
|
||||
|
||||
match = re.search(r'"count"\s*:\s*(null|\d+)', raw)
|
||||
if not match or match.group(1) == "null":
|
||||
return None
|
||||
try:
|
||||
count = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return count if count > 0 else None
|
||||
|
||||
def is_table_of_contents(
|
||||
self,
|
||||
page_title: str,
|
||||
raw_text: str,
|
||||
max_input_chars: int = 3000,
|
||||
on_activity: Callable[[], None] | None = None,
|
||||
) -> bool:
|
||||
"""Détecte si la page actuelle est une table des matières / liste de
|
||||
chapitres plutôt que le contenu narratif d'un chapitre — sans ce
|
||||
garde-fou, le scraping automatique tente d'"extraire le contenu
|
||||
narratif" d'une simple liste de liens (résultat vide ou aberrant,
|
||||
capturé comme un faux chapitre) et choisit un "lien suivant" au
|
||||
hasard parmi tous les chapitres listés plutôt que viser le premier
|
||||
(voir MainWindow._auto_scrape_on_page_text). Best-effort, comme
|
||||
estimate_chapter_count : ne lève jamais, retourne False (page
|
||||
normale, comportement inchangé) en cas d'échec ou d'ambiguïté."""
|
||||
try:
|
||||
llm = self._ensure_loaded()
|
||||
trimmed = raw_text.strip()[:max_input_chars]
|
||||
if not trimmed:
|
||||
return False
|
||||
|
||||
user_prompt = f"Titre de la page : {page_title!r}\n\n--- TEXTE BRUT ---\n{trimmed}"
|
||||
raw = self._complete(
|
||||
llm, _IS_TABLE_OF_CONTENTS_PROMPT, user_prompt, max_tokens=20, on_activity=on_activity
|
||||
)
|
||||
except LocalAiError:
|
||||
return False
|
||||
|
||||
match = re.search(r'"is_toc"\s*:\s*(true|false)', raw)
|
||||
return bool(match and match.group(1) == "true")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
def _complete(
|
||||
self,
|
||||
llm,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
max_tokens: int,
|
||||
on_activity: Callable[[], None] | None = None,
|
||||
) -> str:
|
||||
"""Exécute l'inférence en streaming plutôt qu'en un seul appel
|
||||
bloquant : `on_activity()` est rappelé à chaque fragment généré,
|
||||
et sert de "signe de vie" à l'appelant (voir MainWindow._run_ai_task
|
||||
et son délai d'INACTIVITÉ, pas de durée totale — un modèle lent
|
||||
mais qui progresse encore n'est ainsi jamais interrompu à tort,
|
||||
contrairement à un simple timeout sur la durée totale de l'appel)."""
|
||||
try:
|
||||
chunks = llm.create_chat_completion(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.1,
|
||||
stream=True,
|
||||
)
|
||||
return response["choices"][0]["message"]["content"] or ""
|
||||
parts: list[str] = []
|
||||
for chunk in chunks:
|
||||
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
||||
piece = delta.get("content")
|
||||
if piece:
|
||||
parts.append(piece)
|
||||
if on_activity is not None:
|
||||
on_activity()
|
||||
return "".join(parts)
|
||||
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
|
||||
def _parse_next_link_response(raw: str, count: int) -> tuple[int | None, bool]:
|
||||
index_match = re.search(r'"index"\s*:\s*(null|\d+)', raw)
|
||||
if not index_match or index_match.group(1) == "null":
|
||||
return None, False
|
||||
try:
|
||||
index = int(match.group(1))
|
||||
index = int(index_match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
return index if 0 <= index < count else None
|
||||
return None, False
|
||||
if not (0 <= index < count):
|
||||
return None, False
|
||||
|
||||
same_chapter = False
|
||||
chapter_match = re.search(r'"same_chapter"\s*:\s*(true|false)', raw)
|
||||
if chapter_match:
|
||||
same_chapter = chapter_match.group(1) == "true"
|
||||
return index, same_chapter
|
||||
+323
-40
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -32,11 +33,12 @@ from PySide6.QtWidgets import (
|
||||
from .ai_config import AiConfig, load_ai_config, save_ai_config
|
||||
from .ai_settings_dialog import AiSettingsDialog
|
||||
from .ai_worker import AiTaskThread
|
||||
from .auto_scrape_dialog import AutoScrapeDialog
|
||||
from .block_picker import BLOCK_PICKER_INIT_JS, SET_STICKY_JS_TEMPLATE
|
||||
from .bridge import Bridge, build_bridge_init_script
|
||||
from .clip_table import ClipTable
|
||||
from .link_detector import LINK_DETECTOR_INIT_JS, SELECTION_JS
|
||||
from .local_ai import LinkCandidate, LocalAi, LocalAiError
|
||||
from .local_ai import LinkCandidate, LocalAi, LocalAiError, NextLinkResult
|
||||
from .models import Clip
|
||||
from .pdf_builder import build_pdf
|
||||
from .pdf_style import PdfStyle
|
||||
@@ -64,19 +66,38 @@ PAGE_TEXT_FOR_AI_JS = """
|
||||
})();
|
||||
"""
|
||||
|
||||
# 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.
|
||||
# Liste large de liens candidats (texte + href + contexte proche), 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. Le contexte (texte du conteneur
|
||||
# parent immédiat) reprend la même logique que link_detector.py : sur
|
||||
# certains sites, un lien de pagination interne à un chapitre réparti sur
|
||||
# plusieurs pages n'a lui-même qu'un texte quasi vide (icône, "›", "2")
|
||||
# — sans ce contexte, l'IA n'a aucun indice pour le distinguer d'un lien
|
||||
# sans rapport.
|
||||
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 (text.length > 100) { return; }
|
||||
if (seen[a.href]) { return; }
|
||||
|
||||
var context = '';
|
||||
if (a.parentElement) {
|
||||
var contextText = (a.parentElement.innerText || a.parentElement.textContent || '').trim();
|
||||
if (contextText && contextText.length <= 150 && contextText !== text) {
|
||||
context = contextText;
|
||||
}
|
||||
}
|
||||
// Un lien sans texte propre n'est retenu que s'il a au moins un
|
||||
// contexte proche exploitable (ex. icône de pagination) — sinon
|
||||
// aucun signal à donner à l'IA.
|
||||
if (!text && !context) { return; }
|
||||
|
||||
seen[a.href] = true;
|
||||
links.push({ text: text, href: a.href });
|
||||
links.push({ text: text, href: a.href, context: context });
|
||||
});
|
||||
return JSON.stringify(links.slice(0, 150));
|
||||
})();
|
||||
@@ -84,6 +105,7 @@ ALL_LINKS_FOR_AI_JS = """
|
||||
|
||||
AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS = 20
|
||||
AUTO_SCRAPE_SETTLE_DELAY_MS = 1500 # laisse le temps au contenu dynamique de se charger
|
||||
AUTO_SCRAPE_AI_TIMEOUT_MS = 120_000 # délai d'INACTIVITÉ (pas la durée totale) avant d'abandonner l'IA
|
||||
AUTOSAVE_INTERVAL_MS = 60_000
|
||||
|
||||
HOME_URL = "https://www.google.com/"
|
||||
@@ -112,10 +134,20 @@ class MainWindow(QMainWindow):
|
||||
self.ai_config = load_ai_config()
|
||||
self.local_ai = LocalAi(self.ai_config)
|
||||
self._ai_thread: AiTaskThread | None = None
|
||||
self._ai_task_id = 0
|
||||
self._ai_task_done = True
|
||||
self._ai_task_silent_on_failure = False
|
||||
self._ai_task_last_activity = 0.0
|
||||
self._ai_task_watchdog: QTimer | 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.auto_scrape_dialog: AutoScrapeDialog | None = None
|
||||
# Dernier extrait ajouté par le scraping automatique — sert à le
|
||||
# marquer linked_to_next si l'IA juge la page suivante comme la
|
||||
# suite du même chapitre (voir _auto_scrape_on_next_link_found).
|
||||
self._auto_scrape_last_clip: Clip | None = None
|
||||
|
||||
# Dossier data de l'appli : persistance QtWebEngine (voir
|
||||
# _build_webview) et fichier de récupération de session.
|
||||
@@ -544,7 +576,7 @@ class MainWindow(QMainWindow):
|
||||
3000,
|
||||
)
|
||||
|
||||
def _add_clip(self, text: str) -> None:
|
||||
def _add_clip(self, text: str) -> Clip:
|
||||
clip = Clip(
|
||||
text=text.strip(),
|
||||
title=self.webview.title() or self.webview.url().toString(),
|
||||
@@ -587,6 +619,7 @@ class MainWindow(QMainWindow):
|
||||
self.clip_list.select_row(insert_at)
|
||||
|
||||
self.statusBar().showMessage("Extrait ajouté au PDF.", 3000)
|
||||
return clip
|
||||
|
||||
def _refresh_clip_list(self) -> None:
|
||||
# ClipTable.set_clips préserve elle-même la sélection courante par
|
||||
@@ -873,10 +906,32 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
self.statusBar().showMessage("IA : lecture de la page…", 3000)
|
||||
# Lit d'abord le texte de la page (nécessaire pour juger si le
|
||||
# contenu s'interrompt en plein chapitre, ou si la page est une
|
||||
# table des matières — voir LocalAi.pick_next_link), puis les
|
||||
# liens — même ordre que le scraping automatique.
|
||||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._on_page_text_for_ai_pick_next_link)
|
||||
|
||||
def _on_links_for_ai_pick(self, links_json: str | None) -> None:
|
||||
def _on_page_text_for_ai_pick_next_link(self, page_text: str | None) -> None:
|
||||
page_text = page_text or ""
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
self.statusBar().showMessage("IA : analyse de la page…", 3000)
|
||||
self._run_ai_task(
|
||||
self.local_ai.is_table_of_contents,
|
||||
args=(title, page_text),
|
||||
on_success=lambda is_toc: self._on_toc_checked_for_ai_pick_next_link(page_text, bool(is_toc)),
|
||||
busy_message="IA : analyse de la page…",
|
||||
)
|
||||
|
||||
def _on_toc_checked_for_ai_pick_next_link(self, page_text: str, is_toc: bool) -> None:
|
||||
self.statusBar().showMessage("IA : lecture des liens de la page…", 3000)
|
||||
self.webview.page().runJavaScript(
|
||||
ALL_LINKS_FOR_AI_JS,
|
||||
lambda links_json: self._on_links_for_ai_pick(page_text, is_toc, links_json),
|
||||
)
|
||||
|
||||
def _on_links_for_ai_pick(self, page_text: str, is_toc: bool, links_json: str | None) -> None:
|
||||
try:
|
||||
raw_links = json.loads(links_json) if links_json else []
|
||||
except (TypeError, ValueError):
|
||||
@@ -885,26 +940,33 @@ class MainWindow(QMainWindow):
|
||||
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]
|
||||
candidates = [
|
||||
LinkCandidate(text=l["text"], href=l["href"], context=l.get("context", ""))
|
||||
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),
|
||||
args=(candidates, title, page_text, is_toc),
|
||||
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:
|
||||
def _on_ai_pick_next_link_succeeded(self, result: object) -> None:
|
||||
result: NextLinkResult
|
||||
if result.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
|
||||
candidate = result.candidate
|
||||
chapter_note = (
|
||||
"\n\n(suite du même chapitre)" if result.same_chapter else "\n\n(nouveau chapitre)"
|
||||
)
|
||||
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 ?",
|
||||
f"L'IA suggère :\n\n« {candidate.text} »\n{candidate.href}{chapter_note}\n\nY aller maintenant ?",
|
||||
)
|
||||
if confirm == QMessageBox.StandardButton.Yes:
|
||||
self.webview.setUrl(QUrl(candidate.href))
|
||||
@@ -912,25 +974,103 @@ class MainWindow(QMainWindow):
|
||||
# ------------------------------------------------------------------ #
|
||||
# IA locale — exécution en arrière-plan
|
||||
# ------------------------------------------------------------------ #
|
||||
def _run_ai_task(self, fn, args: tuple, on_success, busy_message: str) -> None:
|
||||
def _run_ai_task(
|
||||
self,
|
||||
fn,
|
||||
args: tuple,
|
||||
on_success,
|
||||
busy_message: str,
|
||||
timeout_ms: int | None = None,
|
||||
silent_on_failure: bool = False,
|
||||
) -> None:
|
||||
"""Lance `fn(*args)` sur un thread dédié (voir ai_worker.py).
|
||||
|
||||
`timeout_ms` : filet de sécurité pour le scraping automatique — un
|
||||
watchdog (voir _on_ai_task_watchdog_tick) surveille non pas la durée
|
||||
TOTALE de l'appel, mais l'INACTIVITÉ : `fn` reçoit un callback
|
||||
`on_activity` (voir LocalAi._complete, qui l'appelle à chaque
|
||||
fragment généré en streaming) qui réarme le délai à chaque signe de
|
||||
vie du modèle. Un modèle lent mais qui progresse encore n'est ainsi
|
||||
jamais interrompu à tort ; seul un silence prolongé (aucun nouveau
|
||||
fragment pendant `timeout_ms`) déclenche l'arrêt. Le thread
|
||||
orphelin, lui, continue en arrière-plan mais son résultat tardif
|
||||
est ignoré (voir `task_id` : un identifiant unique par appel, pour
|
||||
qu'un watchdog resté actif après la fin d'un appel précédent ne
|
||||
puisse jamais interférer avec un appel suivant).
|
||||
|
||||
`silent_on_failure` : n'affiche pas la boîte de dialogue d'erreur
|
||||
(utilisé en scraping automatique, où personne ne surveille
|
||||
forcément l'écran pour la fermer).
|
||||
"""
|
||||
self.statusBar().showMessage(busy_message)
|
||||
self._set_ai_actions_enabled(False)
|
||||
self._ai_task_id += 1
|
||||
task_id = self._ai_task_id
|
||||
self._ai_task_done = False
|
||||
self._ai_task_silent_on_failure = silent_on_failure
|
||||
|
||||
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_actions_enabled(True))
|
||||
extra_kwargs = {}
|
||||
if timeout_ms is not None:
|
||||
self._ai_task_last_activity = time.monotonic()
|
||||
extra_kwargs["on_activity"] = self._mark_ai_task_activity
|
||||
|
||||
self._ai_thread = AiTaskThread(fn, *args, **extra_kwargs)
|
||||
self._ai_thread.succeeded.connect(
|
||||
lambda result: self._on_ai_task_succeeded(task_id, on_success, result)
|
||||
)
|
||||
self._ai_thread.failed.connect(lambda message: self._on_ai_task_failed(task_id, message))
|
||||
self._ai_thread.finished.connect(self._on_ai_task_finished)
|
||||
self._ai_thread.start()
|
||||
|
||||
def _on_ai_task_succeeded(self, on_success, result: object) -> None:
|
||||
if timeout_ms is not None:
|
||||
watchdog = QTimer(self)
|
||||
watchdog.setInterval(min(timeout_ms, 5000))
|
||||
watchdog.timeout.connect(lambda: self._on_ai_task_watchdog_tick(task_id, timeout_ms, watchdog))
|
||||
watchdog.start()
|
||||
self._ai_task_watchdog = watchdog
|
||||
|
||||
def _mark_ai_task_activity(self) -> None:
|
||||
# Appelé depuis le thread IA en arrière-plan (voir LocalAi._complete)
|
||||
# — simple écriture d'un float, suffisamment sûre sous le GIL pour
|
||||
# ce cas d'usage (un rendez-vous approximatif suffit, pas besoin
|
||||
# d'un verrou pour un compteur de dernière activité).
|
||||
self._ai_task_last_activity = time.monotonic()
|
||||
|
||||
def _on_ai_task_succeeded(self, task_id: int, on_success, result: object) -> None:
|
||||
if task_id == self._ai_task_id:
|
||||
self._ai_task_done = True
|
||||
on_success(result)
|
||||
|
||||
def _on_ai_task_failed(self, message: str) -> None:
|
||||
def _on_ai_task_failed(self, task_id: int, message: str) -> None:
|
||||
if task_id == self._ai_task_id:
|
||||
self._ai_task_done = True
|
||||
self.statusBar().clearMessage()
|
||||
QMessageBox.warning(self, "IA locale", message)
|
||||
if not self._ai_task_silent_on_failure:
|
||||
QMessageBox.warning(self, "IA locale", message)
|
||||
if self.auto_scrape_active:
|
||||
self._stop_auto_scrape(f"Scraping automatique arrêté : {message}")
|
||||
|
||||
def _on_ai_task_finished(self) -> None:
|
||||
self._set_ai_actions_enabled(True)
|
||||
|
||||
def _on_ai_task_watchdog_tick(self, task_id: int, timeout_ms: int, watchdog: QTimer) -> None:
|
||||
# Un appel plus récent a démarré depuis, ou celui-ci a déjà répondu
|
||||
# (succès ou échec) : ce watchdog est obsolète, il s'arrête lui-même.
|
||||
if task_id != self._ai_task_id or self._ai_task_done:
|
||||
watchdog.stop()
|
||||
watchdog.deleteLater()
|
||||
return
|
||||
inactive_ms = (time.monotonic() - self._ai_task_last_activity) * 1000
|
||||
if inactive_ms < timeout_ms:
|
||||
return # l'IA a encore donné signe de vie récemment, on continue d'attendre
|
||||
watchdog.stop()
|
||||
watchdog.deleteLater()
|
||||
if self.auto_scrape_active:
|
||||
self._stop_auto_scrape(
|
||||
f"Scraping automatique arrêté : aucune réponse de l'IA depuis "
|
||||
f"{timeout_ms // 1000}s (analyse probablement bloquée)."
|
||||
)
|
||||
|
||||
def _set_ai_actions_enabled(self, enabled: bool) -> None:
|
||||
self.ai_extract_action.setEnabled(enabled)
|
||||
self.ai_next_link_action.setEnabled(enabled)
|
||||
@@ -951,35 +1091,102 @@ class MainWindow(QMainWindow):
|
||||
max_chapters, ok = QInputDialog.getInt(
|
||||
self,
|
||||
"Scraping automatique",
|
||||
"Nombre maximum de chapitres à capturer automatiquement :",
|
||||
"Nombre maximum de chapitres à capturer automatiquement\n"
|
||||
"(0 = illimité : l'IA tente de détecter le nombre total de "
|
||||
"chapitres sur la page actuelle avant de démarrer ; à défaut, "
|
||||
"le scraping s'arrête seulement quand plus aucune suite n'est "
|
||||
"trouvée) :",
|
||||
self.auto_scrape_max_chapters,
|
||||
1,
|
||||
100,
|
||||
0,
|
||||
500,
|
||||
)
|
||||
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_last_clip = None
|
||||
self.auto_scrape_action.setText("⏹ Arrêter le scraping")
|
||||
self.statusBar().showMessage("Scraping automatique démarré…", 3000)
|
||||
|
||||
# Fenêtre de suivi (journal + progression) : une seule instance
|
||||
# réutilisée d'un lancement à l'autre plutôt que recréée à chaque
|
||||
# fois — voir auto_scrape_dialog.py.
|
||||
if self.auto_scrape_dialog is None:
|
||||
self.auto_scrape_dialog = AutoScrapeDialog(self, on_stop=self._toggle_auto_scrape)
|
||||
self.auto_scrape_dialog.show()
|
||||
self.auto_scrape_dialog.raise_()
|
||||
self.auto_scrape_dialog.activateWindow()
|
||||
|
||||
if max_chapters == 0:
|
||||
# Limite laissée à 0 pour l'instant : ajustée juste après par
|
||||
# _auto_scrape_on_chapter_count_estimated() si la détection
|
||||
# réussit, sinon le scraping reste illimité (voir le garde-fou
|
||||
# `> 0` dans _auto_scrape_on_extracted).
|
||||
self.auto_scrape_max_chapters = 0
|
||||
self.auto_scrape_dialog.reset(0)
|
||||
self.auto_scrape_dialog.log(
|
||||
"Limite illimitée — tentative de détection automatique du nombre de "
|
||||
"chapitres sur la page actuelle…"
|
||||
)
|
||||
self.statusBar().showMessage("Scraping automatique : détection du nombre de chapitres…", 3000)
|
||||
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._auto_scrape_on_page_text_for_estimate)
|
||||
else:
|
||||
self.auto_scrape_max_chapters = max_chapters
|
||||
self.auto_scrape_dialog.reset(max_chapters)
|
||||
self.auto_scrape_dialog.log(f"Démarrage — limite : {max_chapters} chapitre(s).")
|
||||
self.statusBar().showMessage("Scraping automatique démarré…", 3000)
|
||||
self._auto_scrape_extract_current_page()
|
||||
|
||||
def _auto_scrape_on_page_text_for_estimate(self, page_text: str | None) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
self._run_ai_task(
|
||||
self.local_ai.estimate_chapter_count,
|
||||
args=(title, page_text or ""),
|
||||
on_success=self._auto_scrape_on_chapter_count_estimated,
|
||||
busy_message="Scraping automatique : détection du nombre de chapitres (IA)…",
|
||||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||||
silent_on_failure=True,
|
||||
)
|
||||
|
||||
def _auto_scrape_on_chapter_count_estimated(self, count: object) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
if isinstance(count, int) and count > 0:
|
||||
self.auto_scrape_max_chapters = count
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.set_progress(0, count)
|
||||
self.auto_scrape_dialog.log(f"Nombre de chapitres détecté automatiquement : {count}.")
|
||||
elif self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log(
|
||||
"Nombre de chapitres indéterminable sur cette page — scraping sans "
|
||||
"limite fixe (s'arrêtera de lui-même quand plus aucune suite ne sera "
|
||||
"trouvée)."
|
||||
)
|
||||
self._auto_scrape_extract_current_page()
|
||||
|
||||
def _stop_auto_scrape(self, message: str) -> None:
|
||||
self.auto_scrape_active = False
|
||||
self.auto_scrape_action.setText("🔁 Scraping auto (IA)")
|
||||
self.statusBar().showMessage(message, 6000)
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log(message)
|
||||
|
||||
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}…"
|
||||
page_label = self.webview.title() or self.webview.url().toString()
|
||||
chapter_ref = (
|
||||
f"{self.auto_scrape_count + 1}/{self.auto_scrape_max_chapters}"
|
||||
if self.auto_scrape_max_chapters > 0
|
||||
else f"{self.auto_scrape_count + 1}"
|
||||
)
|
||||
self.statusBar().showMessage(f"Scraping automatique : extraction du chapitre {chapter_ref}…")
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log(f"Chapitre {chapter_ref} : lecture de la page « {page_label} »…")
|
||||
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:
|
||||
@@ -989,20 +1196,61 @@ class MainWindow(QMainWindow):
|
||||
self._stop_auto_scrape("Scraping automatique arrêté : page sans texte exploitable.")
|
||||
return
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log("Analyse de la page : table des matières ou chapitre ? (IA)…")
|
||||
# Vérifie d'abord si la page est une table des matières (liste de
|
||||
# chapitres) plutôt qu'un chapitre en soi — sinon on tenterait d'en
|
||||
# "extraire le contenu narratif" (résultat vide/aberrant, capturé
|
||||
# comme un faux chapitre) et on choisirait ensuite un lien "suivant"
|
||||
# arbitraire parmi tous les chapitres listés plutôt que viser le
|
||||
# premier. Voir LocalAi.is_table_of_contents.
|
||||
self._run_ai_task(
|
||||
self.local_ai.is_table_of_contents,
|
||||
args=(title, page_text),
|
||||
on_success=lambda is_toc: self._auto_scrape_on_toc_checked(page_text, title, bool(is_toc)),
|
||||
busy_message="Scraping automatique : analyse de la page (IA)…",
|
||||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||||
silent_on_failure=True,
|
||||
)
|
||||
|
||||
def _auto_scrape_on_toc_checked(self, page_text: str, title: str, is_toc: bool) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
if is_toc:
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log(
|
||||
"↳ Table des matières détectée : rien à capturer ici — recherche du "
|
||||
"premier chapitre plutôt que d'une suite."
|
||||
)
|
||||
self.webview.page().runJavaScript(
|
||||
ALL_LINKS_FOR_AI_JS,
|
||||
lambda links_json: self._auto_scrape_on_links(links_json, from_toc=True),
|
||||
)
|
||||
return
|
||||
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log("Extraction du contenu principal (IA)…")
|
||||
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}…",
|
||||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||||
silent_on_failure=True,
|
||||
)
|
||||
|
||||
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_last_clip = self._add_clip(str(extracted_text))
|
||||
self.auto_scrape_count += 1
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.set_progress(self.auto_scrape_count, self.auto_scrape_max_chapters)
|
||||
self.auto_scrape_dialog.log(f"Chapitre {self.auto_scrape_count} capturé et ajouté au PDF.")
|
||||
|
||||
if self.auto_scrape_count >= self.auto_scrape_max_chapters:
|
||||
# 0 = pas de limite fixe (détectée indéterminable, ou volontairement
|
||||
# illimité) : on ne s'arrête alors que faute de suite trouvée.
|
||||
if self.auto_scrape_max_chapters > 0 and 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."
|
||||
@@ -1011,7 +1259,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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:
|
||||
def _auto_scrape_on_links(self, links_json: str | None, from_toc: bool = False) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
try:
|
||||
@@ -1022,26 +1270,44 @@ class MainWindow(QMainWindow):
|
||||
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]
|
||||
candidates = [
|
||||
LinkCandidate(text=l["text"], href=l["href"], context=l.get("context", ""))
|
||||
for l in raw_links
|
||||
]
|
||||
title = self.webview.title() or self.webview.url().toString()
|
||||
if self.auto_scrape_dialog is not None:
|
||||
target = "premier chapitre" if from_toc else "lien suivant"
|
||||
self.auto_scrape_dialog.log(f"Recherche du {target} parmi {len(candidates)} lien(s) (IA)…")
|
||||
# Réutilise le texte déjà extrait du chapitre qu'on vient de
|
||||
# capturer (voir _auto_scrape_on_extracted) plutôt que de le
|
||||
# relire : c'est le contenu réel affiché à l'écran, dont la fin
|
||||
# sert d'indice pour juger si la page suivante continue le même
|
||||
# chapitre (voir LocalAi.pick_next_link). Sans objet depuis une
|
||||
# table des matières (pas de contenu narratif à juger).
|
||||
current_content = (
|
||||
"" if from_toc or self._auto_scrape_last_clip is None else self._auto_scrape_last_clip.text
|
||||
)
|
||||
self._run_ai_task(
|
||||
self.local_ai.pick_next_link,
|
||||
args=(candidates, title),
|
||||
args=(candidates, title, current_content, from_toc),
|
||||
on_success=self._auto_scrape_on_next_link_found,
|
||||
busy_message="Scraping automatique : recherche du chapitre suivant (IA)…",
|
||||
timeout_ms=AUTO_SCRAPE_AI_TIMEOUT_MS,
|
||||
silent_on_failure=True,
|
||||
)
|
||||
|
||||
def _auto_scrape_on_next_link_found(self, candidate: object) -> None:
|
||||
def _auto_scrape_on_next_link_found(self, result: object) -> None:
|
||||
if not self.auto_scrape_active:
|
||||
return
|
||||
if candidate is None:
|
||||
result: NextLinkResult
|
||||
if result.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
|
||||
candidate = result.candidate
|
||||
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 "
|
||||
@@ -1049,6 +1315,23 @@ class MainWindow(QMainWindow):
|
||||
)
|
||||
return
|
||||
|
||||
# Chapitre réparti sur plusieurs pages : l'IA a jugé que la page
|
||||
# suivante est la suite du MÊME chapitre plutôt qu'un nouveau —
|
||||
# on relie automatiquement l'extrait qu'on vient de capturer (🔗
|
||||
# colonne Lien) pour qu'ils forment une seule section à l'export,
|
||||
# sans intervention manuelle. Voir models.group_into_chapters.
|
||||
if result.same_chapter and self._auto_scrape_last_clip is not None:
|
||||
self._auto_scrape_last_clip.linked_to_next = True
|
||||
self._refresh_clip_list()
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log(
|
||||
"↳ Page suivante détectée comme suite du même chapitre — extraits liés "
|
||||
"automatiquement (🔗)."
|
||||
)
|
||||
|
||||
if self.auto_scrape_dialog is not None:
|
||||
self.auto_scrape_dialog.log(f"Lien suivant : « {candidate.text} » → {candidate.href}")
|
||||
|
||||
def on_next_page_loaded(ok: bool) -> None:
|
||||
self.webview.loadFinished.disconnect(on_next_page_loaded)
|
||||
if not self.auto_scrape_active:
|
||||
|
||||
Reference in new issue
Block a user