Greasy Fork

Greasy Fork is available in English.

M-Team 免费种子提取

获取页面上所有标记为FREE的torrent id并通过API获取下载链接

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         M-Team 免费种子提取
// @name:en      M-Team FREE Torrents Extractor
// @namespace    https://github.com/cyrahs
// @version      2.11
// @description  获取页面上所有标记为FREE的torrent id并通过API获取下载链接
// @description:en  Finds all FREE-marked torrents on the page and fetches download links via the API.
// @author       cyrah
// @license      MIT
// @homepageURL  https://github.com/cyrahs/mteam-js
// @supportURL   https://github.com/cyrahs/mteam-js
// @match        https://*.m-team.cc/*
// @match        https://m-team.cc/*
// @icon         https://kp.m-team.cc/favicon.ico
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
    'use strict';

    // 配置管理(使用 GM 存储)
    const CONFIG_KEYS = {
        apiEndpoint: 'mteam_api_endpoint',
        apiKey: 'mteam_api_key',
        openUrl: 'mteam_open_url',
        openUrlOnPartialSuccess: 'mteam_open_url_on_partial_success',
        minFreeHours: 'mteam_min_free_hours'
    };

    // 简单 i18n:根据浏览器语言在中文/英文间切换
    const I18N = {
        zh: {
            buttonGet: '获取FREE种子',
            buttonProcessing: '处理中...',
            buttonNotFound: '未找到',
            buttonNeedApi: '请配置API',
            buttonFailed: '处理失败',
            doneSuffix: '成功 已复制到剪贴板',
            alertConfigRequired: '请先配置 API Endpoint 和 API Key',
            alertApiEndpointRequired: 'API Endpoint不能为空',
            alertApiKeyRequired: 'API Key不能为空',
            alertMinFreeHoursInvalid: '过滤时长必须是大于0的数字',
            alertSaved: '设置已保存',
            settingsTitle: 'M-Team FREE 种子设置',
            settingsCloseTitle: '关闭',
            settingsButtonTitle: '打开设置',
            settingsButtonAria: '设置',
            apiEndpointLabel: 'API Endpoint:',
            apiEndpointHint: '例如: https://api.m-team.cc/api/torrent/genDlToken',
            apiKeyLabel: 'API Key (x-api-key):',
            apiKeyPlaceholder: '请输入API Key',
            openUrlLabel: '复制后自动打开网址 (可选):',
            openUrlPlaceholder: '例如: http://localhost:8080 或 qbittorrent://',
            openUrlHint: '留空则不自动打开新标签页',
            openUrlAlwaysLabel: '无论是否全部获取成功都自动打开网址',
            openUrlAlwaysHint: '未勾选时,仅当全部成功才自动打开网址',
            minFreeHoursLabel: '过滤时长(小时):',
            minFreeHoursHint: '仅保留剩余FREE时间大于等于该值的种子,例如 24 表示 1 天',
            cancelButton: '取消',
            saveButton: '保存'
        },
        en: {
            buttonGet: 'Get FREE Torrents',
            buttonProcessing: 'Processing...',
            buttonNotFound: 'Not Found',
            buttonNeedApi: 'Configure API',
            buttonFailed: 'Failed',
            doneSuffix: 'success, copied to clipboard',
            alertConfigRequired: 'Please configure API Endpoint and API Key first',
            alertApiEndpointRequired: 'API Endpoint is required',
            alertApiKeyRequired: 'API Key is required',
            alertMinFreeHoursInvalid: 'Filter duration must be a number greater than 0',
            alertSaved: 'Settings saved',
            settingsTitle: 'M-Team FREE Torrent Settings',
            settingsCloseTitle: 'Close',
            settingsButtonTitle: 'Open settings',
            settingsButtonAria: 'Settings',
            apiEndpointLabel: 'API Endpoint:',
            apiEndpointHint: 'Example: https://api.m-team.cc/api/torrent/genDlToken',
            apiKeyLabel: 'API Key (x-api-key):',
            apiKeyPlaceholder: 'Enter API Key',
            openUrlLabel: 'Open URL after copy (optional):',
            openUrlPlaceholder: 'e.g. http://localhost:8080 or qbittorrent://',
            openUrlHint: 'Leave empty to disable auto-open',
            openUrlAlwaysLabel: 'Open URL even if some links fail',
            openUrlAlwaysHint: 'When unchecked, URL opens only if all succeed',
            minFreeHoursLabel: 'Filter Duration (hours):',
            minFreeHoursHint: 'Keep only torrents with remaining FREE time >= this value, e.g. 24 means 1 day',
            cancelButton: 'Cancel',
            saveButton: 'Save'
        }
    };

    function getLang() {
        const lang = (navigator.language || '').toLowerCase();
        return lang.startsWith('zh') ? 'zh' : 'en';
    }

    const LANG = getLang();

    function t(key) {
        const dict = I18N[LANG] || I18N.en;
        return dict[key] || I18N.zh[key] || key;
    }

    function formatDoneMessage(success, total) {
        return `${success}/${total} ${t('doneSuffix')}`;
    }

    function sanitizeMinFreeHours(value, fallback = 24) {
        const parsed = Number(value);
        if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
        return parsed;
    }

    function getConfig() {
        const defaultConfig = {
            apiEndpoint: 'https://api.m-team.cc/api/torrent/genDlToken',
            apiKey: '',
            openUrl: '',
            openUrlOnPartialSuccess: false,
            minFreeHours: 24
        };
        try {
            const storedEndpoint = GM_getValue(CONFIG_KEYS.apiEndpoint, undefined);
            const storedApiKey = GM_getValue(CONFIG_KEYS.apiKey, undefined);
            const storedOpenUrl = GM_getValue(CONFIG_KEYS.openUrl, undefined);
            const storedOpenUrlOnPartialSuccess = GM_getValue(CONFIG_KEYS.openUrlOnPartialSuccess, undefined);
            const storedMinFreeHours = GM_getValue(CONFIG_KEYS.minFreeHours, undefined);

            const hasGMValues =
                storedEndpoint !== undefined ||
                storedApiKey !== undefined ||
                storedOpenUrl !== undefined ||
                storedOpenUrlOnPartialSuccess !== undefined ||
                storedMinFreeHours !== undefined;

            if (hasGMValues) {
                return {
                    ...defaultConfig,
                    apiEndpoint: storedEndpoint !== undefined ? storedEndpoint : defaultConfig.apiEndpoint,
                    apiKey: storedApiKey !== undefined ? storedApiKey : defaultConfig.apiKey,
                    openUrl: storedOpenUrl !== undefined ? storedOpenUrl : defaultConfig.openUrl,
                    openUrlOnPartialSuccess: storedOpenUrlOnPartialSuccess !== undefined
                        ? Boolean(storedOpenUrlOnPartialSuccess)
                        : defaultConfig.openUrlOnPartialSuccess,
                    minFreeHours: sanitizeMinFreeHours(
                        storedMinFreeHours !== undefined ? storedMinFreeHours : defaultConfig.minFreeHours,
                        defaultConfig.minFreeHours
                    )
                };
            }

        } catch (e) {
            console.error('读取配置失败:', e);
        }
        return defaultConfig;
    }

    function saveConfig(config) {
        try {
            GM_setValue(CONFIG_KEYS.apiEndpoint, config.apiEndpoint);
            GM_setValue(CONFIG_KEYS.apiKey, config.apiKey);
            GM_setValue(CONFIG_KEYS.openUrl, config.openUrl);
            GM_setValue(CONFIG_KEYS.openUrlOnPartialSuccess, Boolean(config.openUrlOnPartialSuccess));
            GM_setValue(CONFIG_KEYS.minFreeHours, sanitizeMinFreeHours(config.minFreeHours));
        } catch (e) {
            console.error('保存配置失败:', e);
        }
    }

    // 等待页面加载完成
    function waitForPageLoad() {
        return new Promise((resolve) => {
            if (document.readyState === 'complete') {
                resolve();
            } else {
                window.addEventListener('load', resolve);
            }
        });
    }

    // 查找包含"FREE"文本的元素
    function findFreeElements() {
        const allElements = document.querySelectorAll('*');
        const freeElements = [];
        const seenElements = new Set();

        allElements.forEach(element => {
            const text = element.textContent || '';
            // 查找包含"FREE"文本的元素(精确匹配单词,不区分大小写)
            const freeRegex = /\bFREE\b/i;
            if (freeRegex.test(text) && element.children.length === 0 && !seenElements.has(element)) {
                // 只选择叶子节点,避免重复
                freeElements.push(element);
                seenElements.add(element);
            }
        });

        return freeElements;
    }

    // 从元素中提取torrent id
    function extractTorrentId(element) {
        // 方法1: 查找data属性(当前元素和父元素)
        let current = element;
        for (let i = 0; i < 10; i++) {
            let torrentId = current.getAttribute('data-id') ||
                current.getAttribute('data-torrent-id') ||
                current.getAttribute('data-torrentid') ||
                current.getAttribute('data-tid');

            if (torrentId && /^\d+$/.test(torrentId)) {
                return torrentId;
            }

            if (!current.parentElement) break;
            current = current.parentElement;
        }

        // 方法2: 查找包含数字的id属性
        current = element;
        for (let i = 0; i < 10; i++) {
            const id = current.getAttribute('id');
            if (id) {
                // 查找纯数字id或包含torrent/detail的id
                const match = id.match(/^(\d+)$/) ||
                    id.match(/(?:torrent|detail)[_-]?(\d+)/i) ||
                    id.match(/(\d+)$/);
                if (match && match[1]) {
                    return match[1];
                }
            }
            if (!current.parentElement) break;
            current = current.parentElement;
        }

        // 方法3: 查找附近的链接(向上和向下查找)
        current = element;
        for (let i = 0; i < 10; i++) {
            // 在当前元素及其子元素中查找链接
            const links = current.querySelectorAll('a[href]');
            for (const link of links) {
                const href = link.getAttribute('href') || '';
                // 匹配各种常见的torrent id格式
                const match = href.match(/(?:torrent|detail)[\/=](\d+)/i) ||
                    href.match(/id[=:](\d+)/i) ||
                    href.match(/\/t\/(\d+)/i) ||
                    href.match(/\/details\.php\?id=(\d+)/i) ||
                    href.match(/\/torrents\.php\?id=(\d+)/i) ||
                    href.match(/\/\d+\/(\d+)/) ||
                    href.match(/\/(\d{4,})(?:\/|$|\?|#)/); // 4位以上的数字
                if (match && match[1]) {
                    return match[1];
                }
            }

            // 查找父元素中的链接
            const parentLink = current.closest('a[href]');
            if (parentLink) {
                const href = parentLink.getAttribute('href') || '';
                const match = href.match(/(?:torrent|detail)[\/=](\d+)/i) ||
                    href.match(/id[=:](\d+)/i) ||
                    href.match(/\/t\/(\d+)/i) ||
                    href.match(/\/(\d{4,})(?:\/|$|\?|#)/);
                if (match && match[1]) {
                    return match[1];
                }
            }

            if (!current.parentElement) break;
            current = current.parentElement;
        }

        // 方法4: 查找包含torrent相关class或id的容器
        const container = element.closest('[id*="torrent"], [class*="torrent"], [id*="detail"], [class*="item"], [class*="row"]');
        if (container) {
            // 在容器中查找所有链接
            const containerLinks = container.querySelectorAll('a[href]');
            for (const link of containerLinks) {
                const href = link.getAttribute('href') || '';
                const match = href.match(/\/(\d{4,})(?:\/|$|\?|#)/);
                if (match && match[1]) {
                    return match[1];
                }
            }
        }

        return null;
    }

    const HOUR_MS = 60 * 60 * 1000;

    function normalizeText(text) {
        return (text || '').replace(/\s+/g, ' ').trim();
    }

    function formatDuration(ms) {
        if (!Number.isFinite(ms) || ms < 0) return 'unknown';
        const totalSeconds = Math.floor(ms / 1000);
        const days = Math.floor(totalSeconds / 86400);
        const hours = Math.floor((totalSeconds % 86400) / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        return `${days}d ${hours}h ${minutes}m`;
    }

    function unitToMs(unit) {
        const raw = (unit || '').trim();
        const normalized = raw.toLowerCase();

        if (raw === '天' || raw === '日' || normalized === 'd' || normalized.startsWith('day')) {
            return 24 * 60 * 60 * 1000;
        }
        if (raw === '小时' || raw === '小時' || raw === '时' || raw === '時' || normalized === 'h' || normalized.startsWith('hour')) {
            return 60 * 60 * 1000;
        }
        if (raw === '分钟' || raw === '分鐘' || raw === '分' || normalized === 'm' || normalized.startsWith('min')) {
            return 60 * 1000;
        }
        if (raw === '秒' || normalized === 's' || normalized.startsWith('sec')) {
            return 1000;
        }
        return null;
    }

    function parseDurationTokensToMs(text, options = {}) {
        const input = normalizeText(text);
        if (!input) return null;

        const requireContext = options.requireContext !== false;
        const tokenRegex = /(\d+(?:\.\d+)?)\s*(天|日|d(?:ays?)?|小时|小時|时|時|h(?:ours?)?|分钟|分鐘|分|m(?:in(?:ute)?s?)?|秒|s(?:ec(?:ond)?s?)?)/gi;
        const contextRegex = /(?:free|免费|剩余|剩下|还剩|倒计时|remaining|left)/i;
        let totalMs = 0;
        let hasMatch = false;
        let match;

        while ((match = tokenRegex.exec(input)) !== null) {
            if (requireContext) {
                const contextStart = Math.max(0, match.index - 24);
                const contextEnd = Math.min(input.length, tokenRegex.lastIndex + 24);
                const contextText = input.slice(contextStart, contextEnd);
                if (!contextRegex.test(contextText)) {
                    continue;
                }
            }

            const value = parseFloat(match[1]);
            const unitMs = unitToMs(match[2]);
            if (!Number.isFinite(value) || !unitMs) continue;
            totalMs += value * unitMs;
            hasMatch = true;
        }

        return hasMatch ? Math.round(totalMs) : null;
    }

    // 专门处理类似: "FREE 4h 13min" / "FREE4h13min" / "免费 4小时 13分钟"
    function parseFreeBadgeToMs(text) {
        const input = normalizeText(text);
        if (!input) return null;

        const badgeRegex = /(?:free|免费)\s*([0-9a-zA-Z.::\u4e00-\u9fa5\s]{1,48})/gi;
        let match;

        while ((match = badgeRegex.exec(input)) !== null) {
            const badgeTail = normalizeText(match[1]);
            if (!badgeTail) continue;

            const durationMs = parseDurationTokensToMs(badgeTail, { requireContext: false });
            if (durationMs !== null) return durationMs;

            const clockMs = parseClockToMs(`free ${badgeTail}`);
            if (clockMs !== null) return clockMs;
        }

        return null;
    }

    function parseClockToMs(text) {
        const input = normalizeText(text);
        if (!input) return null;

        const contextRegex = /(?:free|免费|剩余|剩下|还剩|倒计时|remaining|left)/i;
        if (!contextRegex.test(input)) return null;

        const clockRegex = /(\d{1,3})\s*[::]\s*(\d{1,2})(?:\s*[::]\s*(\d{1,2})(?:\s*[::]\s*(\d{1,2}))?)?/g;
        let match;
        while ((match = clockRegex.exec(input)) !== null) {
            const values = match.slice(1).filter(v => v !== undefined).map(v => Number(v));
            if (values.some(v => !Number.isFinite(v))) continue;

            let days = 0;
            let hours = 0;
            let minutes = 0;
            let seconds = 0;

            if (values.length === 4) {
                [days, hours, minutes, seconds] = values;
            } else if (values.length === 3) {
                [hours, minutes, seconds] = values;
            } else if (values.length === 2) {
                [hours, minutes] = values;
            } else {
                continue;
            }

            return (((days * 24 + hours) * 60 + minutes) * 60 + seconds) * 1000;
        }

        return null;
    }

    function parseExpiryDateToMs(text) {
        const input = normalizeText(text);
        if (!input) return null;

        const keywordRegex = /(?:free\s*(?:until|to)|到期|截止|结束|結束|失效|expires?)/i;
        if (!keywordRegex.test(input)) return null;

        const dateMatch = input.match(/(\d{4}[./-]\d{1,2}[./-]\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)/);
        if (!dateMatch) return null;

        const normalizedDate = dateMatch[1].replace(/[./]/g, '-');
        const parts = normalizedDate.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
        if (!parts) return null;

        const year = Number(parts[1]);
        const month = Number(parts[2]) - 1;
        const day = Number(parts[3]);
        const hour = Number(parts[4] || 0);
        const minute = Number(parts[5] || 0);
        const second = Number(parts[6] || 0);

        const expiresAt = new Date(year, month, day, hour, minute, second).getTime();
        if (!Number.isFinite(expiresAt)) return null;

        const remainingMs = expiresAt - Date.now();
        return remainingMs > 0 ? remainingMs : null;
    }

    function getFreeContextWindows(text) {
        const input = normalizeText(text);
        if (!input) return [];

        const windows = [];
        const radius = 80;
        const freeRegex = /free|免费/gi;
        let match;
        while ((match = freeRegex.exec(input)) !== null) {
            const start = Math.max(0, match.index - 20);
            const end = Math.min(input.length, match.index + match[0].length + radius);
            windows.push(input.slice(start, end));
        }

        return windows.length > 0 ? windows : [input];
    }

    function parseRemainingMsFromText(text) {
        const input = normalizeText(text);
        if (!input) return null;

        const badgeMs = parseFreeBadgeToMs(input);
        if (badgeMs !== null) return badgeMs;

        const durationMs = parseDurationTokensToMs(input);
        if (durationMs !== null) return durationMs;

        const expiryMs = parseExpiryDateToMs(input);
        if (expiryMs !== null) return expiryMs;

        const clockMs = parseClockToMs(input);
        if (clockMs !== null) return clockMs;

        return null;
    }

    function collectRemainingTimeTexts(element, container) {
        const textSet = new Set();
        const attrNames = ['title', 'aria-label', 'data-title', 'data-original-title', 'data-tooltip', 'data-content'];
        const nodes = new Set();

        let current = element;
        for (let i = 0; i < 6 && current; i++) {
            nodes.add(current);
            current = current.parentElement;
        }

        if (container) {
            nodes.add(container);
            if (container.parentElement) {
                nodes.add(container.parentElement);
            }
        }

        nodes.forEach(node => {
            const nodeText = normalizeText(node.textContent);
            if (nodeText) textSet.add(nodeText);

            attrNames.forEach(attr => {
                const value = normalizeText(node.getAttribute(attr));
                if (value) textSet.add(value);
            });
        });

        if (container) {
            const tooltipNodes = container.querySelectorAll('[title], [aria-label], [data-title], [data-original-title], [data-tooltip], [data-content]');
            tooltipNodes.forEach(node => {
                attrNames.forEach(attr => {
                    const value = normalizeText(node.getAttribute(attr));
                    if (value) textSet.add(value);
                });
            });
        }

        return Array.from(textSet);
    }

    function getFreeRemainingMs(element, container) {
        const texts = collectRemainingTimeTexts(element, container);
        for (const text of texts) {
            const windows = getFreeContextWindows(text);
            for (const windowText of windows) {
                const remainingMs = parseRemainingMsFromText(windowText);
                if (remainingMs !== null) {
                    return remainingMs;
                }
            }
        }
        return null;
    }

    // 主函数:获取所有FREE torrent id
    function getAllFreeTorrentIds() {
        const freeElements = findFreeElements();
        const torrentIds = [];
        const seenIds = new Set();
        const seenElements = new Set();
        const filteredByRemainingTime = [];
        const unknownRemainingTime = [];
        const config = getConfig();
        const minFreeHours = sanitizeMinFreeHours(config.minFreeHours, 24);
        const minFreeRemainingMs = minFreeHours * HOUR_MS;

        freeElements.forEach(element => {
            // 避免处理同一个父容器中的多个FREE元素
            const container = element.closest('[class*="item"], [class*="row"], [class*="card"], tr, li');
            if (container && seenElements.has(container)) {
                return; // 跳过已处理的容器
            }
            if (container) {
                seenElements.add(container);
            }

            const torrentId = extractTorrentId(element);
            if (torrentId && !seenIds.has(torrentId)) {
                seenIds.add(torrentId);

                // 获取更多上下文信息
                const containerElement = container || element.parentElement;
                const titleElement = containerElement?.querySelector('a, [class*="title"], [class*="name"]');
                const title = titleElement?.textContent?.trim() || element.textContent.trim().substring(0, 100);
                const remainingMs = getFreeRemainingMs(element, containerElement);

                if (remainingMs !== null && remainingMs < minFreeRemainingMs) {
                    filteredByRemainingTime.push({
                        id: torrentId,
                        title: title,
                        remainingMs: remainingMs
                    });
                    return;
                }

                if (remainingMs === null) {
                    unknownRemainingTime.push({
                        id: torrentId,
                        title: title
                    });
                }

                torrentIds.push({
                    id: torrentId,
                    element: element,
                    container: containerElement,
                    title: title,
                    text: element.textContent.trim().substring(0, 100),
                    freeRemainingMs: remainingMs
                });
            }
        });

        if (filteredByRemainingTime.length > 0) {
            console.log(`[FREE过滤] 已过滤 ${filteredByRemainingTime.length} 个剩余时间 < ${minFreeHours}h 的种子`);
            filteredByRemainingTime.slice(0, 10).forEach(item => {
                console.log(`  - ${item.id} (${formatDuration(item.remainingMs)}): ${item.title}`);
            });
        }

        if (unknownRemainingTime.length > 0) {
            console.warn(`[FREE过滤] ${unknownRemainingTime.length} 个种子未识别到剩余FREE时间,已保留`);
        }

        return torrentIds;
    }

    // 调用API获取下载链接
    async function getDownloadLink(torrentId) {
        const config = getConfig();
        const apiUrl = config.apiEndpoint;
        const apiKey = config.apiKey;

        // 将 id 参数添加到 URL 查询字符串中
        const url = new URL(apiUrl);
        url.searchParams.set('id', torrentId);

        const headers = {};

        if (apiKey) {
            headers['x-api-key'] = apiKey;
        }

        try {
            const response = await fetch(url.toString(), {
                method: 'POST',
                headers: headers,
                credentials: 'include'
            });

            if (!response.ok) {
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }

            const data = await response.json();
            if (data.code === '0' && data.data) {
                return data.data;
            } else {
                throw new Error(data.message || 'API返回错误');
            }
        } catch (error) {
            throw new Error('API请求失败: ' + error.message);
        }
    }

    // 获取下载链接(失败重试)
    async function getDownloadLinkWithRetry(torrentId, maxRetries = 1) {
        let lastError;
        for (let attempt = 0; attempt <= maxRetries; attempt++) {
            try {
                if (attempt > 0) {
                    console.warn(`  重试第 ${attempt} 次...`);
                }
                return await getDownloadLink(torrentId);
            } catch (error) {
                lastError = error;
                if (attempt < maxRetries) {
                    await new Promise(resolve => setTimeout(resolve, 300));
                }
            }
        }
        throw lastError;
    }

    // 复制到剪贴板
    async function copyToClipboard(text) {
        try {
            await navigator.clipboard.writeText(text);
            return true;
        } catch (e) {
            // 降级方案
            const textarea = document.createElement('textarea');
            textarea.value = text;
            textarea.style.position = 'fixed';
            textarea.style.opacity = '0';
            document.body.appendChild(textarea);
            textarea.select();
            try {
                document.execCommand('copy');
                document.body.removeChild(textarea);
                return true;
            } catch (err) {
                document.body.removeChild(textarea);
                return false;
            }
        }
    }

    // 主函数:获取所有FREE torrent的下载链接
    async function main() {
        const button = document.getElementById('mteam-free-btn');
        if (button) {
            button.disabled = true;
            button.textContent = t('buttonProcessing');
        }

        try {
            // 等待一小段时间确保动态内容加载完成
            await new Promise(resolve => setTimeout(resolve, 500));

            const freeTorrents = getAllFreeTorrentIds();

            if (freeTorrents.length === 0) {
                if (button) {
                    button.disabled = false;
                    button.textContent = t('buttonNotFound');
                    setTimeout(() => {
                        if (button) {
                            button.textContent = t('buttonGet');
                        }
                    }, 2000);
                }
                return;
            }

            console.log('=== FREE Torrent IDs ===');
            console.log(`找到 ${freeTorrents.length} 个FREE标记的torrent`);

            const config = getConfig();
            if (!config.apiEndpoint || !config.apiKey) {
                if (button) {
                    button.disabled = false;
                    button.textContent = t('buttonNeedApi');
                    setTimeout(() => {
                        if (button) {
                            button.textContent = t('buttonGet');
                        }
                    }, 2000);
                }
                alert(t('alertConfigRequired'));
                showSettingsPanel();
                return;
            }

            // 更新按钮显示开始处理
            if (button) {
                button.textContent = `${t('buttonProcessing')} 0/${freeTorrents.length}`;
            }

            console.log('开始获取下载链接...');

            // 并发处理函数
            const CONCURRENT_LIMIT = 5; // 同时处理的请求数量
            const results = [];
            let completedCount = 0;
            const total = freeTorrents.length;

            // 处理单个种子的函数
            const processTorrent = async (torrent, index) => {
                console.log(`[${index}/${total}] 处理种子 ID: ${torrent.id}...`);

                try {
                    const downloadLink = await getDownloadLinkWithRetry(torrent.id, 1);
                    const result = {
                        id: torrent.id,
                        title: torrent.title,
                        downloadLink: downloadLink
                    };
                    console.log(`  ✓ 下载链接: ${downloadLink}`);
                    return result;
                } catch (error) {
                    console.error(`  ✗ 获取失败: ${error.message}`);
                    return {
                        id: torrent.id,
                        title: torrent.title,
                        downloadLink: null,
                        error: error.message
                    };
                } finally {
                    completedCount++;
                    // 更新按钮显示进度
                    if (button) {
                        button.textContent = `${t('buttonProcessing')} ${completedCount}/${total}`;
                    }
                }
            };

            // 并发处理所有种子
            const processBatch = async (batch) => {
                return Promise.all(batch.map((item, idx) => processTorrent(item.torrent, item.index)));
            };

            // 将种子分批处理
            for (let i = 0; i < freeTorrents.length; i += CONCURRENT_LIMIT) {
                const batch = freeTorrents.slice(i, i + CONCURRENT_LIMIT).map((torrent, idx) => ({
                    torrent: torrent,
                    index: i + idx + 1
                }));

                const batchResults = await processBatch(batch);
                results.push(...batchResults);

                // 批次之间添加小延迟,避免请求过快
                if (i + CONCURRENT_LIMIT < freeTorrents.length) {
                    await new Promise(resolve => setTimeout(resolve, 200));
                }
            }

            // 生成结果文本
            const successResults = results.filter(r => r.downloadLink);
            const resultText = successResults.map(r => r.downloadLink).join('\n');
            const resultTextWithInfo = results.map(r =>
                r.downloadLink
                    ? `${r.id}: ${r.downloadLink}`
                    : `${r.id}: 获取失败${r.error ? ' - ' + r.error : ''}`
            ).join('\n');

            // 复制到剪贴板
            const copied = await copyToClipboard(resultText);

            console.log('\n=== 下载链接结果 ===');
            console.log(`成功获取 ${successResults.length}/${results.length} 个种子的下载链接`);
            console.log('\n完整结果:');
            console.log(resultTextWithInfo);

            // 复制完成后按需打开指定网址(默认全部成功才打开,可在设置中改为总是打开)
            const allSuccess = successResults.length === results.length;
            const openUrlAlways = Boolean(config.openUrlOnPartialSuccess);
            if (copied && (allSuccess || openUrlAlways)) {
                const openUrl = (config.openUrl || '').trim();
                if (openUrl) {
                    try {
                        window.open(openUrl, '_blank', 'noopener');
                    } catch (e) {
                        console.warn('自动打开网址失败:', e);
                    }
                }
            } else if (copied && !allSuccess) {
                console.warn('存在获取失败的种子,已跳过自动打开网址');
            }

            // 保存到全局变量
            window.freeTorrentIds = freeTorrents.map(t => t.id);
            window.freeTorrents = freeTorrents;
            window.freeTorrentDownloadLinks = results;
            window.freeTorrentDownloadLinksArray = successResults.map(r => r.downloadLink);

            // 在按钮上显示成功信息
            if (button) {
                button.disabled = false;
                button.textContent = formatDoneMessage(successResults.length, results.length);
                // 3秒后恢复按钮文本
                setTimeout(() => {
                    if (button) {
                        button.textContent = t('buttonGet');
                    }
                }, 3000);
            }

        } catch (error) {
            console.error('处理失败:', error);
            if (button) {
                button.disabled = false;
                button.textContent = t('buttonFailed');
                // 3秒后恢复按钮文本
                setTimeout(() => {
                    if (button) {
                        button.textContent = t('buttonGet');
                    }
                }, 3000);
            }
        }
    }

    // 创建设置面板
    function createSettingsPanel() {
        const panel = document.createElement('div');
        panel.id = 'mteam-settings-panel';
        panel.style.cssText = `
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            border: 2px solid #1890ff;
            border-radius: 8px;
            padding: 20px;
            z-index: 10000;
            box-shadow: 0 4px 20px rgba(0,0,0,0.3);
            min-width: 400px;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        `;

        const config = getConfig();

        panel.innerHTML = `
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
                <h3 style="margin: 0; color: #1890ff;">${t('settingsTitle')}</h3>
                <button id="mteam-settings-close" title="${t('settingsCloseTitle')}" aria-label="${t('settingsCloseTitle')}" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666;">&times;</button>
            </div>
            <div style="margin-bottom: 15px;">
                <label style="display: block; margin-bottom: 5px; font-weight: bold;">${t('apiEndpointLabel')}</label>
                <input type="text" id="mteam-api-endpoint" value="${config.apiEndpoint}" 
                    style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">
                <small style="color: #666;">${t('apiEndpointHint')}</small>
            </div>
            <div style="margin-bottom: 15px;">
                <label style="display: block; margin-bottom: 5px; font-weight: bold;">${t('apiKeyLabel')}</label>
                <input type="text" id="mteam-api-key" value="${config.apiKey}" 
                    style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;"
                    placeholder="${t('apiKeyPlaceholder')}">
            </div>
            <div style="margin-bottom: 15px;">
                <label style="display: block; margin-bottom: 5px; font-weight: bold;">${t('openUrlLabel')}</label>
                <input type="text" id="mteam-open-url" value="${config.openUrl || ''}" 
                    style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;"
                    placeholder="${t('openUrlPlaceholder')}">
                <small style="color: #666;">${t('openUrlHint')}</small>
            </div>
            <div style="margin-bottom: 15px;">
                <label style="display: block; margin-bottom: 5px; font-weight: bold;">${t('minFreeHoursLabel')}</label>
                <input type="number" id="mteam-min-free-hours" value="${sanitizeMinFreeHours(config.minFreeHours, 24)}"
                    min="0.1" step="0.1"
                    style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">
                <small style="color: #666;">${t('minFreeHoursHint')}</small>
            </div>
            <div style="margin-bottom: 15px;">
                <label style="display: flex; align-items: center; gap: 8px; font-weight: bold; cursor: pointer;">
                    <input type="checkbox" id="mteam-open-url-on-partial-success" ${config.openUrlOnPartialSuccess ? 'checked' : ''}>
                    ${t('openUrlAlwaysLabel')}
                </label>
                <small style="color: #666;">${t('openUrlAlwaysHint')}</small>
            </div>
            <div style="display: flex; gap: 10px; justify-content: flex-end;">
                <button id="mteam-settings-cancel" style="padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; background: #f5f5f5;">${t('cancelButton')}</button>
                <button id="mteam-settings-save" style="padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; background: #1890ff; color: white;">${t('saveButton')}</button>
            </div>
        `;

        document.body.appendChild(panel);

        // 关闭按钮
        document.getElementById('mteam-settings-close').onclick = () => panel.remove();
        document.getElementById('mteam-settings-cancel').onclick = () => panel.remove();

        // 保存按钮
        document.getElementById('mteam-settings-save').onclick = () => {
            const apiEndpoint = document.getElementById('mteam-api-endpoint').value.trim();
            const apiKey = document.getElementById('mteam-api-key').value.trim();
            const openUrl = document.getElementById('mteam-open-url').value.trim();
            const minFreeHoursRaw = document.getElementById('mteam-min-free-hours').value.trim();
            const minFreeHours = Number(minFreeHoursRaw);
            const openUrlOnPartialSuccess = document.getElementById('mteam-open-url-on-partial-success').checked;

            if (!apiEndpoint) {
                alert(t('alertApiEndpointRequired'));
                return;
            }
            if (!apiKey) {
                alert(t('alertApiKeyRequired'));
                return;
            }
            if (!Number.isFinite(minFreeHours) || minFreeHours <= 0) {
                alert(t('alertMinFreeHoursInvalid'));
                return;
            }

            saveConfig({ apiEndpoint, apiKey, openUrl, minFreeHours, openUrlOnPartialSuccess });
            alert(t('alertSaved'));
            panel.remove();
        };

        // 点击背景关闭
        panel.addEventListener('click', (e) => {
            if (e.target === panel) {
                panel.remove();
            }
        });
    }

    // 显示设置面板
    function showSettingsPanel() {
        const existing = document.getElementById('mteam-settings-panel');
        if (existing) {
            existing.remove();
        }
        createSettingsPanel();
    }

    // 创建悬浮按钮
    function createFloatingButton() {
        const wrapper = document.createElement('div');
        wrapper.id = 'mteam-free-btn-wrap';
        wrapper.style.cssText = `
            position: fixed;
            bottom: 20px;
            right: 20px;
            display: inline-flex;
            align-items: center;
            background: #1890ff;
            border-radius: 25px;
            overflow: hidden;
            box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4);
            z-index: 9999;
            transition: transform 0.3s;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        `;

        const mainButton = document.createElement('button');
        mainButton.id = 'mteam-free-btn';
        mainButton.textContent = t('buttonGet');
        mainButton.style.cssText = `
            height: 44px;
            padding: 0 14px 0 20px;
            background: transparent;
            color: white;
            border: none;
            cursor: pointer;
            font-size: 14px;
            font-weight: bold;
            line-height: 1;
            display: inline-flex;
            align-items: center;
            transition: color 0.3s;
        `;

        const divider = document.createElement('span');
        divider.style.cssText = `
            width: 1px;
            background: rgba(255, 255, 255, 0.85);
            border-radius: 1px;
            align-self: stretch;
            margin: 6px 0;
            pointer-events: none;
        `;

        const settingsButton = document.createElement('button');
        settingsButton.id = 'mteam-settings-btn';
        settingsButton.innerHTML = `
            <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"
                style="width: 22px; height: 22px; display: block; fill: currentColor;">
                <path d="M19.14,12.94c0.04-0.31,0.06-0.63,0.06-0.94s-0.02-0.63-0.06-0.94l2.03-1.58
                    c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.11-0.2-0.35-0.28-0.57-0.22l-2.39,0.96
                    c-0.5-0.38-1.04-0.7-1.64-0.94L14.5,2.5c-0.02-0.22-0.2-0.39-0.42-0.39h-3.84
                    c-0.22,0-0.4,0.17-0.42,0.39L9.44,5.35C8.84,5.59,8.3,5.91,7.8,6.29L5.41,5.33
                    c-0.21-0.08-0.46,0.02-0.57,0.22L2.92,8.87C2.81,9.07,2.86,9.34,3.04,9.48l2.03,1.58
                    C5.03,11.37,5.01,11.69,5.01,12s0.02,0.63,0.06,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61
                    l1.92,3.32c0.11,0.2,0.35,0.28,0.57,0.22l2.39-0.96c0.5,0.38,1.04,0.7,1.64,0.94
                    l0.38,2.85c0.02,0.22,0.2,0.39,0.42,0.39h3.84c0.22,0,0.4-0.17,0.42-0.39l0.38-2.85
                    c0.6-0.24,1.14-0.56,1.64-0.94l2.39,0.96c0.21,0.08,0.46-0.02,0.57-0.22l1.92-3.32
                    c0.11-0.2,0.06-0.46-0.12-0.61L19.14,12.94z M12,15.6c-1.99,0-3.6-1.61-3.6-3.6
                    s1.61-3.6,3.6-3.6s3.6,1.61,3.6,3.6S13.99,15.6,12,15.6z"/>
            </svg>
        `;
        settingsButton.title = t('settingsButtonTitle');
        settingsButton.setAttribute('aria-label', t('settingsButtonAria'));
        settingsButton.style.cssText = `
            height: 44px;
            padding: 0 10px;
            min-width: 40px;
            background: transparent;
            color: white;
            border: none;
            cursor: pointer;
            font-size: 22px;
            font-weight: bold;
            line-height: 1;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            transition: color 0.3s;
        `;

        wrapper.onmouseenter = () => {
            wrapper.style.background = '#40a9ff';
            wrapper.style.transform = 'scale(1.05)';
        };
        wrapper.onmouseleave = () => {
            wrapper.style.background = '#1890ff';
            wrapper.style.transform = 'scale(1)';
        };

        mainButton.onclick = () => {
            main();
        };

        settingsButton.onclick = () => {
            showSettingsPanel();
        };

        wrapper.appendChild(mainButton);
        wrapper.appendChild(divider);
        wrapper.appendChild(settingsButton);
        document.body.appendChild(wrapper);
    }

    // 初始化
    async function init() {
        await waitForPageLoad();
        createFloatingButton();
    }

    // 运行初始化
    init();

    // 导出函数供控制台使用
    window.getAllFreeTorrentIds = getAllFreeTorrentIds;
    window.getMTeamConfig = getConfig;
    window.setMTeamConfig = saveConfig;
    window.showMTeamSettings = showSettingsPanel;
})();