Google Infinite Scroll 2 (Refactored)

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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