From df11fa97bff16b07d99d0e8a7af24bdb3bfa568f Mon Sep 17 00:00:00 2001 From: Ange-Marie MAURIN Date: Wed, 9 Sep 2026 15:59:41 +0200 Subject: [PATCH] fix errors --- .claude/settings.json | 7 + README.md | 76 ++++++- pdfscrapper/ai_config.py | 7 + pdfscrapper/ai_settings_dialog.py | 35 ++- pdfscrapper/auto_scrape_dialog.py | 81 +++++++ pdfscrapper/local_ai.py | 303 ++++++++++++++++++++++--- pdfscrapper/main_window.py | 363 ++++++++++++++++++++++++++---- 7 files changed, 795 insertions(+), 77 deletions(-) create mode 100644 .claude/settings.json create mode 100644 pdfscrapper/auto_scrape_dialog.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..044a464 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(QT_QPA_PLATFORM=offscreen QTWEBENGINE_DISABLE_SANDBOX=1 PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe -c ' *)" + ] + } +} diff --git a/README.md b/README.md index 12eaa15..8dfc0bf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pdfscrapper/ai_config.py b/pdfscrapper/ai_config.py index 6f31b72..6680674 100644 --- a/pdfscrapper/ai_config.py +++ b/pdfscrapper/ai_config.py @@ -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() diff --git a/pdfscrapper/ai_settings_dialog.py b/pdfscrapper/ai_settings_dialog.py index d416cc9..59d6bb9 100644 --- a/pdfscrapper/ai_settings_dialog.py +++ b/pdfscrapper/ai_settings_dialog.py @@ -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(), ) diff --git a/pdfscrapper/auto_scrape_dialog.py b/pdfscrapper/auto_scrape_dialog.py new file mode 100644 index 0000000..2bf9058 --- /dev/null +++ b/pdfscrapper/auto_scrape_dialog.py @@ -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() diff --git a/pdfscrapper/local_ai.py b/pdfscrapper/local_ai.py index 93d0909..2b19dde 100644 --- a/pdfscrapper/local_ai.py +++ b/pdfscrapper/local_ai.py @@ -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": } ou {"index": null}. Aucun autre texte.' + '{"index": , "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": } 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 diff --git a/pdfscrapper/main_window.py b/pdfscrapper/main_window.py index f36265f..e1989d1 100644 --- a/pdfscrapper/main_window.py +++ b/pdfscrapper/main_window.py @@ -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: