145 lines
6.2 KiB
Python
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();"
|