Google Infinite Scroll 2 (Refactored)

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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