您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Display comprehensive portfolio statistics on Google Finance
// ==UserScript== // @name Google Finance Statistics // @namespace http://tampermonkey.net/ // @version 1.2 // @description Display comprehensive portfolio statistics on Google Finance // @author MakMak // @match https://www.google.com/finance/* // @icon https://www.gstatic.com/finance/favicon/favicon.png // @grant none // @license MIT // ==/UserScript== (function() { 'use strict'; // --- Configuration --- const realizedGainSelector = "div.H1MkHc > span.P2Luy.Ez2Ioe"; const unrealizedGainSelector = "div.hrdhqb"; const portfolioValueSelector = "div.YMlKec.fxKbKc"; const activityTabSelector = "div[data-tab-id='activity']"; const notificationId = 'gain-calculator-userscript-result'; let currentUrl = window.location.href; let calculationTimeout; let isActivityTabSelected = false; // Drag & Drop state let isDragging = false; let dragOffset = { x: 0, y: 0 }; let currentNotification = null; // --- Helper Functions --- function isPortfolioPage() { const url = window.location.href; return url.includes('/finance/portfolio/'); } function parseCurrency(text) { if (typeof text !== 'string' || !text) return NaN; // Remove currency symbols, thousand separators, and whitespace, then parse const cleanText = text.trim().replace(/[€$,]/g, ''); return parseFloat(cleanText); } function getLastElement(selector) { const elements = document.querySelectorAll(selector); if (elements.length === 0) return null; // Filter out grayed/disabled elements by checking opacity or visibility const activeElements = Array.from(elements).filter(el => { const style = window.getComputedStyle(el); return style.opacity !== '0' && style.visibility !== 'hidden' && style.display !== 'none' && !el.closest('[style*="opacity: 0"]') && !el.closest('[style*="visibility: hidden"]'); }); return activeElements.length > 0 ? activeElements[activeElements.length - 1] : null; } function checkActivityTab() { const activityTabs = document.querySelectorAll(activityTabSelector); if (activityTabs.length === 0) return false; const lastActivityTab = activityTabs[activityTabs.length - 1]; return lastActivityTab.getAttribute('aria-selected') === 'true'; } function handleActivityTabChange() { // Skip if not on portfolio page if (!isPortfolioPage()) return; const currentActivityTabState = checkActivityTab(); if (currentActivityTabState !== isActivityTabSelected) { isActivityTabSelected = currentActivityTabState; if (isActivityTabSelected) { console.log('Activity tab selected, refreshing Realized Gain statistics...'); // Wait a bit for the activity data to load, then recalculate setTimeout(() => { calculateStatistics(); }, 800); } } } function waitForElements(selectors, maxWait = 5000) { return new Promise((resolve) => { const startTime = Date.now(); function check() { const found = selectors.every(selector => getLastElement(selector) !== null); if (found || Date.now() - startTime > maxWait) { resolve(found); } else { setTimeout(check, 100); } } check(); }); } // --- Drag & Drop Functions --- function getEventCoords(e) { // Handle both mouse and touch events if (e.touches && e.touches.length > 0) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } else if (e.changedTouches && e.changedTouches.length > 0) { return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; } else { return { x: e.clientX, y: e.clientY }; } } function startDrag(e) { if (!currentNotification) return; isDragging = true; const coords = getEventCoords(e); const rect = currentNotification.getBoundingClientRect(); dragOffset.x = coords.x - rect.left; dragOffset.y = coords.y - rect.top; // Add dragging class for visual feedback currentNotification.classList.add('dragging'); // Prevent default to avoid text selection on desktop e.preventDefault(); } function drag(e) { if (!isDragging || !currentNotification) return; e.preventDefault(); const coords = getEventCoords(e); let newX = coords.x - dragOffset.x; let newY = coords.y - dragOffset.y; // Keep within viewport bounds const rect = currentNotification.getBoundingClientRect(); const maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; newX = Math.max(0, Math.min(newX, maxX)); newY = Math.max(0, Math.min(newY, maxY)); currentNotification.style.left = newX + 'px'; currentNotification.style.top = newY + 'px'; currentNotification.style.right = 'auto'; // Override right positioning } function stopDrag() { if (!isDragging || !currentNotification) return; isDragging = false; currentNotification.classList.remove('dragging'); } function setupDragAndDrop(element, handleContainer) { // Create a drag handle const dragHandle = document.createElement('div'); dragHandle.className = 'drag-handle'; dragHandle.innerHTML = '⋮⋮'; dragHandle.title = 'Drag to move'; // Style the drag handle Object.assign(dragHandle.style, { // No position: absolute. It will be positioned by its flex container. width: '24px', height: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'move', color: '#666', fontSize: '16px', fontWeight: 'bold', userSelect: 'none', touchAction: 'none', borderRadius: '4px' // Add rounding for a better look }); // Mouse events dragHandle.addEventListener('mousedown', startDrag); document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag); // Touch events for mobile dragHandle.addEventListener('touchstart', startDrag, { passive: false }); document.addEventListener('touchmove', drag, { passive: false }); document.addEventListener('touchend', stopDrag); // Add the drag handle to the provided container, *above* other items handleContainer.prepend(dragHandle); // No longer need to adjust content padding } // --- Main Calculation Logic --- async function calculateStatistics() { // Only calculate if we're on a portfolio page if (!isPortfolioPage()) { console.log('Skipping calculation - not on portfolio page'); // Remove any existing notification const oldNotification = document.getElementById(notificationId); if (oldNotification) oldNotification.remove(); return; } try { // Wait for essential elements to be available const elementsReady = await waitForElements([portfolioValueSelector, unrealizedGainSelector]); if (!elementsReady) { throw new Error('Required elements not found after waiting'); } // 1. Extract Current Portfolio Value (get last active element) const portfolioValueElement = getLastElement(portfolioValueSelector); if (!portfolioValueElement) throw new Error(`Portfolio Value element not found with selector "${portfolioValueSelector}".`); const portfolioValue = parseCurrency(portfolioValueElement.innerText); if (isNaN(portfolioValue)) throw new Error(`Could not parse Portfolio Value from "${portfolioValueElement.innerText}".`); // 2. Extract Unrealized Gain (get last active element) const unrealizedGainElements = document.querySelectorAll(unrealizedGainSelector); const activeUnrealizedElements = Array.from(unrealizedGainElements).filter(el => { const style = window.getComputedStyle(el); return style.opacity !== '0' && style.visibility !== 'hidden' && style.display !== 'none' && !el.closest('[style*="opacity: 0"]') && !el.closest('[style*="visibility: hidden"]'); }); if (activeUnrealizedElements.length < 2) throw new Error(`Unrealized Gain element not found with selector "${unrealizedGainSelector}" at index 1.`); const unrealizedGainElement = activeUnrealizedElements[1]; const unrealizedGainText = unrealizedGainElement.innerText.split('\n')[0]; const unrealizedGain = parseCurrency(unrealizedGainText); if (isNaN(unrealizedGain)) throw new Error(`Could not parse Unrealized Gain value from "${unrealizedGainText}".`); // 3. Calculate Realized Gain (handles cases where it's not found) let realizedGain = 0; const realizedGainElements = document.querySelectorAll(realizedGainSelector); const activeRealizedElements = Array.from(realizedGainElements).filter(el => { const style = window.getComputedStyle(el); return style.opacity !== '0' && style.visibility !== 'hidden' && style.display !== 'none' && !el.closest('[style*="opacity: 0"]') && !el.closest('[style*="visibility: hidden"]'); }); if (activeRealizedElements.length > 0) { activeRealizedElements.forEach(el => { const text = el.innerText.trim(); if (text.startsWith('+') || text.startsWith('-')) { const value = parseFloat(text.replace(',', '.')); if (!isNaN(value)) { realizedGain += value; } } }); } // 4. Calculate All Statistics const totalInvested = portfolioValue - unrealizedGain - realizedGain; const totalGain = realizedGain + unrealizedGain; const pctRealized = totalInvested === 0 ? 0 : (realizedGain / totalInvested) * 100; const pctUnrealized = totalInvested === 0 ? 0 : (unrealizedGain / totalInvested) * 100; const pctTotalGain = totalInvested === 0 ? 0 : (totalGain / totalInvested) * 100; // 5. Prepare and Display the Results const results = { portfolioValue: portfolioValue.toFixed(2), totalInvested: totalInvested.toFixed(2), realizedGain: realizedGain.toFixed(2), pctRealized: pctRealized.toFixed(2) + '%', unrealizedGain: unrealizedGain.toFixed(2), pctUnrealized: pctUnrealized.toFixed(2) + '%', totalGain: totalGain.toFixed(2), pctTotalGain: pctTotalGain.toFixed(2) + '%', // Show hint icon if realized gain is 0 and the activity tab isn't selected showActivityHint: realizedGain === 0 && !isActivityTabSelected }; displayNotification(results, 'success'); } catch (error) { console.error("Userscript Error:", error); displayNotification({ error: error.message }, "error"); } } // --- UI Function --- function displayNotification(data, type = "success") { const oldNotification = document.getElementById(notificationId); if (oldNotification) oldNotification.remove(); const notification = document.createElement('div'); notification.id = notificationId; currentNotification = notification; const content = document.createElement('div'); if (type === 'error') { content.innerHTML = `<strong>Error:</strong><br><small>${data.error}</small>`; } else { let realizedGainHtml; if (data.showActivityHint) { realizedGainHtml = ` <div class="value-with-icon"> <span>📈 Realized Gain: <div class="info-icon" tabindex="0"> i <div class="info-tooltip">Select the 'Activity' tab to include any realized P/L in the calculation.</div> </div> </span> </div> `; } else { realizedGainHtml = `<span>📈 Realized Gain:</span>`; } content.innerHTML = ` <div class="stat-line"><span>🏦 Portfolio Value:</span> <strong>${data.portfolioValue}</strong></div> <div class="stat-line"><span>💵 Total Invested:</span> <strong>${data.totalInvested}</strong></div> <hr> <div class="stat-line"><span>🌱 Unrealized Gain:</span> <strong>${data.unrealizedGain}</strong></div> <div class="stat-line"><span>📊 Pct Unrealized:</span> <strong>${data.pctUnrealized}</strong></div> <hr> <div class="stat-line">${realizedGainHtml} <strong>${data.realizedGain}</strong></div> <div class="stat-line"><span>📊 Pct Realized:</span> <strong>${data.pctRealized}</strong></div> <hr> <div class="stat-line"><span>💰 Total Gain:</span> <strong>${data.totalGain}</strong></div> <div class="stat-line"><span>🚀 Pct Total Gain:</span> <strong>${data.pctTotalGain}</strong></div> `; } const controlsWrapper = document.createElement('div'); controlsWrapper.className = 'controls-wrapper'; const closeButton = document.createElement('span'); closeButton.textContent = '×'; closeButton.onclick = () => { notification.remove(); currentNotification = null; }; controlsWrapper.appendChild(closeButton); notification.appendChild(content); notification.appendChild(controlsWrapper); document.body.appendChild(notification); // Setup drag and drop, placing the handle in the controls wrapper setupDragAndDrop(notification, controlsWrapper); const style = document.createElement('style'); style.innerHTML = ` #${notificationId} { position: fixed !important; top: 68px; right: 20px; padding: 16px; background-color: ${type === 'error' ? '#c82333' : '#f8f9fa'}; color: black; border-radius: 8px; z-index: 99999; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 16px; line-height: 1.7; box-shadow: 0 6px 12px rgba(0,0,0,0.25); display: flex; align-items: flex-start; gap: 8px; /* Gap between content and controls */ user-select: none; touch-action: none; min-width: 280px; max-width: 350px; } #${notificationId}.dragging { box-shadow: 0 12px 24px rgba(0,0,0,0.4); transform: scale(1.02); transition: transform 0.1s ease; } #${notificationId} .controls-wrapper { display: flex; flex-direction: column; align-items: center; gap: 8px; /* Space between drag handle and close button */ } #${notificationId} .stat-line { display: flex; justify-content: space-between; align-items: center; /* Align items vertically */ gap: 20px; } #${notificationId} hr { border: none; border-top: 1px solid #444; margin: 8px 0; } /* --- Styles for the info icon and tooltip --- */ #${notificationId} .value-with-icon { display: flex; align-items: center; gap: 8px; } #${notificationId} .info-icon { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background-color: #e0e0e0; color: #616161; font-size: 11px; font-weight: bold; font-style: italic; cursor: pointer; user-select: none; outline: none; } #${notificationId} .info-icon:hover, #${notificationId} .info-icon:focus { background-color: #c0c0c0; } #${notificationId} .info-tooltip { visibility: hidden; opacity: 0; width: 220px; background-color: #333; color: #fff; text-align: center; border-radius: 6px; padding: 10px; position: absolute; z-index: 10; bottom: 150%; left: 50%; transform: translateX(-50%); transition: opacity 0.3s, visibility 0.3s; font-size: 13px; line-height: 1.4; font-weight: normal; font-style: normal; box-shadow: 0 2px 5px rgba(0,0,0,0.2); pointer-events: none; } #${notificationId} .info-tooltip::after { /* Tooltip arrow */ content: ''; position: absolute; top: 100%; left: 50%; margin-left: -5px; border-width: 5px; border-style: solid; border-color: #333 transparent transparent transparent; } #${notificationId} .info-icon:hover .info-tooltip, #${notificationId} .info-icon:focus .info-tooltip { visibility: visible; opacity: 1; } /* --- End of info styles --- */ #${notificationId} .drag-handle:hover { color: #333; background-color: rgba(0,0,0,0.1); } #${notificationId} .drag-handle:active { color: #000; background-color: rgba(0,0,0,0.2); } /* Mobile-specific styles */ @media (max-width: 768px) { #${notificationId} { font-size: 14px; min-width: 260px; max-width: calc(100vw - 40px); } #${notificationId} .drag-handle { width: 24px !important; height: 24px !important; font-size: 16px !important; } } `; document.head.appendChild(style); Object.assign(content.style, { display: 'flex', flexDirection: 'column', gap: '4px', flex: '1' }); Object.assign(closeButton.style, { fontSize: '24px', fontWeight: 'bold', cursor: 'pointer', opacity: '0.8', lineHeight: '0.8', minWidth: '24px', textAlign: 'center', userSelect: 'none' }); } // --- URL Change Detection --- function handleUrlChange() { const newUrl = window.location.href; if (newUrl !== currentUrl) { currentUrl = newUrl; console.log('URL changed to:', currentUrl); // Remove existing notification when URL changes const oldNotification = document.getElementById(notificationId); if (oldNotification) { oldNotification.remove(); currentNotification = null; } // Clear any pending calculations if (calculationTimeout) { clearTimeout(calculationTimeout); } // Reset activity tab state isActivityTabSelected = false; // Only calculate if we're on a portfolio page if (!isPortfolioPage()) { console.log('Navigated away from portfolio page - skipping calculation'); return; } // Wait a bit for the new portfolio to load, then calculate calculationTimeout = setTimeout(() => { calculateStatistics(); }, 1500); } } // --- Initialize --- function init() { // Initial calculation when script loads (only on portfolio pages) setTimeout(() => { if (isPortfolioPage()) { calculateStatistics(); // Check initial activity tab state isActivityTabSelected = checkActivityTab(); } else { console.log('Started on non-portfolio page - skipping initial calculation'); } }, 1000); // Monitor for URL changes and DOM changes (for SPA navigation and activity tab changes) const observer = new MutationObserver((mutations) => { // Check for URL changes handleUrlChange(); // Check for activity tab changes mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'aria-selected' && mutation.target.matches(activityTabSelector)) { handleActivityTabChange(); } else if (mutation.type === 'childList') { // Also check when new elements are added (in case tabs are dynamically created) setTimeout(handleActivityTabChange, 100); } }); }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-selected'] }); // Also listen for popstate events window.addEventListener('popstate', handleUrlChange); // Override pushState and replaceState to catch programmatic navigation const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function(...args) { originalPushState.apply(history, args); setTimeout(handleUrlChange, 100); }; history.replaceState = function(...args) { originalReplaceState.apply(history, args); setTimeout(handleUrlChange, 100); }; } // Start the script when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址