Files
2026-08-11 15:41:55 +02:00

145 lines
6.2 KiB
Python

"""Détection heuristique des liens de "suite de l'histoire" sur une page.
Première version : un scan JS unique déclenché au signal Qt `loadFinished`
(page().runJavaScript(...) avec callback synchrone). Problème constaté :
sur de nombreux sites (SPA, contenu/pagination injecté en JavaScript après
coup, hydratation différée), le lien "suivant" n'existe pas encore dans le
DOM au moment de `loadFinished` — il apparaît quelques centaines de ms
plus tard. Un scan unique le manque systématiquement, ce qui donnait
l'impression que la détection ne s'activait jamais (elle semblait analyser
une version figée de la page plutôt que la page telle qu'effectivement
rendue).
Cette version scanne en continu via un MutationObserver JS : un premier
scan a lieu dès que le DOM est prêt, puis à chaque mutation significative
du contenu (avec anti-rebond), ce qui couvre aussi bien le contenu chargé
en différé que les sites à navigation interne (SPA/pushState) qui ne
redéclenchent jamais `loadFinished`. Les résultats sont poussés vers
Python via le pont QWebChannel déjà utilisé par block_picker.py (voir
bridge.py) plutôt que remontés ponctuellement par callback synchrone.
Deuxième problème constaté (ex. literotica.com) : certains sites affichent
l'indice "suivant" en dehors du lien lui-même — une icône + légende
("Partie Suivante") posée en élément voisin du <a>, qui ne contient donc
lui-même que le titre du chapitre. Un scan qui ne regarde que le texte du
<a> (a.innerText) ne peut structurellement pas le voir. On regarde donc
aussi le texte du conteneur parent immédiat du lien (borné en taille pour
limiter les faux positifs sur de gros conteneurs sans rapport) : si ce
contexte proche matche un motif, le lien est quand même retenu — son
propre texte (le titre) reste ce qui est affiché/surligné.
"""
# Script persistant, injecté une fois par page (voir main_window.py :
# QWebEngineScript à InjectionPoint.DocumentCreation, comme le pont
# QWebChannel et le mode "capture au survol").
LINK_DETECTOR_INIT_JS = r"""
(function () {
if (window.__pdfscrapperLinkDetectorInitialized) { return; }
window.__pdfscrapperLinkDetectorInitialized = true;
var patterns = [
/\bsuivante?\b/i,
/\bnext\b/i,
/chapitre\s*suivante?/i,
/chapitre\s*\d+/i,
/chapter\s*\d+/i,
/next\s*chapter/i,
/lire la suite/i,
/\bcontinuer\b/i,
/\bpart(ie)?\s*\d+\b/i,
/>>+/,
/→/,
/\bprochain(e)?\b/i,
/nouveau chapitre/i,
/read more/i
];
function reportResult(result) {
if (window.pdfscrapperBridge) {
window.pdfscrapperBridge.reportLinks(JSON.stringify(result));
} else {
// Le pont QWebChannel s'établit de façon asynchrone : s'il
// n'est pas encore prêt (scan très précoce), on réessaie.
setTimeout(function () { reportResult(result); }, 150);
}
}
function scan() {
if (!document.body) { return; }
document.querySelectorAll('a[data-pdfscrapper-suggested]').forEach(function (a) {
a.style.outline = '';
a.style.backgroundColor = '';
a.removeAttribute('data-pdfscrapper-suggested');
});
document.querySelectorAll('a[href]').forEach(function (a) {
var text = (a.innerText || a.textContent || '').trim();
// Le lien doit rester un texte de navigation plausible (un
// titre, pas un paragraphe entier) même si l'indice "suivant"
// est trouvé dans son contexte plutôt que dans son propre texte.
if (!text || text.length > 60) { return; }
var matchedOwnText = patterns.some(function (p) { return p.test(text); });
var matchedContext = false;
if (!matchedOwnText && a.parentElement) {
var contextText = (a.parentElement.innerText || a.parentElement.textContent || '').trim();
// Borné : on ne veut capter qu'une légende/icône voisine
// proche (ex. "Partie Suivante" à côté du lien), pas
// n'importe quel "suivant" perdu dans un gros conteneur.
if (contextText && contextText.length <= 150) {
matchedContext = patterns.some(function (p) { return p.test(contextText); });
}
}
if (matchedOwnText || matchedContext) {
a.setAttribute('data-pdfscrapper-suggested', '1');
a.style.outline = '3px solid #ff9800';
a.style.outlineOffset = '2px';
a.style.backgroundColor = 'rgba(255,152,0,0.18)';
}
});
var relNextEl = document.querySelector('link[rel="next"]');
var result = { relNext: relNextEl ? relNextEl.href : null, links: [] };
document.querySelectorAll('a[data-pdfscrapper-suggested="1"]').forEach(function (a) {
var t = (a.innerText || a.textContent || '').trim();
result.links.push({ text: t.slice(0, 80), href: a.href });
});
reportResult(result);
}
var debounceHandle = null;
function scheduleScan() {
if (debounceHandle) { clearTimeout(debounceHandle); }
debounceHandle = setTimeout(scan, 400);
}
function setupObserver() {
if (!document.body) {
setTimeout(setupObserver, 100);
return;
}
scan();
// Rescanne à chaque ajout/suppression de nœuds n'importe où dans
// la page : couvre le contenu injecté après coup (fetch/AJAX,
// hydratation React/Vue...) et la navigation interne des SPA.
var observer = new MutationObserver(scheduleScan);
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupObserver);
} else {
setupObserver();
}
// Filet de sécurité : un dernier scan au chargement complet (images,
// etc.), qui peut réordonner/ajouter des liens sur certains sites.
window.addEventListener('load', scheduleScan);
})();
"""
# Récupère le texte actuellement sélectionné par l'utilisateur dans la page.
SELECTION_JS = "window.getSelection().toString();"