V2EX Solana Balance Checker

在V2EX个人主页显示Solana&V2EX资产余额、实时单价和总价值。总价值栏包含可点击复制的完整地址,并适配V2EX日/夜主题。

// ==UserScript==
// @name         V2EX Solana Balance Checker
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  在V2EX个人主页显示Solana&V2EX资产余额、实时单价和总价值。总价值栏包含可点击复制的完整地址,并适配V2EX日/夜主题。
// @author       Lome (Modified by Gemini)
// @match        https://www.v2ex.com/member/*
// @match        https://v2ex.com/member/*
// @match        https://*.v2ex.com/member/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @run-at       document-idle
// @connect      api.mainnet-beta.solana.com
// @connect      rpc.ankr.com
// @connect      solana-mainnet.rpc.extrnode.com
// @connect      api.geckoterminal.com
// ==/UserScript==

(function() {
    'use strict';

    // 1. 配置信息 (Configuration)
    const V2EX_TOKEN_MINT_ADDRESS = '9raUVuzeWUk53co63M4WXLWPWE4Xc6Lpn7RS9dnkpump';
    const SOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112';
    const RPC_ENDPOINTS = [ 'https://rpc.ankr.com/solana', 'https://api.mainnet-beta.solana.com', 'https://solana-mainnet.rpc.extrnode.com' ];

    // =================================================================================
    // 主函数 - 脚本核心逻辑
    // =================================================================================
    function main(anchorElement) {
        // 2. 从页面中查找用户的 Solana 地址
        function findAddressOnPage() {
            const scripts = document.querySelectorAll('script');
            for (const script of scripts) {
                if (script.textContent.includes('const address =')) {
                    const match = script.textContent.match(/const address = "([1-9A-HJ-NP-Za-km-z]{32,44})";/);
                    if (match && match[1]) { return match[1]; }
                }
            }
            return null;
        }

        const userSolanaAddress = findAddressOnPage();
        if (!userSolanaAddress) {
            console.error('V2EX Balance Checker: Could not find Solana address on page.');
            return;
        }

        // 3. 添加基础样式 (Layout Styles)
        GM_addStyle(`
            .solana-balance-box { background-color: var(--box-background-color); border-bottom: 1px solid var(--box-border-color); margin-bottom: 20px; }
            .solana-balance-table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-bottom: -1px; }
            .solana-balance-table th, .solana-balance-table td { padding: 12px; text-align: left; border-top: 1px solid var(--box-border-color); font-size: 14px; line-height: 1.6; vertical-align: middle; }
            .solana-balance-table th { background-color: var(--box-header-background-color); font-weight: bold; }
            .solana-balance-table td { font-family: var(--mono-font); word-wrap: break-word; }
            .total-row td { font-weight: bold; border-top: 2px solid var(--box-border-color); }
            .solana-balance-table th:nth-child(1), .solana-balance-table td:nth-child(1) { width: 15%; text-align: center; } /* Asset */
            .solana-balance-table th:nth-child(2), .solana-balance-table td:nth-child(2) { width: 35%; } /* Balance */
            .solana-balance-table th:nth-child(3), .solana-balance-table td:nth-child(3) { width: 25%; } /* Price */
            .solana-balance-table th:nth-child(4), .solana-balance-table td:nth-child(4) { width: 25%; } /* Value */
            .copy-address-link {
                color: var(--link-color); /* 使用 V2EX 原生链接颜色 */
                font-family: var(--mono-font);
                font-weight: normal;
                font-size: 12px;
                margin-left: 10px;
                cursor: pointer;
                text-decoration: none;
                word-break: break-all; /* 强制长地址换行 */
            }
            .copy-address-link:hover {
                text-decoration: underline;
            }
        `);

        // 4. 创建并插入 DOM 元素 (Create & Insert DOM)
        const container = document.createElement('div');
        container.className = 'solana-balance-box';
        container.innerHTML = `
            <table class="solana-balance-table">
                <thead>
                    <tr>
                        <th>Token</th>
                        <th>Balance</th>
                        <th>Price</th>
                        <th>Value</th>
                    </tr>
                </thead>
                <tbody>
                    <tr id="sol-row">
                        <td>SOL</td>
                        <td id="sol-balance">Loading...</td>
                        <td id="sol-price">Loading...</td>
                        <td id="sol-value">Loading...</td>
                    </tr>
                    <tr id="v2ex-row">
                        <td>$V2EX</td>
                        <td id="v2ex-balance">Loading...</td>
                        <td id="v2ex-price">Loading...</td>
                        <td id="v2ex-value">Loading...</td>
                    </tr>
                    <tr id="total-row" class="total-row">
                        <td colspan="3">
                            <span>Address: </span>
                            <a id="copy-address-trigger" class="copy-address-link" href="#"></a>
                        </td>
                        <td id="total-value">Loading...</td>
                    </tr>
                </tbody>
            </table>
        `;
        anchorElement.parentNode.insertBefore(container, anchorElement.nextSibling);

        // 地址复制交互逻辑
        const copyTrigger = document.getElementById('copy-address-trigger');
        if (copyTrigger) {
            copyTrigger.textContent = userSolanaAddress; // 直接显示完整地址
            copyTrigger.title = '点击复制完整地址 (Click to copy full address)';

            copyTrigger.addEventListener('click', (event) => {
                event.preventDefault();
                navigator.clipboard.writeText(userSolanaAddress).then(() => {
                    copyTrigger.textContent = '已复制! (Copied!)';
                    setTimeout(() => {
                        copyTrigger.textContent = userSolanaAddress;
                    }, 2000);
                }).catch(err => {
                    console.error('V2EX Balance Checker: Could not copy address.', err);
                    copyTrigger.textContent = '复制失败';
                     setTimeout(() => {
                        copyTrigger.textContent = userSolanaAddress;
                    }, 2000);
                });
            });
        }

        // 5. 实时更新文本颜色以适配主题 (已修复)
        function updateTextColorsForTheme() {
            const nativeTextColor = getComputedStyle(document.body).getPropertyValue('--box-foreground-color').trim();
            const nativeFadeColor = getComputedStyle(document.body).getPropertyValue('--color-fade').trim();

            const cells = container.querySelectorAll('td');
            for (const cell of cells) {
                // 优先处理 Loading/Error 等状态的颜色
                if (cell.textContent.includes('Loading...') || cell.textContent.includes('Error') || cell.textContent.includes('N/A')) {
                    cell.style.setProperty('color', nativeFadeColor, 'important');
                } else {
                    // 为所有其他单元格应用标准文本颜色。CSS会确保里面的链接保持自己的颜色。
                    cell.style.setProperty('color', nativeTextColor, 'important');
                }
            }
            const headers = container.querySelectorAll('th');
            for (const header of headers) {
                header.style.setProperty('color', nativeTextColor, 'important');
            }
        }

        // 6. 监听主题变化
        const themeObserver = new MutationObserver(() => updateTextColorsForTheme());
        themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
        updateTextColorsForTheme();

        // 7. 数据获取与处理 (Data Fetching & Processing)
        function makeRpcRequest(requestPayload) {
            return new Promise((resolve, reject) => {
                let endpointIndex = 0;
                function tryNextEndpoint() {
                    if (endpointIndex >= RPC_ENDPOINTS.length) {
                        reject(new Error('All RPC endpoints failed for method: ' + requestPayload.method));
                        return;
                    }
                    const currentEndpoint = RPC_ENDPOINTS[endpointIndex++];
                    GM_xmlhttpRequest({
                        method: 'POST', url: currentEndpoint, headers: { 'Content-Type': 'application/json' },
                        data: JSON.stringify(requestPayload), timeout: 8000,
                        onload: function(response) {
                            try {
                                const data = JSON.parse(response.responseText);
                                if (data.error) { tryNextEndpoint(); } else { resolve(data); }
                            } catch (e) { tryNextEndpoint(); }
                        },
                        onerror: tryNextEndpoint, ontimeout: tryNextEndpoint
                    });
                }
                tryNextEndpoint();
            });
        }

        function fetchSolBalance() {
            return makeRpcRequest({ jsonrpc: '2.0', id: 1, method: 'getBalance', params: [userSolanaAddress] })
                .then(data => data.result.value / 1e9);
        }

        function fetchV2exTokenBalance() {
            return makeRpcRequest({ jsonrpc: '2.0', id: 1, method: 'getTokenAccountsByOwner', params: [userSolanaAddress, { mint: V2EX_TOKEN_MINT_ADDRESS }, { encoding: 'jsonParsed' }] })
                .then(data => data.result.value.length > 0 ? parseFloat(data.result.value[0].account.data.parsed.info.tokenAmount.uiAmountString) : 0);
        }

        function fetchPriceFromGeckoTerminal(tokenAddress) {
            return new Promise((resolve, reject) => {
                const url = `https://api.geckoterminal.com/api/v2/networks/solana/tokens/${tokenAddress}/pools?page=1`;
                GM_xmlhttpRequest({
                    method: 'GET', url: url, timeout: 8000,
                    onload: function(response) {
                        try {
                            const parsed = JSON.parse(response.responseText);
                            if (parsed.data && parsed.data.length > 0) {
                                const price = parseFloat(parsed.data[0].attributes.base_token_price_usd);
                                resolve(price);
                            } else { resolve(null); }
                        } catch (e) { reject(new Error(`Failed to parse GeckoTerminal response for ${tokenAddress}: ${e.message}`)); }
                    },
                    onerror: () => reject(new Error(`GeckoTerminal request failed for ${tokenAddress}`)),
                    ontimeout: () => reject(new Error(`GeckoTerminal request timed out for ${tokenAddress}`))
                });
            });
        }

        function fetchPrices() {
            return Promise.all([
                fetchPriceFromGeckoTerminal(SOL_MINT_ADDRESS),
                fetchPriceFromGeckoTerminal(V2EX_TOKEN_MINT_ADDRESS)
            ]).then(([solPrice, v2exPrice]) => ({ solPrice, v2exPrice }));
        }

        async function fetchAllDataAndUpdateUI() {
            const [solBalanceResult, v2exBalanceResult, pricesResult] = await Promise.allSettled([
                fetchSolBalance(),
                fetchV2exTokenBalance(),
                fetchPrices()
            ]);

            let solBalance, v2exBalance, solPrice, v2exPrice;
            let totalValue = 0;
            let canCalcTotal = true;

            if (solBalanceResult.status === 'fulfilled') {
                solBalance = solBalanceResult.value;
                document.getElementById('sol-balance').textContent = solBalance.toFixed(6);
            } else {
                canCalcTotal = false; document.getElementById('sol-balance').textContent = 'Error';
                console.error("Failed to fetch SOL balance:", solBalanceResult.reason);
            }

            if (v2exBalanceResult.status === 'fulfilled') {
                v2exBalance = v2exBalanceResult.value;
                document.getElementById('v2ex-balance').textContent = v2exBalance.toLocaleString();
            } else {
                canCalcTotal = false; document.getElementById('v2ex-balance').textContent = 'Error';
                console.error("Failed to fetch V2EX balance:", v2exBalanceResult.reason);
            }

            if (pricesResult.status === 'fulfilled') {
                solPrice = pricesResult.value.solPrice;
                v2exPrice = pricesResult.value.v2exPrice;
                document.getElementById('sol-price').textContent = solPrice ? `$${solPrice.toFixed(4)}` : 'N/A';
                document.getElementById('v2ex-price').textContent = v2exPrice ? `$${v2exPrice.toFixed(4)}` : 'N/A';
            } else {
                canCalcTotal = false; document.getElementById('sol-price').textContent = 'Error';
                document.getElementById('v2ex-price').textContent = 'Error';
                console.error("Failed to fetch prices:", pricesResult.reason);
            }

            if (typeof solBalance === 'number' && typeof solPrice === 'number') {
                const solValue = solBalance * solPrice;
                totalValue += solValue;
                document.getElementById('sol-value').textContent = `$${solValue.toFixed(2)}`;
            } else {
                canCalcTotal = false; document.getElementById('sol-value').textContent = 'Error';
            }

            if (typeof v2exBalance === 'number' && typeof v2exPrice === 'number') {
                const v2exValue = v2exBalance * v2exPrice;
                totalValue += v2exValue;
                document.getElementById('v2ex-value').textContent = `$${v2exValue.toFixed(2)}`;
            } else {
                canCalcTotal = false;
                document.getElementById('v2ex-value').textContent = (v2exPrice === null && typeof v2exBalance === 'number') ? '$0.00' : 'Error';
            }

            document.getElementById('total-value').textContent = canCalcTotal ? `$${totalValue.toFixed(2)}` : 'Error';
            updateTextColorsForTheme();
        }

        fetchAllDataAndUpdateUI();
    }

    // =================================================================================
    // 初始化函数 - 等待页面加载完成后执行 main()
    // =================================================================================
    const maxTries = 20;
    let tries = 0;
    const initInterval = setInterval(() => {
        const anchorElement = document.querySelector('#Main .box');
        if (anchorElement) {
            clearInterval(initInterval);
            main(anchorElement);
        } else {
            tries++;
            if (tries > maxTries) {
                clearInterval(initInterval);
                console.error('V2EX Balance Checker: Could not find anchor element #Main .box after 10s.');
            }
        }
    }, 500);

})();