GMGN交易者数据导出

监听GMGN.ai交易者数据并提供Excel导出功能

// ==UserScript==
// @name         GMGN交易者数据导出
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  监听GMGN.ai交易者数据并提供Excel导出功能
// @author       You
// @match        https://gmgn.ai/sol/token/*
// @match        https://gmgn.ai/eth/token/*
// @match        https://gmgn.ai/bsc/token/*
// @match        https://gmgn.ai/base/token/*
// @match        https://gmgn.ai/arb/token/*
// @match        https://gmgn.ai/op/token/*
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    let tradersData = [];
    let currentCA = '';
    let currentChain = '';

    // 直接在最外层拦截 XMLHttpRequest
    const originalXHR = window.XMLHttpRequest;
    window.XMLHttpRequest = function() {
        const xhr = new originalXHR();
        const originalOpen = xhr.open;
        xhr.open = function(method, url) {
            if (isTargetTraderApi(url)) {
                console.log('[GMGN交易者] 拦截请求:', url);
                const originalOnload = xhr.onload;
                xhr.onload = function() {
                    if (xhr.readyState === 4 && xhr.status === 200) {
                        try {
                            const data = JSON.parse(xhr.responseText);
                            if (data.code === 0 && data.data?.list) {
                                processTraderData(data.data.list);
                            }
                        } catch (e) {
                            console.warn('[GMGN交易者] 处理失败:', e);
                        }
                    }
                    originalOnload?.apply(this, arguments);
                };
            }
            return originalOpen.apply(this, arguments);
        };
        return xhr;
    };

    function isTargetTraderApi(url) {
        if (typeof url !== 'string') return false;
        return url.includes('/vas/api/v1/token_traders/');
    }

    // 处理交易者数据的函数
    function processTraderData(newData) {
        const existingAddresses = new Set(tradersData.map(trader => trader.address));

        // 添加新的交易者数据
        newData.forEach(trader => {
            if (trader.address && !existingAddresses.has(trader.address)) {
                tradersData.push(trader);
            } else if (trader.address && existingAddresses.has(trader.address)) {
                // 更新现有交易者数据
                const existingIndex = tradersData.findIndex(t => t.address === trader.address);
                if (existingIndex !== -1) {
                    tradersData[existingIndex] = trader;
                }
            }
        });

        console.log(`GMGN本次获取 ${newData.length} 条数据,总计 ${tradersData.length} 条交易者数据`);
        updateDownloadButton();
    }

    // 从URL中提取CA地址和链网络
    function extractCAFromURL() {
        const url = window.location.pathname;
        const match = url.match(/\/(\w+)\/token\/(?:\w+_)?([A-Za-z0-9]+)$/);
        if (match) {
            const chain = match[1];
            const ca = match[2];
            return { chain, ca };
        }
        return null;
    }

    // 清空数据并更新当前监听目标
    function resetData() {
        tradersData = [];
        const urlInfo = extractCAFromURL();
        if (urlInfo) {
            currentCA = urlInfo.ca;
            currentChain = urlInfo.chain;
            console.log(`开始监听新的CA: ${currentChain}/${currentCA}`);
        }
    }

    // 格式化金额为$xxxK/M/B格式
    function formatCurrency(value) {
        if (!value || value === 0) return '$0';

        const absValue = Math.abs(value);
        let formattedValue;
        let suffix;

        if (absValue >= 1000000000) {
            formattedValue = (value / 1000000000).toFixed(1);
            suffix = 'B';
        } else if (absValue >= 1000000) {
            formattedValue = (value / 1000000).toFixed(1);
            suffix = 'M';
        } else if (absValue >= 1000) {
            formattedValue = (value / 1000).toFixed(1);
            suffix = 'K';
        } else {
            formattedValue = value.toFixed(2);
            suffix = '';
        }

        // 移除不必要的.0
        if (formattedValue.endsWith('.0')) {
            formattedValue = formattedValue.slice(0, -2);
        }

        return `$${formattedValue}${suffix}`;
    }

    // 格式化时间戳
    function formatTimestamp(timestamp) {
        if (!timestamp) return '-';
        const date = new Date(timestamp * 1000);
        return date.toLocaleDateString('zh-CN');
    }

    // 计算持仓时间(小时)
    function calculateHoldingTime(startTime, endTime) {
        if (!startTime || !endTime) return '-';
        const hours = Math.round((endTime - startTime) / 3600);
        return hours > 0 ? `${hours}小时` : '-';
    }

    // 导出Excel数据
    function exportToExcel() {
        if (tradersData.length === 0) {
            alert('没有获取到交易者数据,请切换到【交易者】tab页或重新刷新网页');
            return;
        }

        const headers = ['交易者地址', 'SOL余额', '总买入', '总卖出', '平均买价', '平均卖价', '总利润', '利润率', '持仓时间', '最后活跃'];

        let csvContent = "data:text/csv;charset=utf-8,\uFEFF" + headers.join(',') + '\n';

        // 按总利润降序排序
        const sortedTradersData = [...tradersData].sort((a, b) => (b.profit || 0) - (a.profit || 0));

        sortedTradersData.forEach(trader => {
            const row = [
                trader.address || '-',
                (parseFloat(trader.native_balance) / 1000000000).toFixed(2) || '0.00',
                formatCurrency(trader.buy_volume_cur || 0),
                formatCurrency(trader.sell_volume_cur || 0),
                trader.avg_cost?.toFixed(8) || '0',
                trader.avg_sold?.toFixed(8) || '0',
                formatCurrency(trader.profit || 0),
                ((trader.profit_change || 0) * 100).toFixed(2) + '%',
                calculateHoldingTime(trader.start_holding_at, trader.end_holding_at),
                formatTimestamp(trader.last_active_timestamp)
            ];
            csvContent += row.join(',') + '\n';
        });

        const encodedUri = encodeURI(csvContent);
        const link = document.createElement("a");
        link.setAttribute("href", encodedUri);
        link.setAttribute("download", `gmgn_traders_${currentChain}_${currentCA}_${new Date().getTime()}.csv`);
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);

        console.log(`成功导出 ${tradersData.length} 条交易者数据`);
    }

    // 创建下载按钮
    function createDownloadButton() {
        const button = document.createElement('div');
        button.className = 'h-[28px] flex items-center text-[12px] font-medium cursor-pointer bg-btn-secondary p-6px rounded-6px gap-2px text-text-200 hover:text-text-100';
        button.id = 'gmgn-export-button';
        button.innerHTML = `
            <svg width="12px" height="12px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
                <path d="M8.35343 15.677L13.881 7.19356C14.098 6.86024 14.01 6.40976 13.684 6.1877C13.5677 6.10833 13.431 6.066 13.2912 6.06606H8.94507V0.725344C8.94507 0.324837 8.62782 0 8.23639 0C7.99931 0 7.77814 0.121166 7.64658 0.322951L2.11903 8.80644C1.902 9.13976 1.99001 9.59 2.31579 9.8123C2.43216 9.89175 2.56893 9.93416 2.70883 9.93417H7.05494V15.2747C7.05494 15.6752 7.37219 16 7.76362 16C8.0007 16 8.2221 15.8788 8.35343 15.677Z"></path>
            </svg>
            导出交易者数据
        `;

        button.addEventListener('click', exportToExcel);
        return button;
    }

    // 更新下载按钮状态
    function updateDownloadButton() {
        const button = document.getElementById('gmgn-export-button');
        if (button) {
            if (tradersData.length > 0) {
                button.style.opacity = '1';
                button.style.pointerEvents = 'auto';
                button.title = `点击导出 ${tradersData.length} 条交易者数据`;
                console.log(`按钮状态已更新,数据量: ${tradersData.length}`);
            } else {
                button.style.opacity = '0.6';
                button.style.pointerEvents = 'none';
                button.title = '没有数据,请切换到交易者tab页';
            }
        } else {
            // 如果按钮还不存在,但有数据了,尝试创建按钮
            if (tradersData.length > 0) {
                console.log('数据已获取但按钮不存在,尝试插入按钮');
                setTimeout(() => {
                    insertDownloadButton();
                }, 500);
            }
        }
    }

    // 插入下载按钮到页面
    function insertDownloadButton() {
        const targetDiv = document.querySelector('.flex.absolute.top-0.right-0.gap-8px.pl-4px');
        const existingButton = document.getElementById('gmgn-export-button');

        if (targetDiv && !existingButton) {
            const downloadButton = createDownloadButton();
            targetDiv.insertBefore(downloadButton, targetDiv.firstChild);
            console.log('下载按钮已插入');
            // 插入后立即更新按钮状态
            updateDownloadButton();
        } else if (!targetDiv) {
            console.log('目标容器不存在,无法插入按钮');
        } else if (existingButton) {
            console.log('按钮已存在,更新状态');
            updateDownloadButton();
        }
    }

    // 监听页面变化
    function observePageChanges() {
        let lastUrl = location.href;
        let lastCA = currentCA;

        // 使用MutationObserver监听DOM变化
        const observer = new MutationObserver(function(mutations) {
            const currentUrl = location.href;
            const urlInfo = extractCAFromURL();
            const newCA = urlInfo ? urlInfo.ca : '';

            // URL变化或CA变化时重置数据
            if (currentUrl !== lastUrl || newCA !== lastCA) {
                lastUrl = currentUrl;
                lastCA = newCA;
                console.log(`页面变化检测 - URL: ${currentUrl}, CA: ${newCA}`);
                console.log('重置交易者数据');
                resetData();

                // 延迟插入按钮,等待页面加载
                setTimeout(() => {
                    insertDownloadButton();
                }, 2000);
            }

            // 检查是否需要重新插入按钮
            if (!document.getElementById('gmgn-export-button')) {
                setTimeout(() => {
                    insertDownloadButton();
                }, 1000);
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        // 同时监听浏览器历史变化(前进/后退按钮)
        window.addEventListener('popstate', function() {
            const urlInfo = extractCAFromURL();
            const newCA = urlInfo ? urlInfo.ca : '';
            if (newCA !== currentCA) {
                console.log(`浏览器历史变化 - 新CA: ${newCA}, 旧CA: ${currentCA}`);
                console.log('重置交易者数据');
                resetData();
                setTimeout(() => {
                    insertDownloadButton();
                }, 2000);
            }
        });
    }

    // 初始化函数 - 在document-start阶段执行
    function init() {
        console.log('GMGN交易者数据导出插件已在document-start阶段启动');

        // 立即设置当前页面的CA和链信息
        resetData();

        // 等待DOM完全加载后执行DOM相关操作
        function waitForDOM() {
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', () => {
                    setTimeout(() => {
                        insertDownloadButton();
                        observePageChanges();
                    }, 2000);
                });
            } else {
                setTimeout(() => {
                    insertDownloadButton();
                    observePageChanges();
                }, 2000);
            }
        }

        // 如果DOM已经存在,立即执行;否则等待
        if (document.documentElement) {
            waitForDOM();
        } else {
            // 极早阶段,连documentElement都不存在,使用更底层的监听
            const observer = new MutationObserver((mutations, obs) => {
                if (document.documentElement) {
                    obs.disconnect();
                    waitForDOM();
                }
            });
            observer.observe(document, { childList: true, subtree: true });
        }
    }

    // 在document-start阶段立即执行
    init();

})();