Files
PdfScrapper/pdfscrapper/main_window.py
T
2026-09-09 15:59:41 +02:00

1380 lines
61 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fenêtre principale : navigateur + panneau latéral de capture PDF."""
from __future__ import annotations
import json
import time
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QStandardPaths, QTimer, QUrl, Qt
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEnginePage, QWebEngineProfile, QWebEngineScript
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import (
QCheckBox,
QDockWidget,
QFileDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMessageBox,
QPushButton,
QToolBar,
QVBoxLayout,
QWidget,
)
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, NextLinkResult
from .models import Clip
from .pdf_builder import build_pdf
from .pdf_style import PdfStyle
from .preview_window import PdfPreviewWindow
from .project_io import (
PROJECT_FILE_FILTER,
PROJECT_FILE_SUFFIX,
ProjectState,
load_last_session_path,
load_project,
save_last_session_path,
save_project,
)
# Texte brut de la page, tronqué et débarrassé des zones techniques
# évidentes (script/style/nav) avant d'être envoyé au modèle local pour
# extraction — réduit le bruit et la taille du prompt.
PAGE_TEXT_FOR_AI_JS = """
(function () {
var clone = document.body.cloneNode(true);
clone.querySelectorAll('script, style, nav, header, footer').forEach(function (el) {
el.remove();
});
return (clone.innerText || clone.textContent || '').trim();
})();
"""
# Liste large de liens candidats (texte + href + 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.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, context: context });
});
return JSON.stringify(links.slice(0, 150));
})();
"""
AUTO_SCRAPE_DEFAULT_MAX_CHAPTERS = 20
AUTO_SCRAPE_SETTLE_DELAY_MS = 1500 # laisse le temps au contenu dynamique de se charger
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/"
class BrowserPage(QWebEnginePage):
"""Page web qui ouvre les liens target="_blank" dans la même vue
plutôt que de les laisser disparaître silencieusement."""
def createWindow(self, _window_type): # noqa: N802 (nom imposé par Qt)
return self
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("PdfScrapper")
self.clips: list[Clip] = []
self.last_suggested_relnext: str | None = None
self.block_picker_enabled = False
self.insert_above = True
self.pdf_style = PdfStyle()
self.preview_window: PdfPreviewWindow | None = None
self.ai_config = load_ai_config()
self.local_ai = LocalAi(self.ai_config)
self._ai_thread: AiTaskThread | None = None
self._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.
self.app_data_dir = Path(
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
) / "pdfscrapper"
self.autosave_path = self.app_data_dir / f"session_autosave{PROJECT_FILE_SUFFIX}"
# Fichier projet explicitement nommé par l'utilisateur (Enregistrer
# sous / Ouvrir). Tant qu'il vaut None, l'autosave écrit vers
# self.autosave_path — voir _autosave().
self.project_path: Path | None = None
self._build_webview()
self._build_toolbar()
self._build_sidebar()
if not self._restore_last_session():
self.webview.setUrl(QUrl(HOME_URL))
self._update_project_label()
self._autosave_timer = QTimer(self)
self._autosave_timer.setInterval(AUTOSAVE_INTERVAL_MS)
self._autosave_timer.timeout.connect(self._autosave)
self._autosave_timer.start()
def closeEvent(self, event) -> None: # noqa: N802 (nom imposé par Qt)
# Sauvegarde silencieuse à la fermeture — même mécanisme que
# l'autosave périodique, pas de boîte de dialogue bloquante.
self._autosave()
super().closeEvent(event)
# ------------------------------------------------------------------ #
# Construction de l'UI
# ------------------------------------------------------------------ #
def _build_webview(self) -> None:
storage_dir = self.app_data_dir
storage_dir.mkdir(parents=True, exist_ok=True)
# Pas encore parenté à `self` ici — voir plus bas, juste après la
# création de self.webview, pour l'explication.
profile = QWebEngineProfile("pdfscrapper_profile")
profile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.ForcePersistentCookies
)
profile.setPersistentStoragePath(str(storage_dir))
profile.setCachePath(str(storage_dir / "cache"))
# Injecté au tout début de chaque page pour établir le pont
# QWebChannel (window.pdfscrapperBridge) utilisé par le mode
# "capture au survol" — voir bridge.py / block_picker.py.
bridge_script = QWebEngineScript()
bridge_script.setName("pdfscrapper_bridge_init")
bridge_script.setSourceCode(build_bridge_init_script())
bridge_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
bridge_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
bridge_script.setRunsOnSubFrames(False)
profile.scripts().insert(bridge_script)
# Script persistant du mode "capture au survol" (surlignage de
# bloc + activation par Ctrl maintenu ou par bouton) — voir
# block_picker.py pour le détail du fonctionnement.
block_picker_script = QWebEngineScript()
block_picker_script.setName("pdfscrapper_block_picker_init")
block_picker_script.setSourceCode(BLOCK_PICKER_INIT_JS)
block_picker_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
block_picker_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
block_picker_script.setRunsOnSubFrames(False)
profile.scripts().insert(block_picker_script)
# Script persistant de détection des liens de "suite" — scanne en
# continu via un MutationObserver (plutôt qu'une seule fois au
# chargement) pour détecter aussi les liens ajoutés dynamiquement
# par la page elle-même (SPA, contenu chargé après coup). Voir
# link_detector.py.
link_detector_script = QWebEngineScript()
link_detector_script.setName("pdfscrapper_link_detector_init")
link_detector_script.setSourceCode(LINK_DETECTOR_INIT_JS)
link_detector_script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation)
link_detector_script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
link_detector_script.setRunsOnSubFrames(False)
profile.scripts().insert(link_detector_script)
self.webview = QWebEngineView(self)
self.webview.setPage(BrowserPage(profile, self.webview))
self.setCentralWidget(self.webview)
# Parenté à `self` seulement maintenant, APRÈS self.webview : Qt
# détruit les enfants d'un QObject dans l'ordre où ils ont été
# parentés, donc le profile doit apparaître après la vue web dans
# cette liste pour être détruit après elle (et donc après sa
# page) à la fermeture de l'appli. Dans l'autre sens, on obtient
# l'avertissement "Release of profile requested but
# WebEnginePage still not deleted" (la page tente encore d'écrire
# dans un profile déjà libéré).
profile.setParent(self)
self.web_profile = profile
self.bridge = Bridge(self)
self.bridge.block_captured.connect(self._handle_block_capture)
self.bridge.links_detected.connect(self._on_links_detected)
self.web_channel = QWebChannel(self)
self.web_channel.registerObject("pdfscrapperBridge", self.bridge)
self.webview.page().setWebChannel(self.web_channel)
self.webview.urlChanged.connect(self._on_url_changed)
self.webview.loadFinished.connect(self._on_load_finished)
self.webview.titleChanged.connect(self._on_title_changed)
def _build_toolbar(self) -> None:
# Deux barres d'outils séparées (plutôt qu'une seule mêlant tout) :
# la barre d'actions (projet/capture/IA/export) au-dessus, la barre
# de navigation (adresse comprise) juste en dessous, contre la page
# web — addToolBarBreak() force la seconde sur sa propre ligne.
self._build_actions_toolbar()
self.addToolBarBreak()
self._build_navigation_toolbar()
def _build_navigation_toolbar(self) -> None:
toolbar = QToolBar("Navigation", self)
toolbar.setMovable(False)
self.addToolBar(toolbar)
back_action = QAction("◀", self)
back_action.setToolTip("Page précédente")
back_action.triggered.connect(self.webview.back)
toolbar.addAction(back_action)
forward_action = QAction("▶", self)
forward_action.setToolTip("Page suivante")
forward_action.triggered.connect(self.webview.forward)
toolbar.addAction(forward_action)
reload_action = QAction("⟳", self)
reload_action.setToolTip("Recharger")
reload_action.triggered.connect(self.webview.reload)
toolbar.addAction(reload_action)
home_action = QAction("🏠", self)
home_action.setToolTip("Page d'accueil")
home_action.triggered.connect(lambda: self.webview.setUrl(QUrl(HOME_URL)))
toolbar.addAction(home_action)
self.address_bar = QLineEdit(self)
self.address_bar.returnPressed.connect(self._navigate_to_address_bar)
toolbar.addWidget(self.address_bar)
go_action = QAction("Go", self)
go_action.triggered.connect(self._navigate_to_address_bar)
toolbar.addAction(go_action)
def _build_actions_toolbar(self) -> None:
toolbar = QToolBar("Actions", self)
toolbar.setMovable(False)
# Barre "principale" : nettement plus haute que la barre de
# navigation (~100px, look "gros boutons" façon OpenOffice), via
# padding plutôt que taille d'icône — les actions n'ont pas
# d'icône, seulement un emoji dans leur texte.
toolbar.setStyleSheet(
"QToolBar { padding: 15px; spacing: 8px; }"
"QToolButton { padding: 25px 14px; font-size: 11pt; }"
)
self.addToolBar(toolbar)
# --- Projets -------------------------------------------------------
open_action = QAction("📂 Ouvrir…", self)
open_action.setToolTip("Ouvre un projet .pdfscrap enregistré précédemment.")
open_action.triggered.connect(self.open_project_action)
toolbar.addAction(open_action)
save_action = QAction("💾 Enregistrer", self)
save_action.setToolTip(
"Enregistre le projet courant (extraits + style). Demande un nom la première "
"fois, réutilise ensuite le même fichier."
)
save_action.triggered.connect(self.save_project_action)
toolbar.addAction(save_action)
save_as_action = QAction("💾 Enregistrer sous…", self)
save_as_action.setToolTip("Enregistre le projet courant dans un nouveau fichier.")
save_as_action.triggered.connect(self.save_project_as_action)
toolbar.addAction(save_as_action)
toolbar.addSeparator()
# --- Éditions --------------------------------------------------------
# Capture (sélection manuelle / mode survol) + aperçu-style : tout
# ce qui sert à constituer et affiner le contenu avant génération.
capture_action = QAction("➕ Ajouter la sélection au PDF", self)
capture_action.setShortcut(QKeySequence("Ctrl+Shift+A"))
capture_action.setToolTip("Ajoute le texte actuellement sélectionné dans la page (Ctrl+Shift+A)")
capture_action.triggered.connect(self.capture_selection)
toolbar.addAction(capture_action)
self.block_picker_action = QAction("🎯 Mode capture au survol", self)
self.block_picker_action.setCheckable(True)
self.block_picker_action.setShortcut(QKeySequence("Ctrl+Shift+B"))
self.block_picker_action.setToolTip(
"Survolez un paragraphe/bloc pour le surligner, cliquez pour l'ajouter au PDF.\n"
"• Ctrl+Shift+B (ou ce bouton) : reste activé jusqu'à nouvelle bascule.\n"
"• Maintenir Ctrl enfoncé dans la page : activation temporaire, désactivée au "
"relâchement.\n"
"Désactive temporairement le clic normal sur la page tant qu'actif."
)
self.block_picker_action.toggled.connect(self._toggle_block_picker)
toolbar.addAction(self.block_picker_action)
preview_action = QAction("🖌 Aperçu et style du PDF…", self)
preview_action.setToolTip(
"Aperçu en direct du PDF final, réglages de style et correction du texte."
)
preview_action.triggered.connect(self.open_pdf_preview)
toolbar.addAction(preview_action)
toolbar.addSeparator()
# --- IA ----------------------------------------------------------
ai_settings_action = QAction("⚙️ Configurer l'IA locale", self)
ai_settings_action.triggered.connect(self.open_ai_settings)
toolbar.addAction(ai_settings_action)
self.ai_extract_action = QAction("🤖 Extraire (IA)", self)
self.ai_extract_action.setToolTip(
"Demande à l'IA locale d'extraire le texte narratif principal de la page "
"courante et de l'ajouter au PDF (alternative à la sélection manuelle / au "
"mode survol)."
)
self.ai_extract_action.triggered.connect(self.run_ai_extract)
toolbar.addAction(self.ai_extract_action)
self.ai_next_link_action = QAction("🤖 Lien suivant (IA)", self)
self.ai_next_link_action.setToolTip(
"Demande à l'IA locale de désigner, parmi tous les liens de la page, celui "
"qui mène le plus probablement à la suite — utile quand la détection "
"automatique (mots-clés) ne trouve rien."
)
self.ai_next_link_action.triggered.connect(self.run_ai_pick_next_link)
toolbar.addAction(self.ai_next_link_action)
self.auto_scrape_action = QAction("🔁 Scraping auto (IA)", self)
self.auto_scrape_action.setToolTip(
"Boucle : extrait le contenu de la page courante (IA), capture le PDF, "
"trouve et suit le lien suivant (IA), répète — jusqu'à la limite de "
"chapitres fixée ou jusqu'à ce qu'aucune suite ne soit trouvée."
)
self.auto_scrape_action.triggered.connect(self._toggle_auto_scrape)
toolbar.addAction(self.auto_scrape_action)
toolbar.addSeparator()
# --- Génération ------------------------------------------------------
export_action = QAction("📄 Exporter en PDF…", self)
export_action.triggered.connect(self.export_pdf)
toolbar.addAction(export_action)
def _build_sidebar(self) -> None:
dock = QDockWidget("Capture PDF", self)
dock.setFeatures(
QDockWidget.DockWidgetFeature.DockWidgetMovable
| QDockWidget.DockWidgetFeature.DockWidgetFloatable
)
panel = QWidget()
layout = QVBoxLayout(panel)
# Actions Ouvrir/Enregistrer/Enregistrer sous : dans la toolbar
# (voir _build_toolbar) — seul l'état ("Projet : xxx" / "Aucun
# projet enregistré") reste affiché ici.
self.project_path_label = QLabel()
self.project_path_label.setStyleSheet("color: grey; font-style: italic;")
self.project_path_label.setWordWrap(True)
layout.addWidget(self.project_path_label)
self.clip_count_label = QLabel("Extraits capturés : 0")
layout.addWidget(self.clip_count_label)
self.insert_above_checkbox = QCheckBox("Insérer les nouvelles captures au-dessus de la sélection")
self.insert_above_checkbox.setChecked(self.insert_above)
self.insert_above_checkbox.setToolTip(
"Coché : chaque nouvelle capture s'insère juste au-dessus de l'extrait "
"sélectionné dans la liste.\n"
"Décoché : elle s'insère juste en dessous.\n"
"Sans sélection, elle s'ajoute toujours à la fin."
)
self.insert_above_checkbox.toggled.connect(self._toggle_insert_direction)
layout.addWidget(self.insert_above_checkbox)
self.clip_list = ClipTable()
self.clip_list.renameRequested.connect(self._rename_clip_at)
self.clip_list.linkToggled.connect(self._toggle_link_after)
layout.addWidget(self.clip_list, stretch=2)
clip_buttons = QHBoxLayout()
rename_btn = QPushButton("✏️ Renommer")
up_btn = QPushButton("▲ Monter")
down_btn = QPushButton("▼ Descendre")
delete_btn = QPushButton("🗑 Supprimer")
rename_btn.clicked.connect(self._rename_selected_clip)
up_btn.clicked.connect(lambda: self._move_clip(-1))
down_btn.clicked.connect(lambda: self._move_clip(1))
delete_btn.clicked.connect(self._delete_selected_clip)
clip_buttons.addWidget(rename_btn)
clip_buttons.addWidget(up_btn)
clip_buttons.addWidget(down_btn)
clip_buttons.addWidget(delete_btn)
layout.addLayout(clip_buttons)
clear_btn = QPushButton("Vider tout")
clear_btn.clicked.connect(self._clear_clips)
layout.addWidget(clear_btn)
layout.addWidget(_separator_label("Liens suggérés sur cette page"))
self.suggested_list = QListWidget()
self.suggested_list.itemDoubleClicked.connect(self._navigate_to_suggested)
layout.addWidget(self.suggested_list, stretch=1)
self.next_page_btn = QPushButton("→ Aller à la suite détectée")
self.next_page_btn.setEnabled(False)
self.next_page_btn.clicked.connect(self._go_to_relnext)
layout.addWidget(self.next_page_btn)
dock.setWidget(panel)
self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock)
# ------------------------------------------------------------------ #
# Navigation
# ------------------------------------------------------------------ #
def _navigate_to_address_bar(self) -> None:
text = self.address_bar.text().strip()
if not text:
return
if "://" not in text and " " not in text and "." in text:
text = "https://" + text
elif "://" not in text:
text = "https://www.google.com/search?q=" + text.replace(" ", "+")
self.webview.setUrl(QUrl(text))
def _on_url_changed(self, url: QUrl) -> None:
self.address_bar.setText(url.toString())
# Efface les suggestions de la page précédente : le script de
# détection (persistant, voir link_detector.py) rescanne tout seul
# et repoussera des résultats frais sous peu — inutile d'attendre
# loadFinished, qui ne se déclenche d'ailleurs pas pour les
# navigations internes de type SPA (pushState).
self.suggested_list.clear()
self.next_page_btn.setEnabled(False)
self.last_suggested_relnext = None
def _on_title_changed(self, title: str) -> None:
self.setWindowTitle(f"PdfScrapper — {title}" if title else "PdfScrapper")
def _on_load_finished(self, ok: bool) -> None:
if not ok:
return
# L'état JS "sticky" (bascule bouton/raccourci) ne survit pas à une
# navigation : on le reporte sur la page fraîchement chargée. Le
# maintien de Ctrl, lui, est purement géré côté JS en continu. La
# détection de liens, elle, est poussée en continu par le script
# persistant (MutationObserver) — rien à déclencher ici.
self._sync_sticky_state()
def _sync_sticky_state(self) -> None:
value = "true" if self.block_picker_enabled else "false"
self.webview.page().runJavaScript(SET_STICKY_JS_TEMPLATE.format(value=value))
def _on_links_detected(self, links_json: str) -> None:
try:
result = json.loads(links_json)
except (TypeError, ValueError):
return
self.suggested_list.clear()
for link in result.get("links", []):
item = QListWidgetItem(link["text"] or link["href"])
item.setData(Qt.ItemDataRole.UserRole, link["href"])
item.setToolTip(link["href"])
self.suggested_list.addItem(item)
self.last_suggested_relnext = result.get("relNext")
self.next_page_btn.setEnabled(bool(self.last_suggested_relnext))
def _navigate_to_suggested(self, item: QListWidgetItem) -> None:
href = item.data(Qt.ItemDataRole.UserRole)
if href:
self.webview.setUrl(QUrl(href))
def _go_to_relnext(self) -> None:
if self.last_suggested_relnext:
self.webview.setUrl(QUrl(self.last_suggested_relnext))
# ------------------------------------------------------------------ #
# Capture de texte
# ------------------------------------------------------------------ #
def capture_selection(self) -> None:
self.webview.page().runJavaScript(SELECTION_JS, self._on_selection_captured)
def _on_selection_captured(self, selected_text: str | None) -> None:
if not selected_text or not selected_text.strip():
self.statusBar().showMessage("Aucun texte sélectionné dans la page.", 4000)
return
self._add_clip(selected_text)
def _toggle_block_picker(self, enabled: bool) -> None:
self.block_picker_enabled = enabled
self._sync_sticky_state()
if enabled:
self.statusBar().showMessage(
"Mode capture au survol activé en continu : survolez un bloc puis cliquez "
"pour l'ajouter au PDF (le clic normal est suspendu tant que ce mode est actif).",
6000,
)
else:
self.statusBar().showMessage(
"Mode capture au survol désactivé (astuce : maintenez Ctrl enfoncé dans la "
"page pour l'activer temporairement sans bouton).",
4000,
)
def _handle_block_capture(self, text: str) -> None:
self._add_clip(text)
def _toggle_insert_direction(self, above: bool) -> None:
self.insert_above = above
self.statusBar().showMessage(
"Nouvelles captures insérées au-dessus de la sélection." if above
else "Nouvelles captures insérées en dessous de la sélection.",
3000,
)
def _add_clip(self, text: str) -> Clip:
clip = Clip(
text=text.strip(),
title=self.webview.title() or self.webview.url().toString(),
url=self.webview.url().toString(),
captured_at=datetime.now(),
)
# Insère juste au-dessus ou en dessous de l'extrait actuellement
# sélectionné (selon self.insert_above), plutôt que toujours à la
# fin — pour pouvoir intercaler une capture au milieu d'un ordre
# déjà établi.
selected_row = self.clip_list.currentRow()
has_selection = 0 <= selected_row < len(self.clips)
if not has_selection:
# Pas de repère : simple accumulation chronologique à la fin.
# On ne sélectionne rien après coup, pour que les captures
# suivantes continuent d'être de simples ajouts à la fin,
# quel que soit le mode — le mode au-dessus/en dessous ne
# prend effet que si l'utilisateur choisit lui-même un repère.
insert_at = len(self.clips)
self.clips.insert(insert_at, clip)
self._refresh_clip_list()
self.clip_list.clear_current()
elif self.insert_above:
insert_at = selected_row
self.clips.insert(insert_at, clip)
self._refresh_clip_list()
# On garde le repère (l'extrait qui était sélectionné, décalé
# d'un cran) comme sélection courante, pour que les captures
# suivantes continuent de s'empiler au-dessus de lui, dans
# l'ordre de capture.
self.clip_list.select_row(selected_row + 1)
else:
insert_at = selected_row + 1
self.clips.insert(insert_at, clip)
self._refresh_clip_list()
# La nouvelle capture devient elle-même le repère, pour que
# les suivantes s'enchaînent juste en dessous d'elle.
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
# index — cette méthode est aussi appelée en arrière-plan par la
# fenêtre d'aperçu (à chaque correction de texte), qui ne doit pas
# faire perdre le repère d'insertion au-dessus/en dessous choisi
# dans le panneau latéral.
self.clip_list.set_clips(self.clips)
self.clip_count_label.setText(f"Extraits capturés : {len(self.clips)}")
def _rename_clip_at(self, index: int) -> None:
self.clip_list.select_row(index)
self._rename_selected_clip()
def _toggle_link_after(self, index: int) -> None:
if not (0 <= index < len(self.clips)):
return
clip = self.clips[index]
clip.linked_to_next = not clip.linked_to_next
self._refresh_clip_list()
self.statusBar().showMessage(
"Extraits liés : ils formeront un seul chapitre à l'export."
if clip.linked_to_next
else "Extraits déliés.",
3000,
)
def _move_clip(self, offset: int) -> None:
row = self.clip_list.currentRow()
new_row = row + offset
if row < 0 or not (0 <= new_row < len(self.clips)):
return
# Deux extraits directement liés forment un seul chapitre dans un
# ordre précis : les permuter inverserait silencieusement le sens
# du lien (et pourrait fusionner le mauvais extrait avec le
# mauvais chapitre à l'export). On demande de délier d'abord.
earlier_row = min(row, new_row)
if self.clips[earlier_row].linked_to_next:
self.statusBar().showMessage(
"Extraits liés : déliez-les d'abord (icône 🔗) pour les réordonner.", 4000
)
return
self.clips[row], self.clips[new_row] = self.clips[new_row], self.clips[row]
self._refresh_clip_list()
self.clip_list.select_row(new_row)
def _delete_selected_clip(self) -> None:
row = self.clip_list.currentRow()
if row < 0:
return
del self.clips[row]
self._refresh_clip_list()
def _rename_selected_clip(self) -> None:
row = self.clip_list.currentRow()
if not (0 <= row < len(self.clips)):
self.statusBar().showMessage("Sélectionnez d'abord un extrait à renommer.", 3000)
return
clip = self.clips[row]
new_title, ok = QInputDialog.getText(
self,
"Renommer l'extrait",
"Titre affiché (utilisé aussi comme titre de section dans le PDF) :",
QLineEdit.EchoMode.Normal,
clip.title,
)
if ok and new_title.strip():
clip.title = new_title.strip()
self._refresh_clip_list()
self.clip_list.select_row(row)
def _clear_clips(self) -> None:
if not self.clips:
return
confirm = QMessageBox.question(
self,
"Vider les extraits",
"Supprimer tous les extraits capturés ?",
)
if confirm == QMessageBox.StandardButton.Yes:
self.clips.clear()
self._refresh_clip_list()
# ------------------------------------------------------------------ #
# Projet : sauvegarde manuelle/auto, reprise au démarrage
# ------------------------------------------------------------------ #
def _current_state(self) -> ProjectState:
return ProjectState(
clips=self.clips,
pdf_style=self.pdf_style,
insert_above=self.insert_above,
current_url=self.webview.url().toString(),
)
def _save_to(self, path: Path) -> None:
save_project(path, self._current_state())
save_last_session_path(path)
def _autosave(self) -> None:
target = self.project_path or self.autosave_path
try:
self._save_to(target)
except Exception: # noqa: BLE001 — l'autosave ne doit jamais interrompre l'appli
return
self.statusBar().showMessage("💾 Sauvegarde automatique effectuée.", 2000)
def save_project_action(self) -> None:
if self.project_path is None:
self.save_project_as_action()
return
try:
self._save_to(self.project_path)
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
QMessageBox.critical(self, "Enregistrer le projet", f"Échec de l'enregistrement :\n{exc}")
return
self.statusBar().showMessage(f"Projet enregistré : {self.project_path}", 4000)
def save_project_as_action(self) -> None:
default_name = (
self.project_path.name
if self.project_path
else f"projet_{datetime.now().strftime('%Y%m%d_%H%M')}{PROJECT_FILE_SUFFIX}"
)
path, _ = QFileDialog.getSaveFileName(
self, "Enregistrer le projet sous…", default_name, PROJECT_FILE_FILTER
)
if not path:
return
if not path.lower().endswith(PROJECT_FILE_SUFFIX):
path += PROJECT_FILE_SUFFIX
target = Path(path)
try:
self._save_to(target)
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
QMessageBox.critical(self, "Enregistrer le projet", f"Échec de l'enregistrement :\n{exc}")
return
self.project_path = target
self._update_project_label()
self.statusBar().showMessage(f"Projet enregistré : {target}", 4000)
def open_project_action(self) -> None:
if self.clips:
confirm = QMessageBox.question(
self,
"Ouvrir un projet",
"Ouvrir un autre projet remplacera les extraits actuels (non enregistrés "
"y compris). Continuer ?",
)
if confirm != QMessageBox.StandardButton.Yes:
return
path, _ = QFileDialog.getOpenFileName(self, "Ouvrir un projet", "", PROJECT_FILE_FILTER)
if not path:
return
try:
state = load_project(path)
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
QMessageBox.critical(self, "Ouvrir un projet", f"Échec de l'ouverture :\n{exc}")
return
self._apply_project_state(state)
self.project_path = Path(path)
save_last_session_path(self.project_path)
self._update_project_label()
self.statusBar().showMessage(f"Projet ouvert : {path}", 4000)
def _apply_project_state(self, state: ProjectState) -> None:
self.clips = state.clips
# Mute l'objet PdfStyle existant en place plutôt que de le
# remplacer : preview_window.py le garde par référence une fois
# la fenêtre d'aperçu ouverte une première fois (voir sa
# docstring) — le remplacer désynchroniserait l'aperçu déjà
# ouvert du style du projet qu'on vient de charger.
vars(self.pdf_style).update(vars(state.pdf_style))
self.insert_above = state.insert_above
self.insert_above_checkbox.setChecked(self.insert_above)
self._refresh_clip_list()
self.webview.setUrl(QUrl(state.current_url or HOME_URL))
if self.preview_window is not None:
self.preview_window.sync_from_external_change()
def _restore_last_session(self) -> bool:
"""Recharge la dernière session (projet explicite ou fichier de
récupération automatique) au démarrage. Retourne False si rien
n'a pu être restauré (première utilisation, fichier absent ou
corrompu) — l'appelant démarre alors sur la page d'accueil."""
last_path = load_last_session_path()
if last_path is None or not last_path.is_file():
return False
try:
state = load_project(last_path)
except Exception: # noqa: BLE001 — fichier corrompu : démarrage à vide, pas d'erreur
return False
self._apply_project_state(state)
# Le fichier de récupération par défaut ne compte pas comme un
# "vrai" projet nommé : Enregistrer proposera toujours Enregistrer
# sous tant que l'utilisateur n'a pas choisi lui-même un fichier.
if last_path != self.autosave_path:
self.project_path = last_path
self.statusBar().showMessage("Session précédente restaurée.", 4000)
return True
def _update_project_label(self) -> None:
if self.project_path:
self.project_path_label.setText(f"Projet : {self.project_path.name}")
else:
self.project_path_label.setText(
"Aucun projet enregistré (sauvegarde automatique locale)."
)
# ------------------------------------------------------------------ #
# Aperçu & style du PDF
# ------------------------------------------------------------------ #
def open_pdf_preview(self) -> None:
if self.preview_window is None:
self.preview_window = PdfPreviewWindow(
style=self.pdf_style,
get_clips=lambda: self.clips,
parent=self,
on_clips_changed=self._refresh_clip_list,
)
self.preview_window.refresh_preview()
self.preview_window.show()
self.preview_window.raise_()
self.preview_window.activateWindow()
# ------------------------------------------------------------------ #
# IA locale — configuration
# ------------------------------------------------------------------ #
def open_ai_settings(self) -> None:
dialog = AiSettingsDialog(self.ai_config, self)
if dialog.exec() == AiSettingsDialog.DialogCode.Accepted:
self.ai_config = dialog.updated_config()
save_ai_config(self.ai_config)
self.local_ai.config = self.ai_config
self.local_ai.reset() # force le rechargement si le modèle a changé
self.statusBar().showMessage("Configuration IA enregistrée.", 3000)
def _require_ai_configured(self) -> bool:
if self.local_ai.is_configured():
return True
confirm = QMessageBox.question(
self,
"IA locale non configurée",
"Aucun modèle IA n'est configuré. Ouvrir les réglages maintenant ?",
)
if confirm == QMessageBox.StandardButton.Yes:
self.open_ai_settings()
return False
# ------------------------------------------------------------------ #
# IA locale — extraction de contenu
# ------------------------------------------------------------------ #
def run_ai_extract(self) -> None:
if not self._require_ai_configured():
return
self.statusBar().showMessage("IA : lecture de la page…", 3000)
self.webview.page().runJavaScript(PAGE_TEXT_FOR_AI_JS, self._on_page_text_for_ai_extract)
def _on_page_text_for_ai_extract(self, page_text: str | None) -> None:
if not page_text or not page_text.strip():
QMessageBox.warning(self, "IA locale", "Aucun texte exploitable sur cette page.")
return
title = self.webview.title() or self.webview.url().toString()
self._run_ai_task(
self.local_ai.extract_main_content,
args=(title, page_text),
on_success=self._on_ai_extract_succeeded,
busy_message="IA : extraction du contenu en cours (peut prendre du temps sur CPU)…",
)
def _on_ai_extract_succeeded(self, extracted_text: object) -> None:
self._add_clip(str(extracted_text))
self.statusBar().showMessage("Extrait ajouté au PDF (IA).", 3000)
# ------------------------------------------------------------------ #
# IA locale — suggestion du lien suivant
# ------------------------------------------------------------------ #
def run_ai_pick_next_link(self) -> None:
if not self._require_ai_configured():
return
self.statusBar().showMessage("IA : lecture 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_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):
raw_links = []
if not raw_links:
QMessageBox.warning(self, "IA locale", "Aucun lien exploitable sur cette page.")
return
candidates = [
LinkCandidate(text=l["text"], href=l["href"], 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, 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, 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 = 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}{chapter_note}\n\nY aller maintenant ?",
)
if confirm == QMessageBox.StandardButton.Yes:
self.webview.setUrl(QUrl(candidate.href))
# ------------------------------------------------------------------ #
# IA locale — exécution en arrière-plan
# ------------------------------------------------------------------ #
def _run_ai_task(
self,
fn,
args: tuple,
on_success,
busy_message: str,
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
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()
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, task_id: int, message: str) -> None:
if task_id == self._ai_task_id:
self._ai_task_done = True
self.statusBar().clearMessage()
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)
if enabled and not self.auto_scrape_active:
self.auto_scrape_action.setEnabled(True)
# ------------------------------------------------------------------ #
# IA locale — scraping automatique (boucle)
# ------------------------------------------------------------------ #
def _toggle_auto_scrape(self) -> None:
if self.auto_scrape_active:
self._stop_auto_scrape("Scraping automatique arrêté.")
return
if not self._require_ai_configured():
return
max_chapters, ok = QInputDialog.getInt(
self,
"Scraping automatique",
"Nombre maximum de chapitres à capturer automatiquement\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,
0,
500,
)
if not ok:
return
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")
# 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())
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:
if not self.auto_scrape_active:
return
if not page_text or not page_text.strip():
self._stop_auto_scrape("Scraping automatique arrêté : page sans texte exploitable.")
return
title = self.webview.title() or self.webview.url().toString()
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._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.")
# 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."
)
return
self.webview.page().runJavaScript(ALL_LINKS_FOR_AI_JS, self._auto_scrape_on_links)
def _auto_scrape_on_links(self, links_json: str | None, from_toc: bool = False) -> None:
if not self.auto_scrape_active:
return
try:
raw_links = json.loads(links_json) if links_json else []
except (TypeError, ValueError):
raw_links = []
if not raw_links:
self._stop_auto_scrape("Scraping automatique terminé : plus aucun lien sur la page.")
return
candidates = [
LinkCandidate(text=l["text"], href=l["href"], 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, 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, result: object) -> None:
if not self.auto_scrape_active:
return
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 = 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 "
"(boucle détectée)."
)
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:
return
if not ok:
self._stop_auto_scrape("Scraping automatique arrêté : échec du chargement de la page suivante.")
return
# Laisse le temps au contenu chargé en JS de s'installer avant
# de relire la page (voir link_detector.py pour le même souci).
QTimer.singleShot(AUTO_SCRAPE_SETTLE_DELAY_MS, self._auto_scrape_extract_current_page)
self.webview.loadFinished.connect(on_next_page_loaded)
self.webview.setUrl(QUrl(candidate.href))
# ------------------------------------------------------------------ #
# Export PDF
# ------------------------------------------------------------------ #
def export_pdf(self) -> None:
if not self.clips:
QMessageBox.warning(self, "Export PDF", "Aucun extrait à exporter.")
return
default_name = f"extraits_{datetime.now().strftime('%Y%m%d_%H%M')}.pdf"
path, _ = QFileDialog.getSaveFileName(
self, "Exporter en PDF", default_name, "Fichiers PDF (*.pdf)"
)
if not path:
return
if not path.lower().endswith(".pdf"):
path += ".pdf"
try:
build_pdf(self.clips, path, self.pdf_style)
except Exception as exc: # noqa: BLE001 — affiché à l'utilisateur
QMessageBox.critical(self, "Export PDF", f"Échec de l'export :\n{exc}")
return
self.statusBar().showMessage(f"PDF exporté : {path}", 5000)
QMessageBox.information(self, "Export PDF", f"PDF généré avec succès :\n{path}")
def _separator_label(text: str) -> QLabel:
label = QLabel(text)
label.setStyleSheet("font-weight: bold; margin-top: 8px;")
return label