Google Infinite Scroll 2 (Refactored)

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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