Google Infinite Scroll 2 (Refactored)

Performance-optimized infinite scroll for Google Search with robust heuristics, SPA support, and deduplication

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Google Infinite Scroll 2 (Refactored)
// @description  Performance-optimized infinite scroll for Google Search with robust heuristics, SPA support, and deduplication
// @match        *://www.google.com/search*
// @run-at       document-end
// @version      1.3.0
// @license      GPL-3.0-or-later
// @namespace https://www.tampermonkey.net/
// ==/UserScript==

/*
 * Google Infinite Scroll 2 (Refactored)
 * Copyright (C) 2024
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 */

(function() {
    'use strict';

    class GoogleInfiniteScroll {
        constructor() {
            this.state = {
                isLoading: false,
                nextUrl: null,
                hasMore: true,
                error: null,
                currentUrl: window.location.href,
                seenVeds: new Set()
            };

            this.config = {
                containerSelectors: ['#rso', '#center_col', '#res'],
                nextButtonSelectors: [
                    '#pnnext', 
                    'a[aria-label^="Next"]', 
                    'a[href*="/search?"][href*="start="]:last-of-type'
                ],
                resultSelectors: ['.g', '.MjjYud', 'div[data-hveid]', '.hlcw0c'],
                paginationSelectors: ['#foot', '#navcnt', '#xjs > div:last-child'],
                sentinelId: 'infinite-scroll-sentinel',
                rootMargin: '800px'
            };

            this.container = null;
            this.sentinel = null;
            this.intersectionObserver = null;
            this.mutationObserver = null;
            this.abortController = null;

            this.init();
            this.bindEvents();
        }

        log(msg, data = '') {
            console.log(`[InfiniteScroll] ${msg}`, data);
        }

        bindEvents() {
            // Handle SPA-like navigation (Back/Forward and URL changes)
            window.addEventListener('popstate', () => this.handleNavigation());
            
            // Polling fallback for URL changes that don't trigger popstate
            this.urlCheckInterval = setInterval(() => {
                if (window.location.href !== this.state.currentUrl) {
                    this.handleNavigation();
                }
            }, 1000);
        }

        handleNavigation() {
            this.log('Navigation detected, resetting state...');
            this.destroy();
            this.state.currentUrl = window.location.href;
            this.state.nextUrl = null;
            this.state.hasMore = true;
            this.state.seenVeds.clear();
            this.init();
        }

        destroy() {
            if (this.urlCheckInterval) clearInterval(this.urlCheckInterval);
            if (this.intersectionObserver) this.intersectionObserver.disconnect();
            if (this.mutationObserver) this.mutationObserver.disconnect();
            if (this.abortController) this.abortController.abort();
            if (this.sentinel && this.sentinel.parentNode) {
                this.sentinel.parentNode.removeChild(this.sentinel);
            }
            this.sentinel = null;
            this.container = null;
        }

        findContainer(doc = document) {
            for (const selector of this.config.containerSelectors) {
                const el = doc.querySelector(selector);
                if (el) return el;
            }
            return null;
        }

        findNextUrl(doc = document) {
            for (const selector of this.config.nextButtonSelectors) {
                const btn = doc.querySelector(selector);
                if (btn && btn.href) {
                    // Basic heuristic: check if start param exists
                    const url = new URL(btn.href, window.location.origin);
                    if (url.searchParams.has('start')) return btn.href;
                }
            }
            return null;
        }

        updateSeenResults(container) {
            if (!container) return;
            container.querySelectorAll('[data-ved]').forEach(el => {
                this.state.seenVeds.add(el.getAttribute('data-ved'));
            });
        }

        createSentinel() {
            if (document.getElementById(this.config.sentinelId)) return;

            const sentinel = document.createElement('div');
            sentinel.id = this.config.sentinelId;
            sentinel.style.cssText = `
                min-height: 5rem;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                font-family: Google Sans, Roboto, Arial, sans-serif;
                color: #70757a;
                font-size: 0.875rem;
                margin: 2rem 0;
                transition: opacity 0.3s ease;
            `;
            this.container.after(sentinel);
            this.sentinel = sentinel;
            
            // Inject spinner keyframes if not present
            if (!document.getElementById('is-spinner-style')) {
                const style = document.createElement('style');
                style.id = 'is-spinner-style';
                style.textContent = `@keyframes is-spin { to { transform: rotate(360deg); } }`;
                document.head.appendChild(style);
            }
            
            this.updateSentinelState('idle');
        }

        updateSentinelState(state) {
            if (!this.sentinel) return;

            switch (state) {
                case 'loading':
                    this.sentinel.innerHTML = `
                        <div style="margin-bottom: 0.5rem;">Loading more results...</div>
                        <div style="width: 24px; height: 24px; border: 2px solid #e8eaed; border-top-color: #4285f4; border-radius: 50%; animation: is-spin 0.8s linear infinite;"></div>
                    `;
                    this.sentinel.style.opacity = '1';
                    break;
                case 'error':
                    this.sentinel.innerHTML = `
                        <div style="color: #d93025; margin-bottom: 0.5rem;">Connection lost or blocked.</div>
                        <button id="is-retry-btn" style="background: #4285f4; color: white; border: none; padding: 0.6rem 1.2rem; border-radius: 4px; cursor: pointer; font-size: 0.875rem; font-weight: 500; box-shadow: 0 1px 2px rgba(0,0,0,0.1);">Try Again</button>
                    `;
                    this.sentinel.querySelector('#is-retry-btn').onclick = (e) => {
                        e.preventDefault();
                        this.fetchResults();
                    };
                    break;
                case 'done':
                    this.sentinel.textContent = 'End of results.';
                    this.sentinel.style.opacity = '0.7';
                    break;
                default:
                    this.sentinel.innerHTML = '';
                    this.sentinel.style.opacity = '1';
            }
        }

        async fetchResults() {
            if (this.state.isLoading || !this.state.hasMore || !this.state.nextUrl) return;

            this.state.isLoading = true;
            this.updateSentinelState('loading');

            if (this.abortController) this.abortController.abort();
            this.abortController = new AbortController();

            try {
                const response = await fetch(this.state.nextUrl, { signal: this.abortController.signal });
                if (!response.ok) throw new Error(`HTTP ${response.status}`);

                const html = await response.text();
                const doc = new DOMParser().parseFromString(html, 'text/html');
                
                const newContainer = this.findContainer(doc);
                if (!newContainer) throw new Error('Container mismatch');

                const children = Array.from(newContainer.children);
                const fragment = document.createDocumentFragment();
                let addedCount = 0;

                children.forEach(child => {
                    // Skip known non-result IDs and duplicates via VED
                    if (child.id === 'botstuff' || child.id === 'navcnt') return;
                    
                    const ved = child.querySelector('[data-ved]')?.getAttribute('data-ved') || child.getAttribute('data-ved');
                    if (ved && this.state.seenVeds.has(ved)) return;
                    
                    if (ved) this.state.seenVeds.add(ved);
                    fragment.appendChild(child.cloneNode(true));
                    addedCount++;
                });

                if (addedCount > 0) {
                    this.container.appendChild(fragment);
                    this.log(`Appended ${addedCount} items`);
                }

                const next = this.findNextUrl(doc);
                this.state.nextUrl = next;
                this.state.hasMore = !!next;
                this.updateSentinelState(this.state.hasMore ? 'idle' : 'done');

            } catch (err) {
                if (err.name === 'AbortError') return;
                this.log('Fetch error:', err.message);
                this.updateSentinelState('error');
            } finally {
                this.state.isLoading = false;
            }
        }

        init() {
            const waitAndStart = () => {
                this.container = this.findContainer();
                if (this.container) {
                    this.state.nextUrl = this.findNextUrl();
                    if (this.state.nextUrl) {
                        this.updateSeenResults(this.container);
                        this.setup();
                        return true;
                    }
                }
                return false;
            };

            if (!waitAndStart()) {
                this.mutationObserver = new MutationObserver((_, obs) => {
                    if (waitAndStart()) obs.disconnect();
                });
                this.mutationObserver.observe(document.body, { childList: true, subtree: true });
                setTimeout(() => this.mutationObserver.disconnect(), 10000);
            }
        }

        setup() {
            this.config.paginationSelectors.forEach(s => {
                const el = document.querySelector(s);
                if (el) el.style.display = 'none';
            });

            this.createSentinel();

            this.intersectionObserver = new IntersectionObserver((entries) => {
                if (entries[0].isIntersecting) this.fetchResults();
            }, { rootMargin: this.config.rootMargin });

            this.intersectionObserver.observe(this.sentinel);
            this.log('v1.3.0 Initialized');
        }
    }

    new GoogleInfiniteScroll();
})();