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