fix errors

This commit is contained in:
ange committed 2026-09-09 15:59:41 +02:00
1 parent 2427c3525e
commit df11fa97bf
7 files changed
+795 -77

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(QT_QPA_PLATFORM=offscreen QTWEBENGINE_DISABLE_SANDBOX=1 PYTHONIOENCODING=utf-8 .venv/Scripts/python.exe -c ' *)"
]
}
}
+70 -6
View File
@@ -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
+7
View File
@@ -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()
+33 -2
View File
@@ -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(),
)
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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: