关注列表导出1.6.5版本

[箭头修复+固定序号]三重发博时间获取+并发优化+反爬增强

// ==UserScript==
// @name          关注列表导出1.6.5版本
// @namespace    https://github.com/yourname
// @version      1.6.5
// @description  [箭头修复+固定序号]三重发博时间获取+并发优化+反爬增强
// @author       YourName
// @match        https://weibo.com/*
// @match        https://*.weibo.com/*
// @icon         https://weibo.com/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_notification
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      weibo.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.7/dayjs.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/dayjs/1.11.7/plugin/relativeTime.min.js
// ==/UserScript==

(function () {
    'use strict';
    dayjs.extend(dayjs_plugin_relativeTime);

    const CONFIG = {
        API_PATH: location.host.includes('weibo.com')
            ? '/ajax/friendships/friends'
            : '/api/ajax/friendships/friends',
        API_ENDPOINTS: {
            WEIBO_LIST: {
                PRIMARY: '/ajax/statuses/mymblog',
                BACKUP: [
                    '/ajax/statuses/usermblog',
                    '/ajax/profile/usermblog'
                ]
            },
            USER_PROFILE: {
                PRIMARY: '/ajax/profile/info',
                BACKUP: [
                    '/ajax/profile/detail',
                    '/ajax/profile/basic'
                ]
            }
        },
        REQUEST_HEADERS: {
            ACCEPT: [
                'application/json, text/plain, */*',
                'application/json, text/javascript, */*; q=0.01',
                'application/json, text/html, application/xml;q=0.9'
            ],
            ACCEPT_LANGUAGE: [
                'zh-CN,zh;q=0.9,en;q=0.8',
                'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3',
                'en-US,en;q=0.9,zh-CN;q=0.8'
            ],
            USER_AGENTS: [
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.159 Safari/537.36',
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.82 Safari/537.36',
                'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.2 Safari/605.1.15',
                'Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1'
            ],
            ACCEPT_ENCODING: [
                'gzip, deflate, br',
                'gzip, deflate',
                'br'
            ],
            CONNECTION: [
                'keep-alive',
                'close'
            ],
            SEC_FETCH_DEST: ['document', 'empty'],
            SEC_FETCH_MODE: ['navigate', 'cors', 'no-cors'],
            SEC_FETCH_SITE: ['same-origin', 'same-site', 'cross-site'],
            SEC_CH_UA_PLATFORM: ['"Windows"', '"macOS"', '"Linux"'],
            SEC_CH_UA_MOBILE: ['?0', '?1']
        },
        BUTTON_STYLE: `
            position: fixed;
            bottom: 100px;
            right: 30px;
            z-index: 9999;
            padding: 12px 16px;
            background: linear-gradient(145deg, #ff6b6b, #fda085);
            color: white;
            border-radius: 8px;
            cursor: pointer;
            box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
            border: none;
            font-size: 14px;
            transition: transform 0.3s ease-in-out;
            width: 160px;
            text-align: center;
            height: 42px;
            line-height: 18px;
        `,
        TEXTAREA_STYLE: `
            position: fixed;
            bottom: 160px;
            right: 30px;
            z-index: 9998;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 4px;
            width: 300px;
            height: 200px;
            resize: none;
            display: none;
            font-size: 14px;
            line-height: 1.5;
        `,
        SWITCH_BUTTON_STYLE: `
            position: fixed;
            bottom: 150px;
            right: 30px;
            z-index: 9999;
            padding: 12px 16px;
            background: linear-gradient(145deg, #3498db, #2980b9);
            color: white;
            border-radius: 8px;
            cursor: pointer;
            box-shadow: 0 4px 15px rgba(52, 152, 219, 0.4);
            border: none;
            font-size: 14px;
            transition: transform 0.3s ease-in-out;
            width: 160px;
            text-align: center;
            height: 42px;
            line-height: 18px;
        `,
        PROGRESS_STYLE: `
            position: fixed;
            bottom: 60px;
            right: 30px;
            z-index: 9999;
            padding: 8px 8px;
            background: rgba(0,0,0,0.7);
            color: white;
            border-radius: 8px;
            font-size: 12px;
            width: 140px;
            text-align: center;
            line-height: 1.2;
        `,
        DATE_FORMAT: 'YYYY.M.D',
        MIN_DELAY: 300,
        MAX_DELAY: 1000,
        MAX_CONCURRENT: 5,
        MAX_RETRY: 5,
        PAGE_SIZE: 20,
        EXPORT_FORMATS: {
            HTML: 'HTML',
            CSV: 'CSV'
        },
        CACHE_EXPIRY: 30 * 60 * 1000, // 缓存有效期30分钟
    };

    class WeiboFollowExporter {
        constructor() {
            this.userId = this.extractUserId();
            this.exportButton = null;
            this.progressText = null;
            this.isExporting = false;
            this.currentProgress = 0;
            this.totalFollows = 0;
            this.failedPages = [];
            this.controller = new AbortController();
            this.isManualMode = false;
            this.manualTextarea = null;
            this.switchButton = null;
            this.cache = {
                userInfo: new Map(),
                weiboData: new Map(),
                lastUpdate: new Map()
            };
            this.exportFormat = CONFIG.EXPORT_FORMATS.HTML;
            this.formatSelector = null;
            this.init();
            this.bindPageUnload();
        }

        extractUserId() {
            const followMatch = location.pathname.match(/\/u\/page\/follow\/(\d+)/);
            if (followMatch) return followMatch[1];
            const classicMatch = location.pathname.match(/\/(\d+)\/follow/);
            return classicMatch ? classicMatch[1] : 'unknown';
        }

        async init() {
            if (document.querySelector('#weiboExportBtn')) return;
            this.createSwitchButton();
            this.createManualTextarea();
            this.createExportButton();
            this.createProgressText();
            this.addPageListener();
        }

        createExportButton() {
            const container = document.createElement('div');
            container.style.cssText = `
                position: fixed;
                bottom: 100px;
                right: 30px;
                z-index: 9999;
                display: flex;
                flex-direction: column;
                gap: 10px;
                align-items: flex-end;
            `;

            // 创建格式选择器
            const formatContainer = document.createElement('div');
            formatContainer.style.cssText = `
                position: absolute;
                top: 100%;
                right: 0;
                margin-top: 5px;
                background: white;
                padding: 5px 8px;
                border-radius: 4px;
                box-shadow: 0 2px 10px rgba(0,0,0,0.1);
                display: flex;
                align-items: center;
                gap: 5px;
                opacity: 0;
                transform: translateY(-5px);
                transition: all 0.3s ease;
                z-index: 10000;
                font-size: 12px;
            `;

            const formatLabel = document.createElement('span');
            formatLabel.textContent = '格式';
            formatLabel.style.color = '#666';

            this.formatSelector = document.createElement('select');
            this.formatSelector.style.cssText = `
                padding: 3px 6px;
                border: 1px solid #ddd;
                border-radius: 3px;
                outline: none;
                cursor: pointer;
                font-size: 12px;
            `;

            Object.values(CONFIG.EXPORT_FORMATS).forEach(format => {
                const option = document.createElement('option');
                option.value = format;
                option.textContent = format;
                this.formatSelector.appendChild(option);
            });

            formatContainer.appendChild(formatLabel);
            formatContainer.appendChild(this.formatSelector);

            // 创建导出按钮
            this.exportButton = document.createElement('button');
            this.exportButton.id = 'weiboExportBtn';
            this.exportButton.textContent = '↓ 导出关注列表';
            this.exportButton.style = CONFIG.BUTTON_STYLE;
            this.exportButton.onclick = () => this.startExport();

            container.onmouseenter = () => {
                this.exportButton.style.transform = 'scale(1.05)';
                formatContainer.style.opacity = '1';
                formatContainer.style.transform = 'translateY(0)';
            };

            container.onmouseleave = () => {
                this.exportButton.style.transform = 'scale(1)';
                formatContainer.style.opacity = '0';
                formatContainer.style.transform = 'translateY(-5px)';
            };

            container.appendChild(formatContainer);
            container.appendChild(this.exportButton);
            document.body.appendChild(container);
        }

        createSwitchButton() {
            this.switchButton = document.createElement('button');
            this.switchButton.id = 'weiboSwitchBtn';
            this.switchButton.textContent = '手动输入模式';
            this.switchButton.style = CONFIG.SWITCH_BUTTON_STYLE;
            this.switchButton.onclick = () => {
                if (!location.pathname.includes('/follow')) {
                    // 在非关注页面,直接切换输入框显示状态
                    this.toggleTextarea();
                } else {
                    // 在关注页面,切换自动/手动模式
                    this.toggleMode();
                }
            };
            this.switchButton.onmouseover = () => this.switchButton.style.transform = 'scale(1.05)';
            this.switchButton.onmouseout = () => this.switchButton.style.transform = 'scale(1)';
            document.body.appendChild(this.switchButton);
        }

        createManualTextarea() {
            this.manualTextarea = document.createElement('textarea');
            this.manualTextarea.id = 'weiboIdTextarea';
            this.manualTextarea.placeholder = '每行输入一个微博用户链接,可附带【备注】信息\n例如:\nhttps://weibo.com/u/1234567【备注信息】';
            this.manualTextarea.style = CONFIG.TEXTAREA_STYLE;
            document.body.appendChild(this.manualTextarea);
        }

        toggleMode() {
            // 检查是否要切换到自动模式(当前是手动模式,且点击切换按钮)
            const willSwitchToAuto = this.isManualMode;

            // 在非关注页面时,如果要切换到自动模式,立即阻止并提示
            if (!location.pathname.includes('/follow') && willSwitchToAuto) {
                alert('只有在关注列表页面才能使用自动获取模式');
                return;
            }

            // 正常切换模式
            this.isManualMode = !this.isManualMode;

            // 在关注页面时
            if (location.pathname.includes('/follow')) {
                this.exportButton.style.display = 'block';
                this.exportButton.textContent = this.isManualMode ? '导出手动输入列表' : '↓ 导出关注列表';
                this.switchButton.textContent = this.isManualMode ? '手动输入模式' : '自动获取模式';

                if (this.isManualMode) {
                    // 切换到手动模式时,显示输入框
                    this.toggleTextarea();
                } else {
                    // 切换到自动模式时,隐藏输入框并清空内容
                    this.manualTextarea.style.display = 'none';
                    this.manualTextarea.value = '';
                }
            } else {
                // 在非关注页面时
                if (this.isManualMode) {
                    // 切换到手动模式时,显示导出按钮,但不显示输入框
                    this.exportButton.style.display = 'block';
                    this.exportButton.textContent = '导出手动输入列表';
                } else {
                    // 隐藏所有元素
                    this.exportButton.style.display = 'none';
                    this.manualTextarea.style.display = 'none';
                }
            }
        }

        toggleTextarea() {
            const isVisible = this.manualTextarea.style.display === 'block';
            this.manualTextarea.style.display = isVisible ? 'none' : 'block';

            // 同步显示/隐藏导出按钮
            if (this.exportButton) {
                this.exportButton.style.display = isVisible ? 'none' : 'block';
                this.exportButton.textContent = '导出手动输入列表';
            }

            // 隐藏时清空内容
            if (isVisible) {
                this.manualTextarea.value = '';
            }
        }

        async startExport() {
            if (this.isExporting) return;

            if (this.isManualMode) {
                await this.startManualExport();
            } else {
                if (this.userId === 'unknown') {
                    alert('请先进入关注列表页面');
                    return;
                }
                await this.startAutoExport();
            }
        }

        async startManualExport() {
            if (!this.manualTextarea.value.trim()) {
                alert('请输入至少一个有效的微博用户链接');
                return;
            }

            const lines = this.manualTextarea.value.split('\n').filter(line => line.trim());
            const idsWithRemark = [];

            for (const line of lines) {
                const idMatch = line.match(/https:\/\/weibo\.com\/u\/(\d+)/);
                const remarkMatch = line.match(/【(.*?)】/);
                if (idMatch) {
                    const id = idMatch[1];
                    const remark = remarkMatch ? remarkMatch[1] : '';
                    idsWithRemark.push({ id, remark });
                }
            }

            if (idsWithRemark.length === 0) {
                alert('请输入至少一个有效的微博用户链接');
                return;
            }

            try {
                this.isExporting = true;
                // 隐藏手动输入相关元素
                if (this.manualTextarea) this.manualTextarea.style.display = 'none';
                if (this.switchButton) this.switchButton.style.display = 'none';
                this.updateButtonText('正在获取数据...');
                this.showProgressText();

                const total = idsWithRemark.length;
                let data = [];

                for (let i = 0; i < idsWithRemark.length; i++) {
                    const { id, remark } = idsWithRemark[i];
                    this.updateProgressText(`正在获取第 ${i + 1}/${total} 个用户信息...`);

                    try {
                        const [userInfo, weiboData] = await Promise.all([
                            this.fetchUserInfoWithRetry(id),
                            this.fetchWeiboDataWithRetry(id)
                        ]);

                        if (userInfo) {
                            const currentFollowers = parseInt(userInfo.followers_count) || 0;
                            const followersChange = await this.getFollowersChange(id, currentFollowers);
                            const backupPostTime = userInfo.status?.created_at;
                            const lastPost = this.parseLastPost(weiboData) || backupPostTime;

                            data.push({
                                order: i + 1,
                                name: userInfo.screen_name,
                                id: id,
                                url: `https://weibo.com/u/${id}`,
                                followers_count: this.formatFollowers(currentFollowers),
                                followers_raw: currentFollowers,
                                avatar: userInfo.avatar_hd || userInfo.avatar_large || userInfo.profile_image_url,
                                remark: remark,
                                last_post: lastPost,
                                change: followersChange.text,
                                change_raw: followersChange.value
                            });
                        }
                    } catch (error) {
                        console.error(`获取用户 ${id} 信息失败:`, error);
                    }

                    await this.randomDelay();
                }

                if (data.length > 0) {
                    await this.exportFile(data);
                    GM_notification({
                        title: '导出完成',
                        text: `成功导出 ${data.length} 条数据`,
                        timeout: 5000
                    });
                } else {
                    throw new Error('没有成功获取任何用户数据,请检查输入的链接是否正确');
                }
            } catch (error) {
                alert(`导出失败:${error.message}`);
            } finally {
                this.isExporting = false;
                this.updateButtonText(this.isManualMode ? '导出手动输入列表' : '↓ 导出关注列表');
                this.hideProgressText();
                // 导出完成后重新显示手动输入相关元素
                if (this.manualTextarea) this.manualTextarea.style.display = this.isManualMode ? 'block' : 'none';
                if (this.switchButton) this.switchButton.style.display = 'block';
                this.controller = new AbortController();
            }
        }

        // 检查缓存是否有效
        isCacheValid(key, type) {
            const lastUpdate = this.cache.lastUpdate.get(`${type}_${key}`);
            return lastUpdate && (Date.now() - lastUpdate) < CONFIG.CACHE_EXPIRY;
        }

        // 从缓存获取数据
        getCachedData(key, type) {
            if (this.isCacheValid(key, type)) {
                return this.cache[type].get(key);
            }
            return null;
        }

        // 设置缓存数据
        setCacheData(key, type, data) {
            this.cache[type].set(key, data);
            this.cache.lastUpdate.set(`${type}_${key}`, Date.now());
        }

        async fetchUserInfoWithRetry(id, retryCount = 0) {
            // 先检查缓存
            const cachedData = this.getCachedData(id, 'userInfo');
            if (cachedData) {
                return cachedData;
            }

            try {
                const response = await this.fetchUserProfile(id);
                if (response?.data?.user) {
                    // 设置缓存
                    this.setCacheData(id, 'userInfo', response.data.user);
                    return response.data.user;
                }
                throw new Error('获取用户信息失败');
            } catch (error) {
                if (retryCount < CONFIG.MAX_RETRY) {
                    await new Promise(resolve => setTimeout(resolve, 2000 * (retryCount + 1)));
                    return this.fetchUserInfoWithRetry(id, retryCount + 1);
                }
                throw error;
            }
        }

        async fetchWeiboDataWithRetry(id, retryCount = 0) {
            // 先检查缓存
            const cachedData = this.getCachedData(id, 'weiboData');
            if (cachedData) {
                return cachedData;
            }

            try {
                const response = await this.fetchWeiboData(id);
                if (response?.data?.list) {
                    // 设置缓存
                    this.setCacheData(id, 'weiboData', response);
                    return response;
                }
                throw new Error('获取微博数据失败');
            } catch (error) {
                if (retryCount < CONFIG.MAX_RETRY) {
                    await new Promise(resolve => setTimeout(resolve, 2000 * (retryCount + 1)));
                    return this.fetchWeiboDataWithRetry(id, retryCount + 1);
                }
                return null;
            }
        }

        parseLastPost(weiboData) {
            if (!weiboData?.data?.list) return null;
            const validPosts = weiboData.data.list.filter(post => !post.isTop);
            return validPosts[0]?.created_at || null;
        }

        async getFollowersChange(uid, currentCount) {
            const key = `followers_${uid}`;
            const prevData = GM_getValue(key, null);

            // 如果没有历史数据,保存当前数据并返回"无"
            if (!prevData) {
                GM_setValue(key, {
                    count: currentCount,
                    timestamp: Date.now()
                });
                return {
                    value: 0,
                    text: '无',
                    color: '#999999'
                };
            }

            // 有历史数据,计算变化值
            const change = currentCount - prevData.count;

            // 更新数据
            GM_setValue(key, {
                count: currentCount,
                timestamp: Date.now()
            });

            return {
                value: change,
                text: change === 0 ? '-' : (change > 0 ? `+${change}` : `${change}`),
                color: change > 0 ? '#2ecc71' : change < 0 ? '#e74c3c' : '#999999'
            };
        }

        async startAutoExport() {
            try {
                this.isExporting = true;
                // 隐藏手动输入相关元素
                if (this.manualTextarea) this.manualTextarea.style.display = 'none';
                if (this.switchButton) this.switchButton.style.display = 'none';

                this.failedPages = [];
                this.updateButtonText('正在获取总数...');
                this.totalFollows = await this.getTotalFollows();

                // 修改预估时间计算逻辑
                const totalPages = Math.ceil(this.totalFollows / CONFIG.PAGE_SIZE);
                const avgTimePerRequest = 3; // 每个请求平均3秒
                const concurrentRequests = CONFIG.MAX_CONCURRENT;
                const estimatedTime = Math.ceil((totalPages * avgTimePerRequest) / concurrentRequests);

                if (!confirm(`准备导出 ${this.totalFollows} 个关注,预计需要 ${estimatedTime} 秒(约${Math.ceil(estimatedTime/60)}分钟),继续吗?`)) {
                    this.isExporting = false;
                    this.updateButtonText('↓ 导出关注列表');
                    return;
                }

                this.updateButtonText('开始导出...');
                this.showProgressText();
                this.updateProgressText('正在准备数据...');

                // 添加初始进度显示
                this.currentProgress = 0;
                this.updateProgressText(`导出: 0/${this.totalFollows} | 0%`);

                const data = await this.fetchAllData(this.totalFollows);
                await this.exportFile(data);

                if (this.failedPages.length > 0) {
                    alert(`导出完成,但有 ${this.failedPages.length} 页数据获取失败:${this.failedPages.join(',')}`);
                }

                GM_notification({
                    title: '导出完成',
                    text: `成功导出 ${data.length} 条数据`,
                    timeout: 5000
                });
            } catch (error) {
                alert(`导出失败:${error.message}`);
            } finally {
                this.isExporting = false;
                this.updateButtonText('↓ 导出关注列表');
                this.hideProgressText();
                // 导出完成后重新显示手动输入相关元素
                if (this.manualTextarea) this.manualTextarea.style.display = this.isManualMode ? 'block' : 'none';
                if (this.switchButton) this.switchButton.style.display = 'block';
                this.controller = new AbortController();
            }
        }

        async getTotalFollows() {
            const data = await this.fetchAPI(1);
            if (!data || !data.total_number) throw new Error('无法获取关注总数');
            return data.total_number;
        }

        async fetchAllData(total) {
            const totalPage = Math.ceil(total / CONFIG.PAGE_SIZE);
            let result = [];
            let currentPage = 1;
            let processedCount = 0;
            let lastProgressUpdate = 0;

            while (currentPage <= totalPage) {
                const pagesToFetch = Array.from(
                    { length: Math.min(CONFIG.MAX_CONCURRENT, totalPage - currentPage + 1) },
                    (_, i) => currentPage + i
                );

                this.updateProgressText(
                    `导出: ${processedCount}/${total} | ${Math.floor((processedCount/total)*100)}%`
                );

                const pageResults = await Promise.allSettled(
                    pagesToFetch.map(page =>
                        this.fetchWithRetry(page)
                            .then(async data => {
                                if (data?.users) {
                                    // 立即更新进度
                                    processedCount += data.users.length;
                                    this.currentProgress = processedCount;
                                    this.updateProgressText(
                                        `导出: ${processedCount}/${total} | ${Math.floor((processedCount/total)*100)}%`
                                    );
                                    return this.processPageConcurrent(data.users, page);
                                }
                                return [];
                            })
                    )
                );

                currentPage += pagesToFetch.length;

                for (const res of pageResults) {
                    if (res.status === 'fulfilled') {
                        result.push(...res.value);
                    }
                }

                // 减少延迟时间,让进度更新更快
                await new Promise(resolve => setTimeout(resolve, 200));
            }

            result.sort((a, b) => a.order - b.order);
            return result;
        }

        async fetchWithRetry(page, retryCount = 0) {
            try {
                await this.randomDelay();
                return await this.fetchAPI(page);
            } catch (error) {
                console.error(`第 ${page} 页尝试 ${retryCount + 1} 次失败:`, error);
                if (retryCount < CONFIG.MAX_RETRY) {
                    await new Promise(r => setTimeout(r, 2000 * (retryCount + 1)));
                    return this.fetchWithRetry(page, retryCount + 1);
                }
                this.failedPages.push(page);
                return { users: [] };
            }
        }

        async getLastPost(uid) {
            try {
                const data = await this.fetchWeiboData(uid);
                if (data?.data?.list) {
                    const validPosts = data.data.list.filter(post => !post.isTop);
                    return validPosts[0]?.created_at || null;
                }
                return null;
            } catch (error) {
                console.error('获取微博列表失败:', error);
                return null;
            }
        }

        async getLastPostFromProfile(uid) {
            try {
                const data = await this.fetchUserProfile(uid);
                return data?.status?.created_at || null;
            } catch (error) {
                console.error('获取用户信息失败:', error);
                return null;
            }
        }

        async getLastPostFromHTML(uid) {
            try {
                const html = await this.fetchUserHomepageHTML(uid);
                const parser = new DOMParser();
                const doc = parser.parseFromString(html, "text/html");
                const timeElement = doc.querySelector('.time[title]') ||
                                  doc.querySelector('div.WB_detail > div.WB_from > a[title]');
                if (timeElement) {
                    const rawTime = timeElement.getAttribute('title') || timeElement.textContent;
                    return this.normalizeTime(rawTime.trim());
                }
                return null;
            } catch (error) {
                console.error('HTML解析失败:', error);
                return null;
            }
        }

        fetchUserHomepageHTML(uid) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: "GET",
                    url: `https://weibo.com/u/${uid}`,
                    headers: this.getRandomHeaders(),
                    onload: (res) => res.status === 200 ? resolve(res.responseText) : reject(),
                    onerror: reject
                });
            });
        }

        normalizeTime(rawTime) {
            const mappings = {
                '今天': dayjs().format('YYYY-MM-DD'),
                '昨天': dayjs().subtract(1, 'day').format('YYYY-MM-DD')
            };
            Object.entries(mappings).forEach(([key, val]) => {
                rawTime = rawTime.replace(key, val);
            });
            return dayjs(rawTime).isValid() ? dayjs(rawTime).format('YYYY-MM-DD HH:mm:ss') : null;
        }

        randomDelay() {
            const delay = Math.random() * (CONFIG.MAX_DELAY - CONFIG.MIN_DELAY) + CONFIG.MIN_DELAY;
            return new Promise(r => setTimeout(r, delay));
        }

        async processPageConcurrent(users, page) {
            const processed = await Promise.all(
                users.map(async (user, index) => {
                    const result = await this.processUser(user, page, index);
                    return result;
                })
            );
            return processed.filter(item => item !== null);
        }

        async processUser(user, page, index) {
            await new Promise(r => setTimeout(r, Math.random() * 300));

            const currentFollowers = user.followers_count;
            const followersChange = await this.getFollowersChange(user.idstr, currentFollowers);

            const [lastPost1, lastPost2, lastPost3] = await Promise.allSettled([
                this.getLastPost(user.idstr),
                this.getLastPostFromProfile(user.idstr),
                this.getLastPostFromHTML(user.idstr)
            ]);

            const lastPost = [
                lastPost1.status === 'fulfilled' ? lastPost1.value : null,
                lastPost2.status === 'fulfilled' ? lastPost2.value : null,
                lastPost3.status === 'fulfilled' ? lastPost3.value : null
            ].find(t => t && dayjs(t).isValid());

            return {
                order: (page - 1) * CONFIG.PAGE_SIZE + index + 1,
                name: user.screen_name,
                remark: user.remark || '',
                url: `https://weibo.com/u/${user.idstr}`,
                id: user.idstr,
                followers_count: this.formatFollowers(currentFollowers),
                followers_raw: currentFollowers,
                change: followersChange.text,
                change_raw: followersChange.value,
                avatar: this.randomizeAvatar(user.avatar_hd),
                last_post: lastPost || null
            };
        }

        randomizeAvatar(url) {
            if (!url) return '';
            const timestamp = Date.now();
            return url.includes('?')
                ? `${url}&_rnd=${timestamp}`
                : `${url}?_rnd=${timestamp}`;
        }

        randomArrayElement(array) {
            return array[Math.floor(Math.random() * array.length)];
        }

        getRandomHeaders() {
            const headers = {
                'Referer': location.href,
                'User-Agent': this.randomArrayElement(CONFIG.REQUEST_HEADERS.USER_AGENTS),
                'Accept': this.randomArrayElement(CONFIG.REQUEST_HEADERS.ACCEPT),
                'Accept-Language': this.randomArrayElement(CONFIG.REQUEST_HEADERS.ACCEPT_LANGUAGE),
                'Accept-Encoding': this.randomArrayElement(CONFIG.REQUEST_HEADERS.ACCEPT_ENCODING),
                'Connection': this.randomArrayElement(CONFIG.REQUEST_HEADERS.CONNECTION),
                'X-Requested-With': 'XMLHttpRequest',
                'Cache-Control': 'no-cache',
                'Pragma': 'no-cache'
            };

            // 随机添加安全相关的请求头
            if (Math.random() > 0.3) {
                headers['Sec-Fetch-Dest'] = this.randomArrayElement(CONFIG.REQUEST_HEADERS.SEC_FETCH_DEST);
                headers['Sec-Fetch-Mode'] = this.randomArrayElement(CONFIG.REQUEST_HEADERS.SEC_FETCH_MODE);
                headers['Sec-Fetch-Site'] = this.randomArrayElement(CONFIG.REQUEST_HEADERS.SEC_FETCH_SITE);
                headers['Sec-Ch-Ua-Platform'] = this.randomArrayElement(CONFIG.REQUEST_HEADERS.SEC_CH_UA_PLATFORM);
                headers['Sec-Ch-Ua-Mobile'] = this.randomArrayElement(CONFIG.REQUEST_HEADERS.SEC_CH_UA_MOBILE);
            }

            return headers;
        }

        async fetchAPI(page) {
            const randomParam = `&_rnd=${Date.now()}`;
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://weibo.com${CONFIG.API_PATH}?page=${page}${randomParam}`,
                    headers: this.getRandomHeaders(),
                    signal: this.controller.signal,
                    onload: (res) => {
                        if (res.status === 200) {
                            try {
                                const data = JSON.parse(res.responseText);
                                data.ok === 1 ? resolve(data) : reject(new Error('API响应异常'));
                            } catch (e) {
                                reject(new Error('数据解析失败'));
                            }
                        } else {
                            reject(new Error(`HTTP ${res.status}`));
                        }
                    },
                    onerror: (err) => reject(err),
                    onabort: () => reject(new Error('用户中止请求'))
                });
            });
        }

        async fetchUserProfile(uid) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://weibo.com${CONFIG.API_ENDPOINTS.USER_PROFILE.PRIMARY}?uid=${uid}`,
                    headers: this.getRandomHeaders(),
                    onload: (res) => {
                        if (res.status === 200) {
                            try {
                                const response = JSON.parse(res.responseText);
                                resolve(response);
                            } catch (e) {
                                reject('用户信息解析失败');
                            }
                        } else {
                            reject(`HTTP ${res.status}`);
                        }
                    },
                    onerror: reject
                });
            });
        }

        async fetchWeiboData(uid) {
            return new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: `https://weibo.com${CONFIG.API_ENDPOINTS.WEIBO_LIST.PRIMARY}?uid=${uid}&page=1&feature=0`,
                    headers: this.getRandomHeaders(),
                    onload: (res) => {
                        if (res.status === 200) {
                            try {
                                const data = JSON.parse(res.responseText);
                                resolve(data);
                            } catch (e) {
                                reject('微博数据解析失败');
                            }
                        } else {
                            reject(`HTTP ${res.status}`);
                        }
                    },
                    onerror: reject
                });
            });
        }

        formatFollowers(num) {
            if (!num && num !== 0) return '0粉';
            return num > 10000
                ? `${(num / 10000).toFixed(1)}万粉`
                : `${num}粉`;
        }

        async exportFile(data) {
            this.exportFormat = this.formatSelector.value;
            const timestamp = dayjs().format(CONFIG.DATE_FORMAT);
            // 根据模式设置不同的文件名前缀
            const prefix = this.isManualMode ? '自填导出' : this.userId;
            const filename = `${prefix}-${timestamp}`;

            if (this.exportFormat === CONFIG.EXPORT_FORMATS.CSV) {
                await this.exportCSV(data, filename);
            } else {
                await this.exportHTML(data, filename);
            }
        }

        async exportCSV(data, filename) {
            const csvContent = data.map((user, index) => {
                const formattedFans = this.formatFollowers(user.followers_raw);
                const remarkDisplay = user.remark ? `【${user.remark}】` : '';
                const changeText = user.change === '-' ? '' : (user.change_raw > 0 ? `(+${user.change_raw})` : `(${user.change_raw})`);
                const url = `https://weibo.com/u/${user.id}`;
                return `(${user.order}) "${user.name}" ${remarkDisplay} ${url}===${formattedFans}${changeText}`;
            }).join('\n');

            const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8' });
            saveAs(blob, `${filename}.csv`);
        }

        async exportHTML(data, filename) {
            const htmlContent = `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>微博关注列表导出 - ${this.userId}</title>
    <style>
        * { font-family: "Microsoft YaHei", Arial, sans-serif; }
        .container { max-width: 1600px; margin: 20px auto; padding: 20px; }
        h1 { color: #2c3e50; text-align: center; margin-bottom: 20px; }
        .unit-switch {
            margin-left: 15px;
            display: inline-flex;
            align-items: center;
            gap: 5px;
        }
        .switch {
            position: relative;
            display: inline-block;
            width: 50px;
            height: 24px;
        }
        .switch input {
            opacity: 0;
            width: 0;
            height: 0;
        }
        .slider {
            position: absolute;
            cursor: pointer;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background-color: #ccc;
            transition: .4s;
            border-radius: 24px;
        }
        .slider:before {
            position: absolute;
            content: "";
            height: 16px;
            width: 16px;
            left: 4px;
            bottom: 4px;
            background-color: white;
            transition: .4s;
            border-radius: 50%;
        }
        input:checked + .slider {
            background-color: #2196F3;
        }
        input:checked + .slider:before {
            transform: translateX(26px);
        }
        .search-container {
            margin: 20px 0;
            display: flex;
            align-items: center;
            gap: 10px;
            background: #f8f9fa;
            padding: 15px;
            border-radius: 8px;
        }
        .search-box {
            flex: 1;
            padding: 12px 20px;
            border: 2px solid #3498db;
            border-radius: 25px;
            font-size: 16px;
            outline: none;
            transition: all 0.3s ease;
        }
        .search-box:focus {
            border-color: #2ecc71;
            box-shadow: 0 0 8px rgba(46, 204, 113, 0.3);
        }
        .info {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 8px;
            margin: 20px 0;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        .info-center {
            text-align: center;
            flex: 1;
        }
        .unit-switch {
            margin-right: 20px;
            display: inline-flex;
            align-items: center;
            gap: 5px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            box-shadow: 0 1px 3px rgba(0,0,0,0.12);
        }
        th {
            background: #3498db;
            color: white;
            padding: 12px;
            text-align: left;
            cursor: pointer;
            position: relative;
        }
        td {
            padding: 12px;
            border-bottom: 1px solid #ecf0f1;
        }
        tr:hover { background: #f5f6fa; }
        a {
            color: #3498db;
            text-decoration: none;
        }
        a:hover { text-decoration: underline; }
        .followers {
            color: #e67e22;
            font-weight: bold;
        }
        .remark { color: #27ae60; }
        .avatar {
            width: 40px;
            height: 40px;
            border-radius: 50%;
        }
        .change.positive { color: #2ecc71; }
        .change.negative { color: #e74c3c; }
        .change.neutral { color: #999999; }
        .change.none { color: #999999; font-style: italic; }
        .user-id {
            color: #95a5a6;
            font-size: 0.9em;
            margin-top: 2px;
        }
        .last-post { color: #7f8c8d; min-width: 120px; }
        th .arrow {
            display: inline-block;
            margin-left: 4px;
            font-weight: normal;
            opacity: 0.8;
            vertical-align: baseline;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>微博关注列表导出</h1>

        <div class="search-container">
            <input type="text"
                   class="search-box"
                   placeholder="搜索昵称/备注/ID..."
                   oninput="searchTable(this.value)">
            <div class="update-time">变动获取时间:${this.formatChangeTime(Date.now())}</div>
        </div>

        <div class="info">
            <div>用户ID:${this.userId}</div>
            <div class="info-center">导出时间:${dayjs().format('YYYY年MM月DD日 HH:mm')}</div>
            <div class="unit-switch">
                <span>单位转换/万</span>
                <label class="switch">
                    <input type="checkbox" id="unitToggle" onchange="toggleUnit()">
                    <span class="slider"></span>
                </label>
            </div>
            <div>总计:${data.length} 人</div>
        </div>
        <table id="followTable">
            <thead>
                <tr>
                    <th>头像</th>
                    <th>序号</th>
                    <th onclick="sortTable(2, 'string')">昵称 <span class="arrow" id="nameArrow"></span></th>
                    <th onclick="sortTable(3, 'remark')">备注 <span class="arrow" id="remarkArrow"></span></th>
                    <th>主页链接/ID</th>
                    <th onclick="sortTable(5, 'timestamp')">上次发博 <span class="arrow" id="timeArrow"></span></th>
                    <th onclick="sortTable(6, 'followers')">粉丝量 <span class="arrow" id="followersArrow"></span></th>
                    <th onclick="sortTable(7, 'change')">粉丝变动 <span class="arrow" id="changeArrow"></span></th>
                    <th onclick="sortTable(8, 'order')">关注顺序 <span class="arrow" id="orderArrow"></span></th>
                </tr>
            </thead>
            <tbody>
                ${data.map((item, index) => `
                    <tr>
                        <td><img class="avatar" src="${item.avatar}" alt=""></td>
                        <td>${index + 1}</td>
                        <td>${this.escapeHtml(item.name)}</td>
                        <td class="remark" data-has-remark="${item.remark ? '1' : '0'}">${item.remark ? this.escapeHtml(item.remark) : '-'}</td>
                        <td>
                            <a href="${item.url}" target="_blank">访问主页</a>
                            <div class="user-id">ID: ${this.escapeHtml(item.id)}</div>
                        </td>
                        <td class="last-post" data-timestamp="${item.last_post ? new Date(item.last_post).getTime() : 0}">
                            ${this.formatTime(item.last_post)}
                        </td>
                        <td class="followers" data-followers="${item.followers_raw}">${item.followers_raw.toString().replace(/\B(?=(\d{4})+(?!\d))/g, ',')}</td>
                        <td class="change ${item.change === '无' ? 'none' : item.change_raw > 0 ? 'positive' : item.change_raw < 0 ? 'negative' : 'neutral'}"
                            data-change="${item.change_raw}">
                            ${item.change}
                        </td>
                        <td data-order="${item.order}">${item.order}</td>
                    </tr>
                `).join('')}
            </tbody>
        </table>
    </div>
    <script>
        let currentSort = { column: null, type: 'number', direction: 1 };
        let showInWan = false;

        function formatNumber(num) {
            if (showInWan) {
                if (num >= 10000) {
                    return Math.floor(num / 10000) + '万';
                }
                return num;
            } else {
                let str = num.toString();
                let result = '';
                for (let i = str.length - 1; i >= 0; i--) {
                    if ((str.length - 1 - i) % 4 === 0 && i !== str.length - 1) {
                        result = ',' + result;
                    }
                    result = str[i] + result;
                }
                return result;
            }
        }

        function toggleUnit() {
            showInWan = document.getElementById('unitToggle').checked;
            const followers = document.querySelectorAll('.followers');
            followers.forEach(cell => {
                const num = parseInt(cell.dataset.followers);
                cell.textContent = formatNumber(num);
            });
        }

        function updateArrows(column) {
            document.querySelectorAll('.arrow').forEach(arrow => arrow.innerHTML = '');
            const arrowElement = document.getElementById(column + 'Arrow');
            if (arrowElement) {
                arrowElement.innerHTML = currentSort.direction === 1 ? '↑' : '↓';
            }
        }

        function getCompareValue(cell, dataType) {
            switch(dataType) {
                case 'timestamp':
                    return parseInt(cell.dataset.timestamp) || 0;
                case 'followers':
                    return parseInt(cell.dataset.followers) || 0;
                case 'change':
                    return parseInt(cell.dataset.change) || 0;
                case 'order':
                    return parseInt(cell.dataset.order) || 0;
                case 'remark':
                    const hasRemark = cell.dataset.hasRemark === '1';
                    const remarkText = cell.textContent.trim();
                    // 有备注的排在前面,相同情况下按备注文本排序
                    return hasRemark ? remarkText : 'zzz' + remarkText;
                case 'string':
                default:
                    return cell.textContent.trim().toLowerCase();
            }
        }

        function sortTable(columnIndex, dataType) {
            const table = document.getElementById('followTable');
            const tbody = table.tBodies[0];
            const rows = Array.from(tbody.rows);

            if (currentSort.column === columnIndex) {
                currentSort.direction *= -1;
            } else {
                currentSort.column = columnIndex;
                currentSort.direction = 1;
                currentSort.type = dataType;
            }

            rows.sort((a, b) => {
                const aVal = getCompareValue(a.cells[columnIndex], dataType);
                const bVal = getCompareValue(b.cells[columnIndex], dataType);
                return (aVal > bVal ? 1 : -1) * currentSort.direction;
            });

            tbody.innerHTML = '';
            rows.forEach((row, index) => {
                row.cells[1].textContent = index + 1;
                tbody.appendChild(row);
            });

            const columnNames = ['','','name','remark','','time','followers','change','order'];
            updateArrows(columnNames[columnIndex]);
        }

        function searchTable(query) {
            query = query.toLowerCase();
            const rows = document.querySelectorAll('#followTable tbody tr');
            rows.forEach(row => {
                const name = row.cells[2].textContent.toLowerCase();
                const remark = row.cells[3].textContent.toLowerCase();
                const id = row.cells[4].querySelector('.user-id').textContent.toLowerCase();
                row.style.display =
                    name.includes(query) ||
                    remark.includes(query) ||
                    id.includes(query) ? '' : 'none';
            });
        }
    </script>
</body>
</html>
            `;

            const blob = new Blob([htmlContent], { type: 'text/html;charset=utf-8' });
            saveAs(blob, `${filename}.html`);
        }

        formatTime(timeStr) {
            if (!timeStr) return '-';
            const date = dayjs(timeStr);
            return date.isValid() ? date.format('YYYY年M月D日HH:mm:ss') : '-';
        }

        escapeHtml(text) {
            const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
            return text.replace(/[&<>"']/g, m => map[m]);
        }

        createProgressText() {
            this.progressText = document.createElement('div');
            this.progressText.style = CONFIG.PROGRESS_STYLE;
            this.progressText.style.display = 'none';
            document.body.appendChild(this.progressText);
        }

        updateButtonText(text) {
            if (this.exportButton) {
                this.exportButton.textContent = text;
                this.exportButton.style.opacity = this.isExporting ? '0.7' : '1';
            }
        }

        showProgressText() {
            this.progressText && (this.progressText.style.display = 'block');
        }

        hideProgressText() {
            this.progressText && (this.progressText.style.display = 'none');
        }

        updateProgressText(text) {
            if (!this.progressText) return;
            this.progressText.textContent = text;
        }

        addPageListener() {
            let lastPath = location.pathname;
            setInterval(() => {
                if (location.pathname !== lastPath) {
                    lastPath = location.pathname;
                    this.userId = this.extractUserId();
                }
            }, 1000);
        }

        bindPageUnload() {
            window.addEventListener('beforeunload', () => {
                if (this.isExporting) {
                    this.controller.abort();
                    GM_notification({
                        title: '导出已中止',
                        text: '页面关闭导致导出中断',
                        timeout: 3000
                    });
                }
            });
        }

        formatChangeTime(timestamp) {
            if (!timestamp) return '-';
            const now = Date.now();
            const diff = now - timestamp;
            const hours = Math.floor(diff / (1000 * 60 * 60));

            if (hours < 24) {
                return `${hours}小时前`;
            } else {
                return dayjs(timestamp).format('YYYY年M月D日HH:mm');
            }
        }
    }

    // 在所有微博页面都初始化
    if (location.host.includes('weibo.com')) {
        const exporter = new WeiboFollowExporter();

        // 只在关注页面显示自动导出相关按钮
        if (!location.pathname.includes('/follow')) {
            if (exporter.exportButton) {
                exporter.exportButton.style.display = 'none';
            }
            if (exporter.progressText) {
                exporter.progressText.style.display = 'none';
            }
            // 默认为手动模式,但不显示输入框
            exporter.isManualMode = true;
            if (exporter.manualTextarea) {
                exporter.manualTextarea.style.display = 'none';
            }
            if (exporter.switchButton) {
                exporter.switchButton.textContent = '手动输入模式';
            }
        }
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址