Performance-optimized infinite scroll for Google Search with robust heuristics, SPA support, and deduplication
// ==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();
})();