您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track visits per URL, show corner badge history & link hover info - Massive Capacity (10K URLs) - ES2020+ & Smooth Tooltips
// ==UserScript== // @name URL Visit Tracker (Improved) // @namespace https://github.com/hongmd/userscript-improved // @version 2.5.2 // @description Track visits per URL, show corner badge history & link hover info - Massive Capacity (10K URLs) - ES2020+ & Smooth Tooltips // @author hongmd // @contributor Original idea by Chewy // @homepage https://greasyforks.org/en/scripts/548595-url-visit-tracker-improved // @homepageURL https://github.com/hongmd/userscript-improved // @supportURL https://github.com/hongmd/userscript-improved/issues // @license MIT // @match https://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // ==/UserScript== (function () { 'use strict'; // Configuration options const CONFIG = { MAX_VISITS_STORED: 20, MAX_URLS_STORED: 10000, // Massive capacity for extensive tracking CLEANUP_THRESHOLD: 12000, // Cleanup when exceeding this (20% buffer) HOVER_DELAY: 1000, // Delay before showing tooltip (ms) POLL_INTERVAL: 5000, // Reduced polling frequency for better performance BADGE_POSITION: { right: '14px', bottom: '14px' }, BADGE_VISIBLE: true, DEBUG: false, // Set to true to enable debug logging // Performance optimizations POLLING: { PAUSE_WHEN_HIDDEN: true, // Pause polling timer when tab is hidden SKIP_WHEN_HIDDEN: true, // Skip polling execution when tab is hidden (lighter) ADAPTIVE: true // Enable adaptive polling based on activity }, // Multi-tab coordination MULTI_TAB: { ENABLED: false, // Set to true to enable multi-tab cache coordination SYNC_INTERVAL: 10000 // How often to sync cache across tabs (ms) }, // URL normalization options NORMALIZE_URL: { REMOVE_QUERY: false, // Set to true to ignore query params (?key=value) // false: tracks "site.com?q=A" and "site.com?q=B" separately // true: groups them as "site.com" (same page) REMOVE_HASH: true, // Set to true to ignore hash fragments (#section) // true: treats "page.html#top" and "page.html#bottom" as same // false: tracks different sections separately REMOVE_WWW: true, // Set to true to remove www. prefix REMOVE_PROTOCOL: true, // Set to true to remove http/https REMOVE_TRAILING_SLASH: true, // Set to true to remove trailing / CLEAN_SEARCH_URLS: true // Clean search engine URLs (keep only main query) }, // URL filtering - Skip tracking certain types of URLs URL_FILTERS: { SKIP_UTILITY_PAGES: true, // Skip tracking utility/internal pages (cookies, auth, etc.) SKIP_PATTERNS: [ // URL patterns to skip (case-insensitive) '/RotateCookiesPage', // YouTube cookie rotation '/ServiceLogin', // Google login pages '/CheckCookie', // Cookie check pages '/robots.txt', // Robot files '/favicon.ico', // Favicon requests 'ogs.google.com', // Google widgets/apps '/widget/app', // Google widget apps '/persist_identity', // YouTube identity persistence 'studio.youtube.com/persist_identity' // YouTube Studio identity ] } }; // Badge visibility state let badgeVisible = CONFIG.BADGE_VISIBLE; let menuRegistered = false; // Flag to prevent duplicate menu registration // Polling state let pollTimer = null; let lastHref = location.href; let lastCheck = Date.now(); let activityCount = 0; // Track recent activity for adaptive polling // In-memory cache for hot path performance let dbCache = null; let cacheValid = false; function normalizeUrl(url) { // Validate input URL first if (!url || typeof url !== 'string') { console.warn('Invalid URL provided to normalizeUrl:', url); return location.href; } // Configurable URL normalization for flexible tracking granularity let normalized = url.trim(); // Handle malformed URLs try { // Test if URL is valid by creating URL object new URL(normalized.startsWith('http') ? normalized : 'http://' + normalized); } catch (error) { if (CONFIG.DEBUG) { console.warn('Malformed URL detected, using current location:', url); } return normalizeUrl(location.href); } // Clean search URLs before other normalizations (must be done first) if (CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS) { normalized = cleanSearchUrl(normalized); } // Remove protocol if configured if (CONFIG.NORMALIZE_URL.REMOVE_PROTOCOL) { normalized = normalized.replace(/^https?:\/\//, ''); } // Remove www prefix if configured if (CONFIG.NORMALIZE_URL.REMOVE_WWW) { normalized = normalized.replace(/^www\./, ''); } // Remove trailing slash if configured if (CONFIG.NORMALIZE_URL.REMOVE_TRAILING_SLASH) { normalized = normalized.replace(/\/$/, ''); } // Remove hash fragments if configured if (CONFIG.NORMALIZE_URL.REMOVE_HASH) { normalized = normalized.split('#')[0]; } // Remove query parameters if configured (after search cleaning) if (CONFIG.NORMALIZE_URL.REMOVE_QUERY) { normalized = normalized.split('?')[0]; } if (CONFIG.DEBUG) { console.log(`🔗 URL normalized: "${url}" → "${normalized}"`); } return normalized; } // Check if URL should be skipped from tracking function shouldSkipUrl(url) { if (!CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES) return false; const urlLower = url.toLowerCase(); // Check against skip patterns for (const pattern of CONFIG.URL_FILTERS.SKIP_PATTERNS) { if (urlLower.includes(pattern.toLowerCase())) { if (CONFIG.DEBUG) { console.log(`🚫 Skipping URL (matches pattern "${pattern}"): ${url}`); } return true; } } return false; } // Clean search engine URLs to group similar searches function cleanSearchUrl(url) { if (!CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS) return url; try { const urlObj = new URL(url.startsWith('http') ? url : 'https://' + url); const hostname = urlObj.hostname.toLowerCase(); const pathname = urlObj.pathname; const searchParams = new URLSearchParams(urlObj.search); // Google Search if ((hostname.includes('google.') || hostname === 'google.com') && pathname.includes('/search')) { const query = searchParams.get('q'); if (query) { // Keep only the main query, remove tracking params const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}${pathname}?q=${encodeURIComponent(query)}`; if (CONFIG.DEBUG) { console.log(`🔍 Cleaned Google search: "${url}" → "${cleanUrl}"`); } return cleanUrl; } } // Bing Search else if (hostname.includes('bing.com') && pathname.includes('/search')) { const query = searchParams.get('q'); if (query) { const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}${pathname}?q=${encodeURIComponent(query)}`; if (CONFIG.DEBUG) { console.log(`🔍 Cleaned Bing search: "${url}" → "${cleanUrl}"`); } return cleanUrl; } } // DuckDuckGo Search else if (hostname.includes('duckduckgo.com')) { const query = searchParams.get('q'); if (query) { const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}/?q=${encodeURIComponent(query)}`; if (CONFIG.DEBUG) { console.log(`🔍 Cleaned DuckDuckGo search: "${url}" → "${cleanUrl}"`); } return cleanUrl; } } // YouTube Search else if (hostname.includes('youtube.com') && pathname.includes('/results')) { const query = searchParams.get('search_query'); if (query) { const cleanUrl = `${urlObj.protocol}//${urlObj.hostname}${pathname}?search_query=${encodeURIComponent(query)}`; if (CONFIG.DEBUG) { console.log(`🔍 Cleaned YouTube search: "${url}" → "${cleanUrl}"`); } return cleanUrl; } } } catch (error) { if (CONFIG.DEBUG) { console.warn('Failed to clean search URL:', error); } } return url; // Return original if not a search URL or parsing failed } // Safe closest() that handles Text nodes and elements without closest method function safeClosest(target, selector) { // Handle null/undefined if (!target) return null; // If target is a Text node, use its parent element if (target.nodeType === Node.TEXT_NODE) { target = target.parentElement; } // If target doesn't have closest method (SVG elements in old browsers), fallback if (!target || typeof target.closest !== 'function') { // Traverse up manually let element = target; while (element && element.nodeType === Node.ELEMENT_NODE) { if (element.matches && element.matches(selector)) { return element; } element = element.parentElement; } return null; } // Use native closest if available return target.closest(selector); } // Polling control functions function directPoll() { // Skip polling when tab is hidden for performance if (CONFIG.POLLING.SKIP_WHEN_HIDDEN && document.hidden) { if (CONFIG.DEBUG) { console.log('⏸️ Skipping poll - tab is hidden'); } return; } const currentHref = location.href; const now = Date.now(); // Check if we should process pending URL change if (pendingUrlChange && !pendingTimeout && (now - lastUrlChangeTime) >= URL_CHANGE_MIN_INTERVAL) { if (CONFIG.DEBUG) { console.log(`🔄 Polling processing pending URL change: ${currentUrl} → ${pendingUrlChange}`); } const savedPendingUrl = pendingUrlChange; pendingUrlChange = null; // Validate URL before processing if (savedPendingUrl && savedPendingUrl !== currentUrl) { currentUrl = savedPendingUrl; lastUrlChangeTime = now; updateVisit(); } } // Only process if URL actually changed and enough time has passed if (currentHref !== lastHref && (now - lastCheck) >= 1000) { if (CONFIG.DEBUG) { console.log(`🔄 Polling detected URL change: ${lastHref} → ${currentHref}`); } lastHref = currentHref; lastCheck = now; onUrlChange(); } else if (currentHref !== lastHref) { // URL changed but too soon - just update lastHref to prevent spam lastHref = currentHref; } } function startPolling() { if (pollTimer) clearInterval(pollTimer); // Adaptive polling interval based on activity let interval = CONFIG.POLL_INTERVAL; if (CONFIG.POLLING.ADAPTIVE) { // More frequent polling if recent activity, less if idle interval = activityCount > 0 ? Math.max(2000, CONFIG.POLL_INTERVAL / 2) : CONFIG.POLL_INTERVAL * 2; // Decay activity count over time activityCount = Math.max(0, activityCount - 1); } pollTimer = setInterval(directPoll, interval); } function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } } // Optimized functions for timestamp storage function createTimestamp(date = new Date()) { return date.getTime(); } function formatTimestamp(timestamp) { const date = new Date(timestamp); const pad = n => n.toString().padStart(2, '0'); return `${pad(date.getHours())}:${pad(date.getMinutes())} ${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()}`; } // Calculate accurate UTF-8 byte size using Blob function getActualDataSize(data) { try { const jsonString = JSON.stringify(data); // Create a Blob to get the actual UTF-8 byte size const blob = new Blob([jsonString], { type: 'application/json' }); return blob.size; } catch (error) { // Fallback to character count if Blob fails console.warn('Failed to calculate Blob size, using character count:', error); return JSON.stringify(data).length; } } // Smart cleanup to maintain database size with performance optimization function cleanupOldUrls(db) { const urls = Object.keys(db); if (urls.length <= CONFIG.MAX_URLS_STORED) return db; if (CONFIG.DEBUG) { console.log(`🧹 Large database cleanup: ${urls.length} → ${CONFIG.MAX_URLS_STORED} URLs`); } // Use requestIdleCallback for non-blocking cleanup if available const performCleanup = () => { // Calculate score for each URL (visits * recency) const scored = urls.map(url => { const data = db[url]; const recentVisit = data.visits?.[0] ?? 0; // Optional chaining + nullish coalescing const daysSinceVisit = (Date.now() - recentVisit) / (1000 * 60 * 60 * 24); const recencyScore = Math.max(0, 30 - daysSinceVisit) / 30; // 0-1 based on last 30 days const score = data.count * (1 + recencyScore); // Visits weighted by recency return { url, score, count: data.count, lastVisit: recentVisit }; }); // Keep top 10,000 URLs by score - massive capacity scored.sort((a, b) => b.score - a.score); const keepUrls = scored.slice(0, CONFIG.MAX_URLS_STORED); // Use Object.fromEntries for more modern approach (ES2019) const cleanDb = Object.fromEntries( keepUrls.map(({ url }) => [url, db[url]]) ); return cleanDb; }; // Use requestIdleCallback for non-blocking cleanup if available if (window.requestIdleCallback && urls.length > 5000) { return new Promise(resolve => { requestIdleCallback(() => { resolve(performCleanup()); }, { timeout: 10000 }); // 10s timeout fallback }); } else { return performCleanup(); } } function shortenNumber(num) { // Handle edge cases first if (!Number.isFinite(num)) return '0'; // Handle NaN, Infinity, -Infinity if (num < 0) return '0'; // Visits can't be negative if (num === 0) return '0'; // Convert to absolute value and round to avoid floating point issues const absNum = Math.abs(Math.floor(num)); // Handle very large numbers with appropriate suffixes if (absNum >= 1_000_000_000) { return (Math.round(absNum / 100_000_000) / 10) + 'B'; // Billions } if (absNum >= 1_000_000) { return (Math.round(absNum / 100_000) / 10) + 'M'; // Millions } if (absNum >= 1_000) { return (Math.round(absNum / 100) / 10) + 'K'; // Thousands } return String(absNum); } function getDB() { // Return cached version if available and valid if (cacheValid && dbCache !== null) { return dbCache; } try { dbCache = GM_getValue('visitDB', {}); cacheValid = true; return dbCache; } catch (error) { console.warn('Failed to read visit database:', error); dbCache = {}; cacheValid = true; return dbCache; } } // Fast read-only access for hot paths (tooltips, etc) function getDBCached() { if (cacheValid && dbCache !== null) { return dbCache; } return getDB(); // Fallback to full load } function setDB(db) { try { // Auto cleanup if database is getting too large if (Object.keys(db).length > CONFIG.CLEANUP_THRESHOLD) { db = cleanupOldUrls(db); } // Update cache first dbCache = db; cacheValid = true; // Then persist to storage GM_setValue('visitDB', db); } catch (error) { console.warn('Failed to save visit database:', error); // Invalidate cache on save failure cacheValid = false; } } // Invalidate cache when external changes might occur (multi-tab coordination) // This function is used when CONFIG.MULTI_TAB.ENABLED is true to ensure // cache consistency across multiple tabs function invalidateCache() { cacheValid = false; // Use setTimeout to prevent race conditions setTimeout(() => { dbCache = null; }, 0); } let currentUrl = normalizeUrl(location.href); function updateVisit() { // Skip tracking if current URL matches filter patterns if (shouldSkipUrl(location.href)) { if (CONFIG.DEBUG) { console.log(`🚫 Skipping visit tracking: ${location.href}`); } return; } const db = getDB(); const now = new Date(); const timestamp = createTimestamp(now); // Use logical assignment and modern destructuring db[currentUrl] ??= { count: 0, visits: [] }; const urlData = db[currentUrl]; urlData.count += 1; urlData.visits.unshift(timestamp); // Trim visits array if needed if (urlData.visits.length > CONFIG.MAX_VISITS_STORED) { urlData.visits.length = CONFIG.MAX_VISITS_STORED; } if (CONFIG.DEBUG) { const isNew = urlData.count === 1; console.log(isNew ? `🆕 New URL tracked: ${currentUrl}` : `🔄 URL revisited: ${currentUrl} (${urlData.count} times)` ); } setDB(db); renderBadge(urlData); // Only register menu once to prevent duplicates if (!menuRegistered) { registerMenu(); menuRegistered = true; } } function registerMenu() { // Register static menu items once to prevent duplicates GM_registerMenuCommand('👁️ Toggle Badge', toggleBadgeVisibility); GM_registerMenuCommand('📊 Export Data', exportData); GM_registerMenuCommand('📈 Show Statistics', showStatistics); GM_registerMenuCommand('🗑️ Clear Current Page', clearCurrentPage); GM_registerMenuCommand('💥 Clear All Data', clearAllData); GM_registerMenuCommand('🚫 Toggle URL Filtering', toggleUrlFiltering); GM_registerMenuCommand('🔍 Toggle Search URL Cleaning', toggleSearchCleaning); GM_registerMenuCommand('🐛 Toggle Debug Mode', toggleDebugMode); } function exportData() { try { // Use cached DB for export - same data, no extra I/O const db = getDBCached(); const dataStr = JSON.stringify(db, null, 2); const blob = new Blob([dataStr], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `visit-tracker-${new Date().toISOString().split('T')[0]}.json`; // Safely append to DOM if (document.body) { document.body.appendChild(a); a.click(); document.body.removeChild(a); } else { // Fallback for early DOM state a.click(); } URL.revokeObjectURL(url); } catch (error) { console.error('Export failed:', error); alert('Failed to export data: ' + error.message); } } function showStatistics() { // Use cached DB for statistics - read-only operation const db = getDBCached(); const urls = Object.keys(db); const totalUrls = urls.length; // Handle empty database if (totalUrls === 0) { alert('📈 Visit Tracker Statistics\n\n🌐 No websites tracked yet!\n\nStart browsing to collect visit data.'); return; } const totalVisits = urls.reduce((sum, url) => sum + db[url].count, 0); // Find most visited site using optional chaining const mostVisited = urls.reduce((max, url) => db[url].count > (db[max]?.count ?? 0) ? url : max, ''); // Find oldest entry using optional chaining const oldestEntry = urls.reduce((oldest, url) => { const visits = db[url].visits; if (!visits?.length) return oldest; const lastVisit = visits[visits.length - 1]; const oldestVisits = db[oldest]?.visits; if (!oldestVisits?.length) return url; const oldestLastVisit = oldestVisits[oldestVisits.length - 1]; return lastVisit < oldestLastVisit ? url : oldest; }, ''); const stats = ` 📈 Visit Tracker Statistics 🌐 Total websites tracked: ${totalUrls} 👆 Total visits recorded: ${totalVisits} 🏆 Most visited: ${mostVisited} (${db[mostVisited]?.count ?? 0} visits) ⏰ Oldest tracked site: ${oldestEntry} 📅 Current page visits: ${db[currentUrl]?.count ?? 0} Database size: ${Math.round(getActualDataSize(db) / 1024)} KB (UTF-8) `.trim(); alert(stats); } function clearCurrentPage() { if (confirm(`Clear visit data for current page?\n\nURL: ${currentUrl}\nThis will only affect this page.`)) { const db = getDB(); // Clear old data and immediately set new entry in single operation const now = new Date(); const timestamp = createTimestamp(now); db[currentUrl] = { count: 1, visits: [timestamp] }; setDB(db); // Update UI immediately with new data renderBadge(db[currentUrl]); alert('Current page data cleared! Counter reset to 1.'); } } function clearAllData() { if (confirm('⚠️ WARNING: This will clear ALL visit data from ALL websites!\n\nAre you absolutely sure?')) { // Clear all data and immediately create new entry for current page in single operation const now = new Date(); const timestamp = createTimestamp(now); const newDb = {}; newDb[currentUrl] = { count: 1, visits: [timestamp] }; setDB(newDb); // Update UI immediately with new data renderBadge(newDb[currentUrl]); alert('All visit data cleared! Current page counter reset to 1.'); } } function ensureBadgeStyles() { if (document.getElementById('vt-hover-styles')) return; const css = ` .vt-badge { position: fixed; right: ${CONFIG.BADGE_POSITION.right}; bottom: ${CONFIG.BADGE_POSITION.bottom}; z-index: 2147483647; font-family: system-ui, sans-serif; cursor: pointer; transition: all 0.3s ease; } .vt-badge.hidden { opacity: 0; pointer-events: none; transform: scale(0.8); } .vt-link { display: inline-block; padding: 6px 10px; border-radius: 9999px; background: rgba(20,20,20,0.9); color: #fff !important; font-size: 12px; box-shadow: 0 4px 14px rgba(0,0,0,0.2); opacity: 0.85; transition: opacity 0.2s ease; } .vt-badge:hover .vt-link { opacity: 1; } .vt-tooltip { position: absolute; bottom: 120%; right: 0; background: #111; color: #fff; border-radius: 10px; padding: 8px 10px; font-size: 12px; white-space: nowrap; box-shadow: 0 10px 25px rgba(0,0,0,0.35); opacity: 0; transform: translateY(6px); transition: opacity 140ms ease, transform 140ms ease; pointer-events: none; } .vt-badge:hover .vt-tooltip { opacity: 1; transform: translateY(0); } .vt-tooltip .vt-line { display: block; } `; const style = document.createElement('style'); style.id = 'vt-hover-styles'; style.textContent = css; document.documentElement.appendChild(style); } function renderBadge(data) { ensureBadgeStyles(); let badge = document.getElementById('vt-hover-badge'); if (!badge) { badge = document.createElement('div'); badge.id = 'vt-hover-badge'; badge.className = 'vt-badge'; badge.innerHTML = ` <a class="vt-link" href="javascript:void(0)"></a> <div class="vt-tooltip"></div> `; // Add click handler for toggle visibility badge.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleBadgeVisibility(); }); document.documentElement.appendChild(badge); } // Apply visibility state if (!badgeVisible) { badge.classList.add('hidden'); } else { badge.classList.remove('hidden'); } badge.querySelector('.vt-link').textContent = `Visit: ${shortenNumber(data.count)}`; const tooltip = badge.querySelector('.vt-tooltip'); tooltip.innerHTML = `<span class="vt-line">Visit: ${data.count}</span>`; // Handle empty visits array - format timestamps for display if (data.visits && data.visits.length > 0) { data.visits.forEach((timestamp, i) => { const formattedTime = formatTimestamp(timestamp); tooltip.innerHTML += `<span class="vt-line">${i + 1}. ${formattedTime}</span>`; }); } else { tooltip.innerHTML += `<span class="vt-line">No visit history</span>`; } } function toggleBadgeVisibility() { badgeVisible = !badgeVisible; const badge = document.getElementById('vt-hover-badge'); if (badge) { if (badgeVisible) { badge.classList.remove('hidden'); } else { badge.classList.add('hidden'); } } // Save state to GM storage try { GM_setValue('badgeVisible', badgeVisible); } catch (error) { console.warn('Failed to save badge visibility state:', error); } } function toggleUrlFiltering() { CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES = !CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES; // Save state to GM storage try { GM_setValue('urlFiltering', CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES); } catch (error) { console.warn('Failed to save URL filtering state:', error); } const status = CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES ? 'enabled' : 'disabled'; alert(`🚫 URL Filtering ${status}!\n\nUtility pages (cookies, auth, etc.) filtering is now ${status}.`); } function toggleSearchCleaning() { CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS = !CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS; // Save state to GM storage try { GM_setValue('searchCleaning', CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS); } catch (error) { console.warn('Failed to save search cleaning state:', error); } const status = CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS ? 'enabled' : 'disabled'; alert(`🔍 Search URL Cleaning ${status}!\n\nSearch URLs will now ${CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS ? 'be cleaned (grouped by query)' : 'be tracked as-is (separate tracking)'}.`); } function toggleDebugMode() { CONFIG.DEBUG = !CONFIG.DEBUG; // Save state to GM storage try { GM_setValue('debugMode', CONFIG.DEBUG); } catch (error) { console.warn('Failed to save debug mode state:', error); } const status = CONFIG.DEBUG ? 'enabled' : 'disabled'; alert(`🐛 Debug mode ${status}!\n\nDebug logging is now ${status}.`); if (CONFIG.DEBUG) { console.log('🐛 Visit Tracker Debug Mode: ENABLED'); } } // Rate limiting for URL changes with pending mechanism let lastUrlChangeTime = 0; let pendingUrlChange = null; let pendingTimeout = null; const URL_CHANGE_MIN_INTERVAL = 500; // Minimum 500ms between URL changes function onUrlChange() { const newUrl = normalizeUrl(location.href); if (newUrl === currentUrl) return; // Skip tracking if URL matches filter patterns if (shouldSkipUrl(location.href)) { if (CONFIG.DEBUG) { console.log(`🚫 Skipping URL tracking: ${location.href}`); } return; } // Increment activity counter for adaptive polling if (CONFIG.POLLING.ADAPTIVE) { activityCount = Math.min(10, activityCount + 2); // Cap at 10, add 2 for URL change } const now = Date.now(); const timeSinceLastChange = now - lastUrlChangeTime; if (timeSinceLastChange < URL_CHANGE_MIN_INTERVAL) { if (CONFIG.DEBUG) { console.log(`⏰ URL change rate limited, scheduling: ${currentUrl} → ${newUrl}`); } // Store the pending change without updating currentUrl yet pendingUrlChange = newUrl; // Clear any existing pending timeout if (pendingTimeout) { clearTimeout(pendingTimeout); } // Schedule the change for when rate limit expires const remainingTime = URL_CHANGE_MIN_INTERVAL - timeSinceLastChange; pendingTimeout = setTimeout(() => { if (pendingUrlChange && pendingUrlChange !== currentUrl) { if (CONFIG.DEBUG) { console.log(`⏰ Processing pending URL change: ${currentUrl} → ${pendingUrlChange}`); } const savedPendingUrl = pendingUrlChange; pendingUrlChange = null; pendingTimeout = null; // Process the pending change currentUrl = savedPendingUrl; lastUrlChangeTime = Date.now(); updateVisit(); } }, remainingTime + 10); // +10ms buffer return; } // Clear any pending changes since we're processing immediately if (pendingTimeout) { clearTimeout(pendingTimeout); pendingTimeout = null; pendingUrlChange = null; } if (CONFIG.DEBUG) { console.log(`🌐 URL changed: ${currentUrl} → ${newUrl}`); } currentUrl = newUrl; lastUrlChangeTime = now; updateVisit(); } function installUrlObservers() { // Enhanced history hooks with rate limiting const _pushState = history.pushState; const _replaceState = history.replaceState; history.pushState = function (...args) { const result = _pushState.apply(this, args); // Use setTimeout to avoid immediate execution conflicts setTimeout(onUrlChange, 50); return result; }; history.replaceState = function (...args) { const result = _replaceState.apply(this, args); setTimeout(onUrlChange, 50); return result; }; // Standard event listeners window.addEventListener('popstate', onUrlChange); window.addEventListener('hashchange', onUrlChange); // Optimized MutationObserver with focused title tracking let mutationTimeout = null; const mo = new MutationObserver((mutations) => { // Throttle mutation processing to avoid spam if (mutationTimeout) return; mutationTimeout = setTimeout(() => { mutationTimeout = null; let titleChanged = false; for (const mutation of mutations) { // Only check mutations that could affect title if (mutation.type === 'childList') { // Case 1: Title element added/removed from head const titleInAdded = Array.from(mutation.addedNodes).some(node => node.nodeName === 'TITLE' ); const titleInRemoved = Array.from(mutation.removedNodes).some(node => node.nodeName === 'TITLE' ); // Case 2: Direct title element changes if (mutation.target.nodeName === 'TITLE') { titleChanged = true; if (CONFIG.DEBUG) { console.log('📝 Title childList mutation detected:', mutation); } break; } if (titleInAdded || titleInRemoved) { titleChanged = true; if (CONFIG.DEBUG) { console.log('📝 Title element added/removed:', mutation); } break; } } // Case 3: Character data changed in title's text nodes (more targeted) else if (mutation.type === 'characterData' && mutation.target.parentNode?.nodeName === 'TITLE') { titleChanged = true; if (CONFIG.DEBUG) { console.log('📝 Title characterData mutation detected:', mutation); } break; } } if (titleChanged) { if (CONFIG.DEBUG) { console.log('📝 Title change detected, triggering URL change check'); } onUrlChange(); } }, 150); // Slightly increased debounce for better performance }); // Safely observe document.head with focused title tracking if (document.head) { mo.observe(document.head, { childList: true, // Detect title element addition/removal subtree: false, // Only direct children for better performance characterData: false // Handle characterData separately for title only }); // Separate observer for title content changes const titleEl = document.querySelector('title'); if (titleEl) { mo.observe(titleEl, { childList: true, characterData: true, subtree: true }); } } else { // Fallback: observe document for head creation (minimal scope) mo.observe(document, { childList: true, subtree: false }); } // Initialize polling startPolling(); } const tooltip = document.createElement('div'); // Apply styles using individual properties for better compatibility Object.assign(tooltip.style, { position: 'fixed', padding: '6px 8px', fontSize: '12px', fontFamily: 'system-ui, sans-serif', background: 'rgba(20, 20, 20, 0.9)', color: 'white', borderRadius: '6px', pointerEvents: 'none', whiteSpace: 'nowrap', zIndex: '999999', opacity: '0', transition: 'opacity 0.15s ease' }); // Safely append tooltip to DOM function appendTooltipSafely() { if (document.body) { try { document.body.appendChild(tooltip); return true; } catch (error) { console.warn('Failed to append tooltip to body:', error); return false; } } else { // Fallback for early DOM state document.addEventListener('DOMContentLoaded', () => { try { if (document.body && !document.body.contains(tooltip)) { document.body.appendChild(tooltip); } } catch (error) { console.warn('Failed to append tooltip on DOMContentLoaded:', error); } }, { passive: true, once: true }); return false; } } if (!appendTooltipSafely()) { if (CONFIG.DEBUG) { console.log('📋 Tooltip will be appended when DOM is ready'); } } let hoverTimer; let currentHoveredLink = null; let rafId = null; // RequestAnimationFrame ID for smooth tooltip movement let pendingTooltipPosition = null; // Store pending position updates function showTooltip(e, linkUrl) { const key = normalizeUrl(linkUrl); // Use cached DB for hot path performance - no storage I/O! const db = getDBCached(); const data = db[key]; // Clear previous content safely tooltip.textContent = ''; if (!data) { tooltip.textContent = 'No visits recorded'; } else { // Create elements safely instead of using innerHTML const visitLine = document.createElement('div'); visitLine.textContent = `Visit: ${shortenNumber(data.count)}`; const lastLine = document.createElement('div'); // Format timestamp for display using optional chaining const lastVisit = data.visits?.[0] ? formatTimestamp(data.visits[0]) : 'Never'; lastLine.textContent = `Last: ${lastVisit}`; tooltip.appendChild(visitLine); tooltip.appendChild(lastLine); } // Set initial position updateTooltipPosition(e.clientX, e.clientY); tooltip.style.opacity = 1; } function updateTooltipPosition(x, y) { // Store the position to be updated in the next frame pendingTooltipPosition = { x: x + 12, y: y + 12 }; // Cancel previous frame if it exists if (rafId) { cancelAnimationFrame(rafId); } // Schedule position update for next frame rafId = requestAnimationFrame(() => { if (pendingTooltipPosition) { tooltip.style.left = pendingTooltipPosition.x + 'px'; tooltip.style.top = pendingTooltipPosition.y + 'px'; pendingTooltipPosition = null; } rafId = null; }); } function hideTooltip() { tooltip.style.opacity = 0; currentHoveredLink = null; // Cancel any pending animation frame if (rafId) { cancelAnimationFrame(rafId); rafId = null; } pendingTooltipPosition = null; // Ensure mousemove listener is properly removed document.removeEventListener('mousemove', moveTooltip); } function moveTooltip(e) { // Use requestAnimationFrame for smooth movement updateTooltipPosition(e.clientX, e.clientY); } // Improved mouse event handling to prevent tooltip flicker // Using passive listeners for better performance on heavy pages document.addEventListener('mouseover', e => { const a = safeClosest(e.target, 'a[href]'); if (!a) return; const href = a.href; if (!/^https?:\/\//.test(href)) return; // Prevent duplicate listeners for same link if (currentHoveredLink === a) return; // Clean up previous link if any if (currentHoveredLink) { clearTimeout(hoverTimer); hideTooltip(); } currentHoveredLink = a; clearTimeout(hoverTimer); hoverTimer = setTimeout(() => showTooltip(e, href), CONFIG.HOVER_DELAY); document.addEventListener('mousemove', moveTooltip, { passive: true }); }, { passive: true }); // Use mouseout with relatedTarget check to prevent flicker from child elements document.addEventListener('mouseout', e => { const a = safeClosest(e.target, 'a[href]'); if (!a || a !== currentHoveredLink) return; // Check if we're moving to a child element of the same link const relatedTarget = e.relatedTarget; if (relatedTarget && a.contains(relatedTarget)) { if (CONFIG.DEBUG) { console.log('🔗 Mouse moved to child element, keeping tooltip visible'); } return; // Still within the same link, don't hide tooltip } // Also check if we're moving from child to parent within same link const relatedLink = safeClosest(relatedTarget, 'a[href]'); if (relatedLink === a) { if (CONFIG.DEBUG) { console.log('🔗 Mouse moved within same link structure, keeping tooltip visible'); } return; // Still within the same link structure } if (CONFIG.DEBUG) { console.log('🔗 Mouse left link, hiding tooltip'); } clearTimeout(hoverTimer); hideTooltip(); }, { passive: true }); // Initialize the tracker function initializeTracker() { // Load saved badge visibility state try { badgeVisible = GM_getValue('badgeVisible', CONFIG.BADGE_VISIBLE); } catch (error) { console.warn('Failed to load badge visibility state:', error); badgeVisible = CONFIG.BADGE_VISIBLE; } // Load saved debug mode state try { CONFIG.DEBUG = GM_getValue('debugMode', CONFIG.DEBUG); } catch (error) { console.warn('Failed to load debug mode state:', error); CONFIG.DEBUG = false; } // Load saved URL filtering state try { CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES = GM_getValue('urlFiltering', CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES); } catch (error) { console.warn('Failed to load URL filtering state:', error); CONFIG.URL_FILTERS.SKIP_UTILITY_PAGES = true; } // Load saved search cleaning state try { CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS = GM_getValue('searchCleaning', CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS); } catch (error) { console.warn('Failed to load search cleaning state:', error); CONFIG.NORMALIZE_URL.CLEAN_SEARCH_URLS = true; } if (CONFIG.DEBUG) { console.log('🐛 Visit Tracker Debug Mode: ENABLED'); } // Don't register menu for initial empty state - let updateVisit() handle it updateVisit(); installUrlObservers(); // Handle polling optimization and cache invalidation for multi-tab scenarios document.addEventListener('visibilitychange', () => { if (document.hidden) { // Tab became hidden - pause polling if configured if (CONFIG.POLLING.PAUSE_WHEN_HIDDEN) { stopPolling(); } } else { // Tab became visible - resume polling if it was paused if (CONFIG.POLLING.PAUSE_WHEN_HIDDEN && !pollTimer) { // Boost activity for immediate responsiveness when tab becomes visible if (CONFIG.POLLING.ADAPTIVE) { activityCount = Math.min(10, activityCount + 3); } startPolling(); } // Multi-tab cache coordination if enabled if (CONFIG.MULTI_TAB.ENABLED) { invalidateCache(); } } }, { passive: true }); // Multi-tab cache synchronization if enabled if (CONFIG.MULTI_TAB.ENABLED) { setInterval(() => { if (!document.hidden) { invalidateCache(); } }, CONFIG.MULTI_TAB.SYNC_INTERVAL); } } // Cleanup pending operations on page unload window.addEventListener('beforeunload', () => { if (pendingTimeout) { clearTimeout(pendingTimeout); // Process any pending URL change immediately before unload if (pendingUrlChange && pendingUrlChange !== currentUrl) { currentUrl = pendingUrlChange; updateVisit(); } } }); initializeTracker(); })();