Greasy Fork is available in English.
获取页面上所有标记为FREE的torrent id并通过API获取下载链接
// ==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;">×</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;
})();