iGPSPORT运动记录下载助手

iGPSPORT的运动记录fit文件的增加批量下载功能

// ==UserScript==
// @name         iGPSPORT运动记录下载助手
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  iGPSPORT的运动记录fit文件的增加批量下载功能
// @author       Yesaye
// @match        https://app.zh.igpsport.com/sport/history/list
// @grant        GM_addStyle
// @grant        unsafeWindow
// @license      MIT
// @icon         https://www.strava.com/icon-strava-chrome-144.png
// ==/UserScript==

(function() {
    'use strict';

    // 主题色变量
    const PRIMARY_COLOR = '#ff3c1f';
    const PRIMARY_LIGHT = '#ff6647';
    const SUCCESS_COLOR = '#4CAF50';
    const ERROR_COLOR = '#f44336';
    const INFO_COLOR = '#2196F3';

    let isDownloading = false;

    // 添加自定义样式
    GM_addStyle(`
        .script-download-btn {
            border-radius: 17px !important;
            height: 34px !important;
            padding: 0 15px !important;
            background-color: ${PRIMARY_COLOR} !important;
            color: white !important;
            border: none !important;
            display: inline-flex !important;
            align-items: center !important;
            justify-content: center !important;
            font-size: 14px !important;
            font-weight: 400 !important;
            margin-left: 10px !important;
            transition: all 0.3s ease !important;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
        }
        
        .script-download-btn:hover {
            background-color: ${PRIMARY_LIGHT} !important;
            box-shadow: 0 3px 6px rgba(0,0,0,0.15) !important;
            cursor: pointer !important;
        }
        
        .script-download-btn:active {
            transform: translateY(0) !important;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1) !important;
        }
        
        .script-download-btn.active {
            background-color: ${INFO_COLOR} !important;
        }
        
        .script-progress-bar {
            position: fixed;
            top: 0;
            left: 0;
            height: 3px;
            background-color: ${PRIMARY_COLOR};
            z-index: 10000;
            transition: width 0.3s ease;
        }
        
        .script-toast {
            position: fixed;
            top: 36px;
            left: 20px;
            background-color: rgba(0,0,0,0.7);
            color: white;
            padding: 10px 15px;
            border-radius: 4px;
            z-index: 10000;
            opacity: 0;
            transition: opacity 0.3s ease;
        }
        
        .script-toast.show {
            opacity: 1;
        }
        
        .script-log-container {
            position: fixed;
            top: 80px;
            left: 20px;
            width: 400px;
            max-height: 400px;
            background-color: rgba(255, 255, 255, 0.95);
            color: #333;
            border-radius: 4px;
            overflow: hidden;
            z-index: 9998;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
            display: none;
            border: 1px solid #eee;
            transform: translateX(-120%);
            transition: transform 0.3s ease-out;
        }
        
        .script-log-container.visible {
            transform: translateX(0);
        }
        
        .script-log-header {
            padding: 8px 12px;
            background-color: ${PRIMARY_COLOR};
            color: white;
            font-weight: bold;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        
        .script-log-close {
            cursor: pointer;
            font-size: 18px;
        }
        
        .script-log-content {
            padding: 10px;
            max-height: 350px;
            overflow-y: auto;
            font-family: 'Courier New', monospace;
            font-size: 13px;
            line-height: 1.5;
        }
        
        .script-log-item {
            margin-bottom: 5px;
            padding: 3px 5px;
            border-radius: 2px;
            transition: all 0.2s ease;
        }
        
        .script-log-item:hover {
            background-color: rgba(0,0,0,0.05);
        }
        
        .script-log-item.success {
            color: ${SUCCESS_COLOR};
        }
        
        .script-log-item.error {
            color: ${ERROR_COLOR};
        }
        
        .script-log-item.info {
            color: ${INFO_COLOR};
        }
        
        /* 新增当前页下载按钮样式 */
        .script-download-current-page-btn {
            border-radius: 17px !important;
            height: 34px !important;
            padding: 0 12px !important;
            background-color: ${PRIMARY_COLOR} !important;
            color: white !important;
            border: none !important;
            display: inline-flex !important;
            align-items: center !important;
            justify-content: center !important;
            font-size: 14px !important;
            font-weight: 400 !important;
            margin-left: 10px !important; /* 与全量按钮保持间距 */
            transition: all 0.3s ease !important;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1) !important;
        }
        
        .script-download-current-page-btn:hover {
            background-color: ${PRIMARY_LIGHT} !important;
            box-shadow: 0 3px 6px rgba(0,0,0,0.15) !important;
            cursor: pointer !important;
        }
        
        .script-download-current-page-btn.active {
            transform: translateY(0) !important;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1) !important;
            background-color: ${INFO_COLOR} !important;
        }
    `);

    // 日志容器
    let logContainer = null;
    let logContent = null;

    // 初始化日志容器
    function initLogContainer() {
        if (logContainer) return;
        
        logContainer = document.createElement('div');
        logContainer.className = 'script-log-container';
        
        const header = document.createElement('div');
        header.className = 'script-log-header';
        header.textContent = '下载日志';
        
        const closeBtn = document.createElement('div');
        closeBtn.className = 'script-log-close';
        closeBtn.textContent = '×';
        closeBtn.onclick = () => {
            logContainer.classList.remove('visible');
            setTimeout(() => {
                logContainer.style.display = 'none';
            }, 300);
        };
        
        header.appendChild(closeBtn);
        logContainer.appendChild(header);
        
        logContent = document.createElement('div');
        logContent.className = 'script-log-content';
        logContainer.appendChild(logContent);
        
        document.body.appendChild(logContainer);
    }

    // 添加日志
    function addLog(message, type = 'info') {
        initLogContainer();
        
        const logItem = document.createElement('div');
        logItem.className = `script-log-item ${type}`;
        logItem.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
        
        logContent.appendChild(logItem);
        logContent.scrollTop = logContent.scrollHeight;
        
        // 显示日志容器
        logContainer.style.display = 'block';
        setTimeout(() => {
            logContainer.classList.add('visible');
        }, 10);
    }

    // 显示提示消息
    function showToast(message, type = 'info', duration = 3000) {
        const toast = document.createElement('div');
        toast.className = 'script-toast';
        toast.textContent = message;
        
        // 设置类型样式
        if (type === 'error') {
            toast.style.backgroundColor = ERROR_COLOR;
        } else if (type === 'success') {
            toast.style.backgroundColor = SUCCESS_COLOR;
        } else if (type === 'primary') {
            toast.style.backgroundColor = PRIMARY_COLOR;
        } else if (type === 'info') { // 新增info类型背景色
            toast.style.backgroundColor = INFO_COLOR;
        }
        
        document.body.appendChild(toast);
        
        // 显示动画
        setTimeout(() => {
            toast.classList.add('show');
        }, 10);
        
        // 自动隐藏
        setTimeout(() => {
            toast.classList.remove('show');
            setTimeout(() => {
                document.body.removeChild(toast);
            }, 300);
        }, duration);
        
        // 添加到日志
        addLog(message, type);
    }

    // 获取进度条元素,不存在则创建
    function getProgressBar() {
        let progressBar = document.querySelector('.script-progress-bar');
        if (!progressBar) {
            progressBar = document.createElement('div');
            progressBar.className = 'script-progress-bar';
            progressBar.style.width = '0%';
            document.body.appendChild(progressBar);
        }
        return progressBar;
    }

    // 从localStorage获取authToken
    function getAuthTokenFromLocalStorage() {
        try {
            const persistState = localStorage.getItem('persist:redux-state');
            if (!persistState) {
                showToast('未找到用户认证信息,请先登录', 'error');
                throw new Error('认证信息缺失');
            }
            
            const parsedState = JSON.parse(persistState);
            const globalState = parsedState.global;
            
            if (!globalState) {
                showToast('用户状态异常,请重新登录', 'error');
                throw new Error('global状态缺失');
            }
            
            const globalData = JSON.parse(globalState);
            const token = globalData.token;
            
            if (!token) {
                showToast('认证Token无效,请重新登录', 'error');
                throw new Error('Token无效');
            }
            
            return token;
            
        } catch (error) {
            console.error('Token获取失败:', error);
            throw error;
        }
    }

    // 获取分页运动记录列表
    async function fetchActivityList(pageNo, authToken) {
        try {
            const response = await fetch(`https://prod.zh.igpsport.com/service/web-gateway/web-analyze/activity/queryMyActivity?pageNo=${pageNo}&pageSize=20&reqType=0&sort=1`, {
                method: 'GET',
                headers: {
                    'Accept': 'application/json, text/plain, */*',
                    'Authorization': `Bearer ${authToken}`,
                    'Origin': 'https://app.zh.igpsport.com',
                    'Referer': 'https://app.zh.igpsport.com/',
                    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
                    'timezone': 'Asia/Shanghai',
                    'qiwu-app-version': '1.0.0'
                }
            });
            
            if (!response.ok) {
                showToast(`分页接口请求失败:状态码 ${response.status}`, 'error');
                throw new Error(`HTTP ${response.status}`);
            }
            
            const data = await response.json();
            if (data.code !== 0) {
                showToast(`接口返回错误:${data.message}`, 'error');
                throw new Error(`业务错误:${data.message}`);
            }
            
            return data;
            
        } catch (error) {
            console.error('分页数据获取失败:', error);
            throw error;
        }
    }

    // 获取运动记录详情
    async function fetchActivityDetail(rideId, authToken) {
        try {
            const response = await fetch(`https://prod.zh.igpsport.com/service/web-gateway/web-analyze/activity/queryActivityDetail/${rideId}`, {
                method: 'GET',
                headers: {
                    'Accept': 'application/json, text/plain, */*',
                    'Authorization': `Bearer ${authToken}`,
                    'Origin': 'https://app.zh.igpsport.com',
                    'Referer': 'https://app.zh.igpsport.com/',
                    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
                    'timezone': 'Asia/Shanghai',
                    'qiwu-app-version': '1.0.0'
                }
            });
            
            if (!response.ok) {
                showToast(`详情接口请求失败:状态码 ${response.status}`, 'error');
                throw new Error(`HTTP ${response.status}`);
            }
            
            const data = await response.json();
            if (data.code !== 0) {
                showToast(`接口返回错误:${data.message}`, 'error');
                throw new Error(`业务错误:${data.message}`);
            }
            
            return data;
            
        } catch (error) {
            console.error('详情数据获取失败:', error);
            throw error;
        }
    }

    // 格式化日期为 ride-0-2025-03-25-07-35-57.fit 格式
    function formatDateForFileName(dateString) {
        try {
            // 尝试解析日期字符串
            const date = new Date(dateString);
            
            // 验证日期是否有效
            if (isNaN(date.getTime())) {
                // 如果无法解析,直接返回原始字符串(添加安全处理)
                return dateString.replace(/[:\s]/g, '-');
            }
            
            // 格式化为:YYYY-MM-DD-HH-MM-SS
            return [
                date.getFullYear(),
                String(date.getMonth() + 1).padStart(2, '0'),
                String(date.getDate()).padStart(2, '0'),
                String(date.getHours()).padStart(2, '0'),
                String(date.getMinutes()).padStart(2, '0'),
                String(date.getSeconds()).padStart(2, '0')
            ].join('-');
            
        } catch (error) {
            console.error('日期格式化失败:', error, '原始值:', dateString);
            // 发生错误时返回安全的文件名
            return `unknown-date-${Date.now()}`;
        }
    }

    // 下载fit文件
    async function downloadFitFile(fitUrl, startTime, rideId) {
        try {
            // 格式化为要求的文件名:ride-0-2025-03-25-07-35-57.fit
            const formattedDate = formatDateForFileName(startTime);
            const fileName = `ride-0-${formattedDate}.fit`;
            addLog(`准备下载: ${fileName}`, 'info');
            
            const response = await fetch(fitUrl);
            if (!response.ok) {
                throw new Error(`下载文件失败,状态码: ${response.status}`);
            }
            
            const blob = await response.blob();
            const url = URL.createObjectURL(blob);
            
            const a = document.createElement('a');
            a.href = url;
            a.download = fileName;
            document.body.appendChild(a);
            a.click();
            
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
            
            addLog(`✅ 下载完成: ${fileName}`, 'success');
            return { success: true, fileName };
            
        } catch (error) {
            const errorMsg = `❌ 下载失败 (ID:${rideId}): ${error.message}`;
            addLog(errorMsg, 'error');
            return { success: false, error: error.message };
        }
    }

    // 主下载逻辑 - 全量下载
    async function downloadAllActivities() {
        if (isDownloading) {
            showToast('正在下载中,请稍后...', 'info');
            return;
        }
        isDownloading = true;
        
        const downloadBtn = document.querySelector('.script-download-btn');
        if (!downloadBtn) return;
        
        // 按钮状态切换
        downloadBtn.classList.add('active');
        downloadBtn.disabled = true;
        downloadBtn.innerHTML = '<span class="ant-btn-icon"><i class="fa fa-spinner fa-spin"></i></span><span>正在下载...</span>';
        
        try {
            const progressBar = getProgressBar();
            progressBar.style.width = '0%';
        

            // 获取Token
            const authToken = getAuthTokenFromLocalStorage();
            if (!authToken) {
                throw new Error('认证Token获取失败');
            }
            
            // 获取总页数
            addLog('正在获取运动记录列表...', 'info');
            const firstPageData = await fetchActivityList(1, authToken);
            const totalPages = firstPageData.data.totalPage;
            const totalItems = firstPageData.data.totalRows;
            
            if (totalItems === 0) {
                showToast('没有可下载的运动记录数据', 'info');
                throw new Error('没有可下载的运动记录数据');
            }
            
            showToast(`开始下载 ${totalItems} 个运动记录文件`, 'primary', 5000);
            addLog(`发现 ${totalItems} 个运动记录文件(共${totalPages}页)`, 'info');
            
            let processedItems = 0;
            let failedItems = [];
            
            // 处理所有分页
            for (let page = 1; page <= totalPages; page++) {
                addLog(`📄 正在处理第 ${page}/${totalPages} 页...`, 'info');
                
                const pageData = await fetchActivityList(page, authToken);
                const items = pageData.data.rows;
                
                // 处理当前页的每个运动记录
                for (const item of items) {
                    try {
                        addLog(`🔍 获取运动记录 ${item.rideId} 详情...`, 'info');
                        const detail = await fetchActivityDetail(item.rideId, authToken);
                        const { fitUrl, startTime } = detail.data;
                        
                        if (!fitUrl || !startTime) {
                            throw new Error('缺少必要字段');
                        }
                        
                        const result = await downloadFitFile(fitUrl, startTime, item.rideId);
                        if (!result.success) {
                            throw new Error(result.error);
                        }
                        
                        processedItems++;
                        const progress = Math.round((processedItems / totalItems) * 100);
                        progressBar.style.width = `${progress}%`;
                        
                        // 下载间隔,避免请求过于频繁
                        await new Promise(resolve => setTimeout(resolve, 800));
                        
                    } catch (error) {
                        failedItems.push({
                            rideId: item.rideId,
                            error: error.message
                        });
                    }
                }
            }
            
            // 显示最终结果
            if (failedItems.length > 0) {
                showToast(`下载完成,成功 ${processedItems - failedItems.length} 个,失败 ${failedItems.length} 个`, 'info', 8000);
                addLog(`⚠️ 下载完成,成功 ${processedItems - failedItems.length} 个,失败 ${failedItems.length} 个`, 'info');
                
                if (failedItems.length > 0) {
                    addLog('失败项目列表:', 'error');
                    failedItems.forEach((item, index) => {
                        addLog(`  [${index+1}] ID:${item.rideId} - ${item.error}`, 'error');
                    });
                }
            } else {
                showToast(`全部 ${processedItems} 个运动记录文件下载成功!`, 'success', 8000);
                addLog(`🎉 全部 ${processedItems} 个运动记录文件下载成功!`, 'success');
            }
            
        } catch (error) {
            showToast(`操作失败: ${error.message}`, 'error', 8000);
            addLog(`💥 操作失败: ${error.message}`, 'error');
            console.error('下载过程中发生错误:', error);
        } finally {
            // 重置按钮状态
            const downloadBtn = document.querySelector('.script-download-btn');
            if (downloadBtn) {
                downloadBtn.classList.remove('active');
                downloadBtn.disabled = false;
                downloadBtn.innerHTML = '<span class="ant-btn-icon"><i class="fa fa-download"></i></span><span>下载全部</span>';
            }
            
            // 重置进度条
            const progressBar = getProgressBar();
            setTimeout(() => {
                progressBar.style.width = '0%';
            }, 3000);
            isDownloading = false; // 重置下载状态
        }
    }

    // 新增:获取当前页码函数
    function getCurrentPageNumber() {
        const activePageElement = document.getElementsByClassName('ant-pagination-item-active')[0];
        if (!activePageElement) {
            showToast('当前页信息获取失败,请检查分页组件', 'error');
            return null;
        }
        const pageNumber = activePageElement.getAttribute('title') || activePageElement.textContent;
        return parseInt(pageNumber, 10);
    }

    // 新增:下载当前页运动记录主函数
    async function downloadCurrentPageActivities() {
        if (isDownloading) {
            showToast('正在下载中,请稍后...', 'info');
            return;
        }
        isDownloading = true;

        const currentPage = getCurrentPageNumber();
        if (!currentPage) return;

        const downloadBtn = document.querySelector(`.script-download-current-page-btn`);
        if (!downloadBtn) return;

        // 按钮状态切换
        downloadBtn.classList.add('active');
        downloadBtn.disabled = true;
        downloadBtn.innerHTML = '<span class="ant-btn-icon"><i class="fa fa-spinner fa-spin"></i></span><span>下载中...</span>';

        try {
            const progressBar = getProgressBar();
            progressBar.style.width = '0%';

            // 清空当前日志(可选)
            // if (logContent) logContent.innerHTML = '';

            const authToken = getAuthTokenFromLocalStorage();
            if (!authToken) throw new Error('认证Token获取失败');

            // 获取当前页数据
            addLog(`正在获取第 ${currentPage} 页运动记录列表...`, 'info');
            const pageData = await fetchActivityList(currentPage, authToken);
            const items = pageData.data.rows;
            const totalItems = items.length;

            if (totalItems === 0) {
                showToast(`第 ${currentPage} 页没有运动记录数据`, 'info');
                throw new Error('无运动记录数据');
            }

            showToast(`开始下载当前页 ${totalItems} 个运动记录文件`, 'primary', 5000); // 使用info类型提示
            addLog(`当前页发现 ${totalItems} 个运动记录文件`, 'info');

            let processedItems = 0;
            let failedItems = [];

            // 处理当前页所有运动记录
            for (const item of items) {
                try {
                    addLog(`🔍 获取运动记录 ${item.rideId} 详情...`, 'info');
                    const detail = await fetchActivityDetail(item.rideId, authToken);
                    const { fitUrl, startTime } = detail.data;

                    if (!fitUrl || !startTime) throw new Error('缺少必要字段');

                    const result = await downloadFitFile(fitUrl, startTime, item.rideId);
                    if (!result.success) throw new Error(result.error);

                    processedItems++;
                    const progress = Math.round((processedItems / totalItems) * 100);
                    progressBar.style.width = `${progress}%`;

                    // 控制请求间隔
                    await new Promise(resolve => setTimeout(resolve, 800));

                } catch (error) {
                    failedItems.push({
                        rideId: item.rideId,
                        error: error.message
                    });
                }
            }

            // 显示结果
            if (failedItems.length > 0) {
                showToast(`当前页下载完成,成功 ${processedItems - failedItems.length} 个,失败 ${failedItems.length} 个`, 'info', 8000);
                addLog(`⚠️ 第 ${currentPage} 页下载结果:成功 ${processedItems - failedItems.length} 个,失败 ${failedItems.length} 个`, 'info');
                failedItems.forEach((item, index) => {
                    addLog(`  [${index+1}] ID:${item.rideId} - ${item.error}`, 'error');
                });
            } else {
                showToast(`第 ${currentPage} 页 ${processedItems} 个运动记录文件全部下载成功!`, 'success', 8000);
                addLog(`🎉 第 ${currentPage} 页下载完成:全部成功`, 'success');
            }

        } catch (error) {
            showToast(`当前页下载失败: ${error.message}`, 'error', 8000);
            addLog(`💥 第 ${currentPage} 页下载失败: ${error.message}`, 'error');
            console.error('当前页下载错误:', error);
        } finally {
            // 重置按钮状态
            const downloadBtn = document.querySelector(`.script-download-current-page-btn`);
            if (downloadBtn) {
                downloadBtn.classList.remove('active');
                downloadBtn.disabled = false;
                downloadBtn.innerHTML = '<span class="ant-btn-icon"><i class="fa fa-download"></i></span><span>下载当前页</span>';
            }

            // 重置进度条
            const progressBar = getProgressBar();
            setTimeout(() => progressBar.style.width = '0%', 3000);
            isDownloading = false; // 重置下载状态
        }
    }

    // 修改:按钮添加函数,同时创建当前页下载按钮
    function addDownloadButton() {
        const importButton = document.querySelector('.global-tabbar button.ant-btn-primary');
        if (!importButton) {
            setTimeout(addDownloadButton, 500);
            return;
        }

        // 添加原有全量下载按钮
        if (!document.querySelector('.script-download-btn')) {
            const fullButton = document.createElement('button');
            fullButton.className = 'script-download-btn ant-btn ant-btn-primary ant-btn-color-primary ant-btn-variant-solid';
            fullButton.innerHTML = '<span class="ant-btn-icon"><i class="fa fa-download"></i></span><span>下载全部</span>';
            fullButton.onclick = downloadAllActivities;
            importButton.parentNode.insertBefore(fullButton, importButton.nextSibling);
        }

        // 添加当前页下载按钮
        if (!document.querySelector(`.script-download-current-page-btn`)) {
            const currentButton = document.createElement('button');
            currentButton.className = `script-download-current-page-btn ant-btn ant-btn-info ant-btn-variant-solid`;
            currentButton.innerHTML = '<span class="ant-btn-icon"><i class="fa fa-download"></i></span><span>下载当前页</span>';
            currentButton.onclick = downloadCurrentPageActivities;
            // 插入到全量按钮之后
            const fullButton = document.querySelector('.script-download-btn');
            fullButton.parentNode.insertBefore(currentButton, fullButton.nextSibling || importButton.nextSibling);
        }
    }

    // 保持原有页面监听逻辑
    window.addEventListener('load', () => {
        addDownloadButton();
        new MutationObserver(() => addDownloadButton()).observe(document.body, { childList: true, subtree: true });
    });

})();