Google Infinite Scroll 2 (Refactored)

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

You will need to install an extension such as Tampermonkey to install this script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==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();
})();