Greasy Fork is available in English.
批量获取网站的链接,支持代理轮换、分批处理、按音轨/画质/地区/年份/类别多选/评分排序等筛选,多磁力折叠显示,支持导出磁力为TXT/JSON,支持增量获取与缓存
// ==UserScript==
// @name btl磁力批发Pro
// @namespace http://tampermonkey.net/
// @version 3.0.0
// @description 批量获取网站的链接,支持代理轮换、分批处理、按音轨/画质/地区/年份/类别多选/评分排序等筛选,多磁力折叠显示,支持导出磁力为TXT/JSON,支持增量获取与缓存
// @author 鸭肠yac
// @match *://*.mukaku.com/*
// @match *://*.butailing.com/*
// @match *://*.butai0.club/*
// @match *://*.butai0.xyz/*
// @match *://*.butai0.dev/*
// @match *://*.butai0.vip/*
// @match *://*.butai0.one/*
// @match *://*.0bt0.com/*
// @match *://*.1bt0.com/*
// @match *://*.2bt0.com/*
// @match *://*.3bt0.com/*
// @match *://*.4bt0.com/*
// @match *://*.5bt0.com/*
// @match *://*.6bt0.com/*
// @match *://*.7bt0.com/*
// @match *://*.8bt0.com/*
// @match *://*.9bt0.com/*
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ==================== 样式 ====================
GM_addStyle(`
#bt-magnet-panel {
position: fixed; top: 60px; right: 20px; z-index: 99999;
width: 420px; max-height: 80vh;
background: #1a1f2e; border: 1px solid #2d3548;
border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.5);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #e0e0e0; display: none; flex-direction: column;
overflow: hidden;
}
#bt-magnet-panel.open { display: flex; }
.bt-header {
padding: 14px 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; justify-content: space-between; align-items: center;
cursor: move; user-select: none;
}
.bt-header h3 { margin: 0; font-size: 15px; color: #fff; }
.bt-header .bt-close {
background: none; border: none; color: #fff; font-size: 20px;
cursor: pointer; padding: 0 4px; line-height: 1;
}
.bt-body { padding: 12px 16px; overflow-y: auto; flex: 1; }
.bt-row { margin-bottom: 10px; }
.bt-label { font-size: 12px; color: #8899aa; margin-bottom: 4px; display: block; }
.bt-input, .bt-select {
width: 100%; padding: 8px 10px; border-radius: 6px;
border: 1px solid #2d3548; background: #0d1117; color: #e0e0e0;
font-size: 13px; box-sizing: border-box; outline: none;
}
.bt-input:focus, .bt-select:focus { border-color: #667eea; }
.bt-row-inline { display: flex; gap: 8px; }
.bt-row-inline > * { flex: 1; }
.bt-btn {
padding: 10px 16px; border: none; border-radius: 8px;
font-size: 14px; cursor: pointer; font-weight: 600;
transition: all 0.2s;
}
.bt-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; width: 100%;
}
.bt-btn-primary:hover { opacity: 0.9; transform: translateY(-1px); }
.bt-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.bt-btn-sm {
padding: 4px 10px; font-size: 12px; border-radius: 4px;
background: #2d3548; color: #e0e0e0; border: 1px solid #3d4558;
}
.bt-btn-sm:hover { background: #3d4558; }
.bt-results { margin-top: 12px; }
.bt-result-item {
background: #0d1117; border: 1px solid #2d3548; border-radius: 8px;
padding: 10px 12px; margin-bottom: 8px;
}
.bt-result-title {
font-size: 13px; font-weight: 600; color: #667eea;
margin-bottom: 4px; word-break: break-all;
}
.bt-result-meta {
font-size: 11px; color: #8899aa; margin-bottom: 6px;
}
.bt-result-magnet {
font-size: 11px; color: #4caf50; word-break: break-all;
background: #0a0e14; padding: 6px 8px; border-radius: 4px;
margin-bottom: 6px; position: relative;
}
.bt-result-actions { display: flex; gap: 6px; }
.bt-status {
padding: 8px 12px; font-size: 12px; color: #8899aa;
border-top: 1px solid #2d3548; background: #0d1117;
}
.bt-progress { color: #667eea; }
.bt-toggle-btn {
position: fixed; top: 70px; right: 20px; z-index: 99998;
padding: 8px 14px; border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; border: none; font-size: 13px; font-weight: 600;
cursor: pointer; box-shadow: 0 4px 12px rgba(102,126,234,0.4);
transition: all 0.2s;
}
.bt-toggle-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(102,126,234,0.5); }
.bt-tag {
display: inline-block; padding: 2px 8px; border-radius: 4px;
font-size: 11px; margin: 2px 2px; cursor: pointer;
background: #1a2332; color: #8899aa; border: 1px solid #2d3548;
}
.bt-tag:hover, .bt-tag.active { background: #667eea; color: #fff; border-color: #667eea; }
.bt-audio-tags, .bt-genre-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.bt-copy-all {
position: sticky; top: 0; z-index: 1;
padding: 8px; text-align: center; background: #1a1f2e;
border-bottom: 1px solid #2d3548; margin-bottom: 8px;
display: flex; gap: 6px; justify-content: center; flex-wrap: wrap;
}
.bt-btn-export {
padding: 8px 16px; font-size: 13px; border-radius: 6px;
border: none; cursor: pointer; font-weight: 600;
transition: all 0.2s;
}
.bt-btn-export:hover { opacity: 0.85; transform: translateY(-1px); }
.bt-btn-export-txt {
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
color: #fff;
}
.bt-btn-export-json {
background: linear-gradient(135deg, #ff9800 0%, #e65100 100%);
color: #fff;
}
.bt-drawer {
overflow: hidden; max-height: 0;
transition: max-height 0.3s ease;
}
.bt-drawer.open { max-height: 2000px; }
.bt-toggle-more {
display: inline-flex; align-items: center; gap: 4px;
padding: 6px 14px; margin-top: 6px; border-radius: 6px;
background: #1a2332; color: #667eea; border: 1px solid #2d3548;
font-size: 12px; cursor: pointer; transition: all 0.2s;
}
.bt-toggle-more:hover { background: #243044; border-color: #667eea; }
.bt-toggle-more .bt-arrow {
display: inline-block; transition: transform 0.2s;
font-size: 10px;
}
.bt-toggle-more.open .bt-arrow { transform: rotate(90deg); }
.bt-rating-stars { color: #ffc107; font-size: 11px; }
`);
// ==================== 工具 ====================
const API_BASE = '/prod/api/v1';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const randSleep = (min, max) => sleep(min + Math.random() * (max - min));
// ==================== 代理管理 ====================
function getProxies() {
try { return JSON.parse(GM_getValue('bt_proxies', '[]')); } catch { return []; }
}
function saveProxies(list) { GM_setValue('bt_proxies', JSON.stringify(list)); }
let proxyIdx = 0;
function nextProxy() {
if (!GM_getValue('bt_proxy_enabled', false)) return null;
const list = getProxies();
if (list.length === 0) return null;
const p = list[proxyIdx % list.length];
proxyIdx++;
return p;
}
async function api(path, params = {}, retries = 2) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await apiRaw(path, params);
} catch (err) {
if (attempt < retries) {
const wait = 1000 * Math.pow(2, attempt - 1);
console.warn(`请求失败 (${attempt}/${retries}): ${err.message},${wait / 1000}秒后重试...`);
await sleep(wait);
} else {
throw err;
}
}
}
}
async function apiRaw(path, params = {}) {
const token = localStorage.getItem('token') || '';
const qs = new URLSearchParams({
app_id: '83768d9ad4',
identity: '23734adac0301bccdcb107c4aa21f96c',
...(token ? { access_token: token } : {}),
...params
});
const url = `${API_BASE}/${path}?${qs}`;
const proxy = nextProxy();
if (typeof GM_xmlhttpRequest !== 'undefined' && proxy) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('请求超时')), 15000);
GM_xmlhttpRequest({
method: 'GET',
url: location.origin + url,
headers: { 'Referer': location.href },
timeout: 15000,
onload(res) {
clearTimeout(timer);
try { resolve(JSON.parse(res.responseText)); }
catch (e) { reject(e); }
},
onerror(e) { clearTimeout(timer); reject(new Error('网络错误')); },
ontimeout() { clearTimeout(timer); reject(new Error('请求超时')); },
});
});
}
if (typeof GM_xmlhttpRequest !== 'undefined' && !proxy) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('请求超时')), 15000);
GM_xmlhttpRequest({
method: 'GET',
url: location.origin + url,
headers: { 'Referer': location.href },
timeout: 15000,
onload(res) {
clearTimeout(timer);
try { resolve(JSON.parse(res.responseText)); }
catch (e) { reject(e); }
},
onerror(e) { clearTimeout(timer); reject(new Error('网络错误')); },
ontimeout() { clearTimeout(timer); reject(new Error('请求超时')); },
});
});
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
try {
const res = await fetch(url, { signal: controller.signal });
return res.json();
} finally {
clearTimeout(timer);
}
}
function $(sel, ctx = document) { return ctx.querySelector(sel); }
function $$(sel, ctx = document) { return [...ctx.querySelectorAll(sel)]; }
// ==================== UI ====================
const toggleBtn = document.createElement('button');
toggleBtn.className = 'bt-toggle-btn';
toggleBtn.textContent = '🧲 批量磁力Pro';
document.body.appendChild(toggleBtn);
const panel = document.createElement('div');
panel.id = 'bt-magnet-panel';
panel.innerHTML = `
<div class="bt-header">
<h3>🧲 批量磁力获取 Pro</h3>
<button class="bt-close">×</button>
</div>
<div class="bt-body">
<div class="bt-row">
<span class="bt-label">搜索关键词 (留空则按筛选)</span>
<input class="bt-input" id="bt-keyword" placeholder="输入影视名称...">
</div>
<div class="bt-row">
<span class="bt-label">影视来源</span>
<div style="display:flex;gap:8px">
<span class="bt-tag active" data-type="movie" id="bt-type-movie" style="flex:1;text-align:center;padding:6px">🎬 电影</span>
<span class="bt-tag" data-type="tv" id="bt-type-tv" style="flex:1;text-align:center;padding:6px">📺 电视剧</span>
</div>
</div>
<div class="bt-row">
<span class="bt-label">制片地区</span>
<select class="bt-select" id="bt-area">
<option value="">不限</option>
<option value="大陆">大陆</option>
<option value="韩国">韩国</option>
<option value="日本">日本</option>
<option value="美国">美国</option>
<option value="欧美">欧美</option>
<option value="香港">香港</option>
<option value="台湾">台湾</option>
<option value="英国">英国</option>
<option value="泰国">泰国</option>
</select>
</div>
<div class="bt-row">
<span class="bt-label">影视类型 (可多选,不选则不限)</span>
<div class="bt-genre-tags" id="bt-genre-tags">
<span class="bt-tag" data-v="剧情">剧情</span>
<span class="bt-tag" data-v="喜剧">喜剧</span>
<span class="bt-tag" data-v="动作">动作</span>
<span class="bt-tag" data-v="爱情">爱情</span>
<span class="bt-tag" data-v="科幻">科幻</span>
<span class="bt-tag" data-v="悬疑">悬疑</span>
<span class="bt-tag" data-v="恐怖">恐怖</span>
<span class="bt-tag" data-v="动画">动画</span>
<span class="bt-tag" data-v="纪录片">纪录片</span>
<span class="bt-tag" data-v="综艺">综艺</span>
<span class="bt-tag" data-v="犯罪">犯罪</span>
<span class="bt-tag" data-v="奇幻">奇幻</span>
<span class="bt-tag" data-v="战争">战争</span>
<span class="bt-tag" data-v="冒险">冒险</span>
<span class="bt-tag" data-v="历史">历史</span>
</div>
<input class="bt-input" id="bt-genre-custom" placeholder="自定义类型,多个用逗号分隔" style="margin-top:6px">
</div>
<div class="bt-row">
<span class="bt-label">上映年份</span>
<select class="bt-select" id="bt-year">
<option value="">不限</option>
<option value="2026">2026</option>
<option value="2025">2025</option>
<option value="2024">2024</option>
<option value="2023">2023</option>
<option value="2022">2022</option>
<option value="2021">2021</option>
<option value="2020">2020</option>
<option value="2019">2019</option>
<option value="2018">2018</option>
<option value="2017">2017</option>
<option value="2016">2016</option>
<option value="2015">2015</option>
<option value="2010-2015">2010-2015</option>
<option value="2000-2010">2000-2010</option>
<option value="90s">90年代</option>
<option value="80s">80年代及更早</option>
</select>
</div>
<div class="bt-row bt-row-inline">
<div>
<span class="bt-label">资源画质</span>
<select class="bt-select" id="bt-quality">
<option value="">不限</option>
<option value="WEB-4K">WEB-4K</option>
<option value="4K蓝光">4K蓝光</option>
<option value="4K-Remux">4K-Remux</option>
<option value="1080P蓝光">1080P蓝光</option>
<option value="WEB-1080P">WEB-1080P</option>
<option value="1080P-Remux">1080P-Remux</option>
<option value="杜比视界">杜比视界</option>
</select>
</div>
<div>
<span class="bt-label">获取数量</span>
<input class="bt-input" id="bt-limit" type="number" value="5" min="1">
</div>
</div>
<div class="bt-row">
<span class="bt-label">排序方式</span>
<select class="bt-select" id="bt-sort">
<option value="default">默认排序</option>
<option value="rating_desc">⭐ 评分从高到低</option>
<option value="rating_asc">⭐ 评分从低到高</option>
<option value="year_desc">📅 年份从新到旧</option>
<option value="year_asc">📅 年份从旧到新</option>
</select>
</div>
<div class="bt-row">
<span class="bt-label">音轨过滤 (磁力名称需包含以下关键词)</span>
<div class="bt-audio-tags" id="bt-audio-tags">
<span class="bt-tag" data-v="国韩">国韩</span>
<span class="bt-tag" data-v="国日">国日</span>
<span class="bt-tag" data-v="国语">国语</span>
<span class="bt-tag" data-v="粤语">粤语</span>
<span class="bt-tag" data-v="多音轨">多音轨</span>
<span class="bt-tag" data-v="中文字幕">中字</span>
</div>
<input class="bt-input" id="bt-audio" placeholder="自定义关键词,多个用逗号分隔" style="margin-top:6px">
</div>
<div class="bt-row" id="bt-proxy-section">
<span class="bt-label">
<span class="bt-toggle-more" id="bt-proxy-toggle">
<span class="bt-arrow">▶</span> 🌐 代理设置
</span>
<label style="float:right;display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px">
<input type="checkbox" id="bt-proxy-enabled" style="cursor:pointer"> 启用
</label>
</span>
<div id="bt-proxy-body" style="display:none;margin-top:8px">
<textarea class="bt-input" id="bt-proxy-list" rows="3"
placeholder="每行一个代理,格式: http://IP:端口 socks5://IP:端口 http://用户:密码@IP:端口"
style="width:100%;resize:vertical;font-size:12px;min-height:60px"></textarea>
<div style="display:flex;gap:6px;margin-top:6px">
<button class="bt-btn" id="bt-proxy-save" style="flex:1;font-size:12px;padding:4px">💾 保存</button>
<button class="bt-btn" id="bt-proxy-clear" style="flex:1;font-size:12px;padding:4px;background:#555">🗑 清空</button>
<button class="bt-btn" id="bt-proxy-test" style="flex:1;font-size:12px;padding:4px;background:linear-gradient(135deg,#2196f3,#1565c0)">🔍 测试</button>
</div>
</div>
</div>
<div class="bt-row">
<button class="bt-btn bt-btn-primary" id="bt-fetch">🔍 开始获取</button>
<button class="bt-btn bt-btn-primary" id="bt-cancel" style="margin-top:8px;background:linear-gradient(135deg,#ef5350 0%,#c62828 100%);display:none">⏹ 取消获取</button>
</div>
<div class="bt-results" id="bt-results"></div>
</div>
<div class="bt-status" id="bt-status">就绪</div>
`;
document.body.appendChild(panel);
// ==================== 事件 ====================
let isDrag = false, dx, dy;
const header = $('.bt-header', panel);
header.addEventListener('mousedown', e => {
if (e.target.classList.contains('bt-close')) return;
isDrag = true;
const rect = panel.getBoundingClientRect();
dx = e.clientX - rect.left;
dy = e.clientY - rect.top;
panel.style.transition = 'none';
});
document.addEventListener('mousemove', e => {
if (!isDrag) return;
panel.style.left = (e.clientX - dx) + 'px';
panel.style.top = (e.clientY - dy) + 'px';
panel.style.right = 'auto';
});
document.addEventListener('mouseup', () => { isDrag = false; });
toggleBtn.addEventListener('click', () => panel.classList.toggle('open'));
$('.bt-close', panel).addEventListener('click', () => panel.classList.remove('open'));
// --- 音轨标签交互 ---
const audioInput = $('#bt-audio');
const audioTags = $('#bt-audio-tags');
let audioLock = false;
audioTags.addEventListener('click', e => {
const tag = e.target.closest('.bt-tag');
if (!tag || !audioTags.contains(tag)) return;
e.preventDefault();
e.stopPropagation();
audioLock = true;
tag.classList.toggle('active');
const selected = $$('.bt-tag', audioTags).filter(t => t.classList.contains('active')).map(t => t.dataset.v);
audioInput.value = selected.join(',');
audioLock = false;
});
audioInput.addEventListener('input', () => {
if (audioLock) return;
const vals = audioInput.value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
$$('.bt-tag', audioTags).forEach(t => {
t.classList.toggle('active', vals.includes(t.dataset.v));
});
});
// --- 类型多选标签交互 ---
const genreCustomInput = $('#bt-genre-custom');
const genreTags = $('#bt-genre-tags');
let genreLock = false;
genreTags.addEventListener('click', e => {
const tag = e.target.closest('.bt-tag');
if (!tag || !genreTags.contains(tag)) return;
e.preventDefault();
e.stopPropagation();
genreLock = true;
tag.classList.toggle('active');
const selected = $$('.bt-tag', genreTags).filter(t => t.classList.contains('active')).map(t => t.dataset.v);
genreCustomInput.value = selected.join(',');
genreLock = false;
});
genreCustomInput.addEventListener('input', () => {
if (genreLock) return;
const vals = genreCustomInput.value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
$$('.bt-tag', genreTags).forEach(t => {
t.classList.toggle('active', vals.includes(t.dataset.v));
});
});
// --- 影视来源切换 ---
const typeMovieBtn = $('#bt-type-movie');
const typeTvBtn = $('#bt-type-tv');
[typeMovieBtn, typeTvBtn].forEach(btn => {
btn.addEventListener('click', () => {
typeMovieBtn.classList.toggle('active', btn === typeMovieBtn);
typeTvBtn.classList.toggle('active', btn === typeTvBtn);
});
});
// ==================== 代理设置事件 ====================
const proxyToggle = $('#bt-proxy-toggle');
const proxyBody = $('#bt-proxy-body');
const proxyListEl = $('#bt-proxy-list');
const proxyEnabledEl = $('#bt-proxy-enabled');
const proxySaveBtn = $('#bt-proxy-save');
const proxyClearBtn = $('#bt-proxy-clear');
const proxyTestBtn = $('#bt-proxy-test');
proxyToggle.addEventListener('click', () => {
const open = proxyBody.style.display !== 'none';
proxyBody.style.display = open ? 'none' : 'block';
proxyToggle.classList.toggle('open', !open);
});
const savedProxies = getProxies();
if (savedProxies.length > 0) {
proxyListEl.value = savedProxies.join('\n');
}
proxyEnabledEl.checked = GM_getValue('bt_proxy_enabled', false);
proxyEnabledEl.addEventListener('change', () => {
GM_setValue('bt_proxy_enabled', proxyEnabledEl.checked);
});
proxySaveBtn.addEventListener('click', () => {
const lines = proxyListEl.value.split('\n')
.map(s => s.trim())
.filter(s => s && /^(https?|socks[45]?):\/\//i.test(s));
saveProxies(lines);
proxyListEl.value = lines.join('\n');
});
proxyClearBtn.addEventListener('click', () => {
proxyListEl.value = '';
saveProxies([]);
proxyEnabledEl.checked = false;
GM_setValue('bt_proxy_enabled', false);
});
proxyTestBtn.addEventListener('click', async () => {
const lines = proxyListEl.value.split('\n')
.map(s => s.trim())
.filter(s => s && /^(https?|socks[45]?):\/\//i.test(s));
if (lines.length === 0) {
alert('请先输入代理地址');
return;
}
proxyTestBtn.disabled = true;
proxyTestBtn.textContent = '测试中...';
const results = [];
for (const proxy of lines) {
try {
const start = Date.now();
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('超时')), 8000);
GM_xmlhttpRequest({
method: 'GET',
url: location.origin + '/prod/api/v1/getVideoList?sb=test&page=1&limit=1&app_id=83768d9ad4&identity=23734adac0301bccdcb107c4aa21f96c',
timeout: 8000,
onload(res) { clearTimeout(timer); resolve(res); },
onerror(e) { clearTimeout(timer); reject(new Error('连接失败')); },
ontimeout() { clearTimeout(timer); reject(new Error('超时')); },
});
});
const ms = Date.now() - start;
results.push(`✅ ${proxy} — ${ms}ms`);
} catch (e) {
results.push(`❌ ${proxy} — ${e.message}`);
}
}
proxyTestBtn.disabled = false;
proxyTestBtn.textContent = '🔍 测试';
alert(results.join('\n'));
});
// ==================== 核心逻辑 ====================
const statusEl = $('#bt-status');
const resultsEl = $('#bt-results');
const fetchBtn = $('#bt-fetch');
const cancelBtn = $('#bt-cancel');
let shouldCancel = false;
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportAsTxt(allMagnets) {
const lines = [];
allMagnets.forEach(entry => {
lines.push(`# ${entry.video.title} (${entry.video.years || ''} ${entry.video.area || ''} ${entry.video.status || ''})${entry.video.rating ? ' [评分:' + entry.video.rating + ']' : ''}`);
entry.magnets.forEach(m => {
if (m.zlink) {
lines.push(m.zlink);
}
});
lines.push('');
});
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
downloadFile(lines.join('\n'), `磁力导出Pro-${ts}.txt`, 'text/plain;charset=utf-8');
}
function parseSize(sizeStr) {
if (!sizeStr) return 0;
const s = sizeStr.trim().toUpperCase();
const num = parseFloat(s);
if (isNaN(num)) return 0;
if (s.includes('TB') || s.includes('T')) return num * 1024 * 1024 * 1024 * 1024;
if (s.includes('GB') || s.includes('G')) return num * 1024 * 1024 * 1024;
if (s.includes('MB') || s.includes('M')) return num * 1024 * 1024;
if (s.includes('KB') || s.includes('K')) return num * 1024;
return num;
}
function exportAsJson(allMagnets) {
const data = allMagnets.map(entry => {
const validMagnets = entry.magnets.filter(m => m.zlink);
const bestMagnet = validMagnets.length > 0
? validMagnets.reduce((best, cur) =>
parseSize(cur.zsize) > parseSize(best.zsize) ? cur : best
)
: null;
return {
title: entry.video.title,
years: entry.video.years || '',
area: entry.video.area || '',
status: entry.video.status || '',
rating: entry.video.rating || '',
magnets: bestMagnet ? [{
name: bestMagnet.zname || '',
size: bestMagnet.zsize || '',
quality: bestMagnet.zqxd || '',
codec: bestMagnet.ezt || '',
magnet: bestMagnet.zlink
}] : []
};
});
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
downloadFile(JSON.stringify(data, null, 2), `磁力导出Pro-${ts}.json`, 'application/json;charset=utf-8');
}
function setStatus(msg, loading = false) {
statusEl.innerHTML = loading ? `<span class="bt-progress">⏳ ${msg}</span>` : msg;
}
function renderStars(rating) {
if (!rating && rating !== 0) return '';
const r = parseFloat(rating);
if (isNaN(r)) return '';
const full = Math.floor(r / 2);
const half = (r / 2 - full) >= 0.5 ? 1 : 0;
const empty = 5 - full - half;
return `<span class="bt-rating-stars">${'★'.repeat(full)}${half ? '☆' : ''}${'☆'.repeat(empty)}</span> <span style="color:#ffc107;font-size:11px">${r.toFixed(1)}</span>`;
}
function renderResult(idx, video, magnets) {
const div = document.createElement('div');
div.className = 'bt-result-item';
if (!magnets || magnets.length === 0) {
div.innerHTML = `
<div class="bt-result-title">${idx}. ${video.title}</div>
<div class="bt-result-meta">${video.years || ''} ${video.area || ''} ${video.status ? '| ' + video.status : ''} ${video.rating ? '| ' + renderStars(video.rating) : ''} | 无匹配磁力</div>
`;
return div;
}
const firstMagnet = magnets.map((m, i) => `
<div class="bt-result-magnet" id="mag-${idx}-${i}">
<div style="margin-bottom:4px"><b>${m.zname || '未知'}</b></div>
<div style="margin-bottom:4px;color:#667eea">${m.zsize || '?'} | ${m.zqxd || '?'} | ${m.ezt || ''}</div>
<div>${m.zlink || '无链接'}</div>
</div>
<div class="bt-result-actions">
<button class="bt-btn-sm bt-copy-one" data-idx="${idx}" data-i="${i}">📋 复制此磁力</button>
</div>
`)[0];
const restMagnets = magnets.length > 1
? magnets.slice(1).map((m, i) => {
const ri = i + 1;
return `
<div class="bt-result-magnet" id="mag-${idx}-${ri}">
<div style="margin-bottom:4px"><b>${m.zname || '未知'}</b></div>
<div style="margin-bottom:4px;color:#667eea">${m.zsize || '?'} | ${m.zqxd || '?'} | ${m.ezt || ''}</div>
<div>${m.zlink || '无链接'}</div>
</div>
<div class="bt-result-actions">
<button class="bt-btn-sm bt-copy-one" data-idx="${idx}" data-i="${ri}">📋 复制此磁力</button>
</div>
`;
}).join('')
: '';
const drawerHtml = magnets.length > 1 ? `
<div class="bt-drawer" id="drawer-${idx}">${restMagnets}</div>
<button class="bt-toggle-more" data-idx="${idx}">
<span class="bt-arrow">▶</span> 展开剩余 ${magnets.length - 1} 条磁力
</button>
` : '';
div.innerHTML = `
<div class="bt-result-title">${idx}. ${video.title}</div>
<div class="bt-result-meta">${video.years || ''} ${video.area || ''} ${video.status ? '| ' + video.status : ''} ${video.rating ? '| ' + renderStars(video.rating) : ''} | ${magnets.length} 条磁力</div>
${firstMagnet}
${drawerHtml}
`;
return div;
}
resultsEl.addEventListener('click', e => {
const btn = e.target.closest('.bt-copy-one');
if (!btn) return;
const idx = btn.dataset.idx, i = btn.dataset.i;
const el = $(`#mag-${idx}-${i}`);
if (!el) return;
const link = el.querySelectorAll('div')[2]?.textContent?.trim();
if (link && link.startsWith('magnet:')) {
GM_setClipboard(link);
btn.textContent = '✅ 已复制';
setTimeout(() => btn.textContent = '📋 复制此磁力', 1500);
}
});
resultsEl.addEventListener('click', e => {
const btn = e.target.closest('.bt-toggle-more');
if (!btn) return;
const idx = btn.dataset.idx;
const drawer = $(`#drawer-${idx}`);
if (!drawer) return;
const isOpen = drawer.classList.toggle('open');
btn.classList.toggle('open', isOpen);
const total = drawer.querySelectorAll('.bt-result-magnet').length;
btn.innerHTML = isOpen
? `<span class="bt-arrow">▶</span> 收起磁力`
: `<span class="bt-arrow">▶</span> 展开剩余 ${total} 条磁力`;
});
// ==================== 缓存管理 ====================
const CACHE_PREFIX = 'bt_cache_v3_';
function getCacheKey(keyword, area, genres, quality, audioKws, videoType, year, sortMode) {
return CACHE_PREFIX + JSON.stringify({ keyword, area, genres: genres.sort(), quality, audioKws: audioKws.sort(), videoType, year, sortMode });
}
function matchYear(videoYears, yearFilter) {
if (!yearFilter) return true;
const y = parseInt(videoYears);
if (isNaN(y)) return false;
switch (yearFilter) {
case '2026': return y === 2026;
case '2025': return y === 2025;
case '2024': return y === 2024;
case '2023': return y === 2023;
case '2022': return y === 2022;
case '2021': return y === 2021;
case '2020': return y === 2020;
case '2019': return y === 2019;
case '2018': return y === 2018;
case '2017': return y === 2017;
case '2016': return y === 2016;
case '2015': return y === 2015;
case '2010-2015': return y >= 2010 && y <= 2015;
case '2000-2010': return y >= 2000 && y < 2010;
case '90s': return y >= 1990 && y < 2000;
case '80s': return y < 1990;
default: return true;
}
}
// 获取视频评分
function getVideoRating(v) {
const r = v.douban_rating || v.rating || v.score || v.douban_score || 0;
return parseFloat(r) || 0;
}
// 获取视频年份
function getVideoYear(v) {
return parseInt(v.niandai || v.years || '0') || 0;
}
// 评分/年份排序比较器
function sortVideos(list, sortMode) {
if (sortMode === 'default') return list;
const sorted = [...list];
switch (sortMode) {
case 'rating_desc':
sorted.sort((a, b) => getVideoRating(b) - getVideoRating(a));
break;
case 'rating_asc':
sorted.sort((a, b) => getVideoRating(a) - getVideoRating(b));
break;
case 'year_desc':
sorted.sort((a, b) => getVideoYear(b) - getVideoYear(a));
break;
case 'year_asc':
sorted.sort((a, b) => getVideoYear(a) - getVideoYear(b));
break;
}
return sorted;
}
function saveCache(cacheKey, allMagnets) {
try {
GM_setValue(cacheKey, JSON.stringify(allMagnets));
} catch (e) {
console.warn('缓存保存失败:', e);
}
}
function loadCache(cacheKey) {
try {
const raw = GM_getValue(cacheKey, null);
return raw ? JSON.parse(raw) : null;
} catch (e) {
console.warn('缓存读取失败:', e);
return null;
}
}
function addExportButtons(allMagnets, newMagnets) {
const totalMagnets = allMagnets.flatMap(r => r.magnets).filter(m => m.zlink);
const onlyNewMagnets = newMagnets.flatMap(r => r.magnets).filter(m => m.zlink);
const hasNew = newMagnets.length > 0 && newMagnets.length < allMagnets.length;
if (totalMagnets.length > 0) {
const copyAllDiv = document.createElement('div');
copyAllDiv.className = 'bt-copy-all';
copyAllDiv.innerHTML = `
<button class="bt-btn-sm" id="bt-copy-all" style="padding:8px 16px;font-size:13px">
📋 复制全部 (${totalMagnets.length})
</button>
<button class="bt-btn-export bt-btn-export-txt" id="bt-export-txt">
📄 导出 TXT
</button>
<button class="bt-btn-export bt-btn-export-json" id="bt-export-json">
📦 导出 JSON (全部 ${allMagnets.length})
</button>
${hasNew ? `
<button class="bt-btn-export bt-btn-export-json" id="bt-export-json-new" style="background:linear-gradient(135deg,#4caf50 0%,#2e7d32 100%)">
📦 导出 JSON (仅新增 ${newMagnets.length})
</button>
` : ''}
`;
resultsEl.insertBefore(copyAllDiv, resultsEl.firstChild);
$('#bt-copy-all', resultsEl).addEventListener('click', () => {
const allLinks = totalMagnets.map(m => m.zlink).join('\n');
GM_setClipboard(allLinks);
$('#bt-copy-all', resultsEl).textContent = '✅ 已复制!';
setTimeout(() => { $('#bt-copy-all', resultsEl).textContent = `📋 复制全部 (${totalMagnets.length})`; }, 2000);
});
$('#bt-export-txt', resultsEl).addEventListener('click', () => {
exportAsTxt(allMagnets);
$('#bt-export-txt', resultsEl).textContent = '✅ 已导出!';
setTimeout(() => { $('#bt-export-txt', resultsEl).textContent = '📄 导出 TXT'; }, 2000);
});
$('#bt-export-json', resultsEl).addEventListener('click', () => {
exportAsJson(allMagnets);
$('#bt-export-json', resultsEl).textContent = '✅ 已导出!';
setTimeout(() => { $('#bt-export-json', resultsEl).textContent = `📦 导出 JSON (全部 ${allMagnets.length})`; }, 2000);
});
if (hasNew) {
$('#bt-export-json-new', resultsEl).addEventListener('click', () => {
exportAsJson(newMagnets);
$('#bt-export-json-new', resultsEl).textContent = '✅ 已导出!';
setTimeout(() => { $('#bt-export-json-new', resultsEl).textContent = `📦 导出 JSON (仅新增 ${newMagnets.length})`; }, 2000);
});
}
}
}
cancelBtn.addEventListener('click', () => {
shouldCancel = true;
setStatus('⏹ 正在取消...');
});
// ==================== 主流程(优化并发 + 多类别 + 评分排序) ====================
fetchBtn.addEventListener('click', async () => {
const keyword = $('#bt-keyword').value.trim();
const area = $('#bt-area').value;
const quality = $('#bt-quality').value;
const year = $('#bt-year').value;
const totalLimit = parseInt($('#bt-limit').value) || 5;
const audioRaw = audioInput.value.trim();
const audioKws = audioRaw ? audioRaw.split(/[,,、/]/).map(s => s.trim().toLowerCase()).filter(Boolean) : [];
const videoType = typeTvBtn.classList.contains('active') ? 'tv' : 'movie';
const sortMode = $('#bt-sort').value;
// 解析多选类型
const genreSelected = $$('.bt-tag.active', genreTags).map(t => t.dataset.v);
const genreCustomRaw = genreCustomInput.value.trim();
const genreCustom = genreCustomRaw ? genreCustomRaw.split(/[,,、/]/).map(s => s.trim()).filter(Boolean) : [];
const genres = [...new Set([...genreSelected, ...genreCustom])];
if (!keyword && !area && genres.length === 0 && !quality && !year) {
setStatus('⚠️ 请至少输入关键词或选择一个筛选条件');
return;
}
fetchBtn.disabled = true;
cancelBtn.style.display = 'block';
shouldCancel = false;
resultsEl.innerHTML = '';
setStatus('正在搜索...', true);
const cacheKey = getCacheKey(keyword, area, genres, quality, audioKws, videoType, year, sortMode);
const PAGE_SIZE = 25;
const BATCH_SIZE = 500;
const CONCURRENCY = 5;
const startTime = Date.now();
function elapsed() {
const s = Math.floor((Date.now() - startTime) / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
return h > 0 ? `${h}时${m % 60}分` : m > 0 ? `${m}分${s % 60}秒` : `${s}秒`;
}
try {
const allMagnets = [];
const newMagnets = [];
let processed = 0;
// --- 恢复缓存 ---
const cached = loadCache(cacheKey);
if (cached && cached.length > 0) {
setStatus(`从缓存恢复 ${cached.length} 条...`, true);
resultsEl.innerHTML = '';
cached.forEach((entry, i) => {
allMagnets.push(entry);
const item = renderResult(i + 1, entry.video, entry.magnets);
resultsEl.appendChild(item);
});
processed = cached.length;
}
if (processed >= totalLimit) {
addExportButtons(allMagnets, []);
saveCache(cacheKey, allMagnets);
setStatus(`✅ 从缓存恢复 ${processed} 条,无需重复获取`);
fetchBtn.disabled = false;
return;
}
const processedIds = new Set(allMagnets.map(e => e.doubId).filter(Boolean));
const remaining = totalLimit - processed;
const batchCount = Math.ceil(remaining / BATCH_SIZE);
if (batchCount > 1) {
setStatus(`📊 总需 ${totalLimit} 条,将分 ${batchCount} 批处理(每批最多 ${BATCH_SIZE} 条,并发${CONCURRENCY})...`, true);
await sleep(800);
}
let globalVideoList = [];
let globalTotal = 0;
let globalIdx = 0;
// 多类型搜索辅助:获取类型参数
function getGenreParam(genre) {
// API 的 sc 参数接受类型名称
return genre || '';
}
// 翻页加载更多
async function loadMore() {
const nextPage = Math.floor(globalVideoList.length / PAGE_SIZE) + 1;
setStatus(`正在搜索第 ${nextPage} 页...`, true);
if (keyword) {
const wantType = videoType === 'tv' ? 2 : 1;
const res = await api('getVideoList', { sb: keyword, page: nextPage });
if (!res?.success) return;
const items = (res.data?.data || []).filter(v => v.type === wantType);
globalTotal = res.data?.total || globalTotal;
globalVideoList = globalVideoList.concat(items);
} else {
// 多类型搜索:如果选了多个类型,分别搜索后合并去重
if (genres.length > 1) {
const allItems = [];
for (const g of genres) {
const sa = videoType === 'tv' ? '2' : '1';
const res = await api('getVideoMovieList', {
sa, sb: '', sc: getGenreParam(g), sd: area,
se: '', sf: quality, sg: '1', sh: '',
page: String(nextPage), pfrs: '0', pfqj: '0x10',
imdb: '0', iswp: '0', status: ''
});
if (res?.success && res.data?.list) {
allItems.push(...res.data.list);
}
await sleep(80);
}
// 去重
const seen = new Set(globalVideoList.map(v => v.doub_id));
const unique = allItems.filter(v => !seen.has(v.doub_id));
globalTotal = globalVideoList.length + unique.length;
globalVideoList = globalVideoList.concat(unique);
} else {
const sa = videoType === 'tv' ? '2' : '1';
const res = await api('getVideoMovieList', {
sa, sb: '', sc: getGenreParam(genres[0] || ''), sd: area,
se: '', sf: quality, sg: '1', sh: '',
page: String(nextPage), pfrs: '0', pfqj: '0x10',
imdb: '0', iswp: '0', status: ''
});
if (!res?.success) return;
const items = res.data?.list || [];
globalTotal = res.data?.total || globalTotal;
globalVideoList = globalVideoList.concat(items);
}
}
await sleep(80);
}
// 收集一批待处理视频
async function collectVideoBatch(batchTarget) {
const list = [];
while (list.length < batchTarget) {
if (shouldCancel) break;
if (globalIdx >= globalVideoList.length) {
if (globalVideoList.length < globalTotal) {
await loadMore();
if (shouldCancel) break;
} else {
break;
}
}
if (globalIdx >= globalVideoList.length) break;
const v = globalVideoList[globalIdx++];
const doubId = v.doub_id;
if (!doubId) continue;
if (processedIds.has(doubId)) continue;
const vYear = v.niandai || v.years || '';
if (year && !matchYear(vYear, year)) continue;
list.push(v);
}
return list;
}
// 并发处理视频列表
async function processVideoListConcurrently(videoList) {
let successCount = 0;
const running = new Set();
for (const v of videoList) {
const task = (async () => {
const doubId = v.doub_id;
await randSleep(20, 80);
try {
const detail = await api('getVideoDetail', { id: doubId });
if (!detail?.success || !detail?.data) return;
const seeds = detail.data.all_seeds || [];
let matched = seeds;
if (audioKws.length > 0) {
matched = seeds.filter(s => {
const name = (s.zname || '').toLowerCase();
return audioKws.some(k => name.includes(k));
});
}
const rating = getVideoRating(v) || getVideoRating(detail.data);
const videoInfo = {
title: v.title || '未知',
years: v.niandai || v.years || '',
area: v.production_area || '',
status: v.status || v.zhuangtai || v.state || '',
rating: rating,
};
const magnets = (matched.length > 0 || audioKws.length === 0) ? (matched.length > 0 ? matched : seeds) : null;
if (magnets) {
const entry = { video: videoInfo, magnets, doubId };
allMagnets.push(entry);
newMagnets.push(entry);
processedIds.add(doubId);
processed++;
const item = renderResult(processed, videoInfo, magnets);
resultsEl.appendChild(item);
saveCache(cacheKey, allMagnets);
successCount++;
}
} catch (err) {
console.error(`获取 ${v.title} 失败:`, err);
}
})();
const wrapped = task.then(() => { running.delete(wrapped); });
running.add(wrapped);
if (running.size >= CONCURRENCY) {
await Promise.race(running);
}
}
await Promise.allSettled(running);
return successCount;
}
// --- 第一批搜索 ---
if (keyword) {
const wantType = videoType === 'tv' ? 2 : 1;
setStatus('正在搜索第 1 页...', true);
const res = await api('getVideoList', { sb: keyword, page: 1 });
if (!res?.success) throw new Error(res?.message || '搜索失败');
const items = (res.data?.data || []).filter(v => v.type === wantType);
globalTotal = res.data?.total || items.length;
globalVideoList = items;
} else {
// 多类型首批搜索
if (genres.length > 1) {
setStatus(`正在搜索 ${genres.length} 个类型...`, true);
const allItems = [];
for (const g of genres) {
const sa = videoType === 'tv' ? '2' : '1';
const res = await api('getVideoMovieList', {
sa, sb: '', sc: getGenreParam(g), sd: area,
se: '', sf: quality, sg: '1', sh: '',
page: '1', pfrs: '0', pfqj: '0x10',
imdb: '0', iswp: '0', status: ''
});
if (res?.success && res.data?.list) {
allItems.push(...res.data.list);
globalTotal = res.data?.total || 0;
}
await sleep(80);
}
// 去重
const seen = new Set();
globalVideoList = allItems.filter(v => {
if (seen.has(v.doub_id)) return false;
seen.add(v.doub_id);
return true;
});
globalTotal = globalVideoList.length;
} else {
const sa = videoType === 'tv' ? '2' : '1';
setStatus('正在搜索第 1 页...', true);
const res = await api('getVideoMovieList', {
sa, sb: '', sc: getGenreParam(genres[0] || ''), sd: area,
se: '', sf: quality, sg: '1', sh: '',
page: '1', pfrs: '0', pfqj: '0x10',
imdb: '0', iswp: '0', status: ''
});
if (!res?.success) throw new Error(res?.message || '搜索失败');
const items = res.data?.list || [];
globalTotal = res.data?.total || items.length;
globalVideoList = items;
}
}
// 应用排序
if (sortMode !== 'default') {
const sortLabel = {
'rating_desc': '评分从高到低',
'rating_asc': '评分从低到高',
'year_desc': '年份从新到旧',
'year_asc': '年份从旧到新'
}[sortMode];
setStatus(`找到 ${globalTotal} 个影视,按${sortLabel}排序中...`, true);
globalVideoList = sortVideos(globalVideoList, sortMode);
}
const genreLabel = genres.length > 0 ? `[${genres.join('+')}]` : '';
setStatus(`找到 ${globalTotal} 个影视${genreLabel},开始加速处理...`, true);
// --- 分批并发 ---
for (let batch = 0; batch < batchCount; batch++) {
if (shouldCancel) break;
const batchTarget = Math.min(BATCH_SIZE, totalLimit - processed);
const batchList = await collectVideoBatch(batchTarget);
if (batchList.length === 0 || shouldCancel) break;
const batchLabel = batchCount > 1 ? `[批${batch + 1}] ` : '';
setStatus(`${batchLabel}正在并发获取 ${batchList.length} 个视频详情...(已获取 ${processed}/${totalLimit},已用 ${elapsed()})`, true);
await processVideoListConcurrently(batchList);
if (batchCount > 1 && !shouldCancel && processed < totalLimit) {
const cooldownSec = 3 + Math.floor(Math.random() * 4);
for (let cd = cooldownSec; cd > 0; cd--) {
if (shouldCancel) break;
setStatus(`⏳ 第 ${batch + 1} 批完成,冷却中...${cd}秒(已用 ${elapsed()})`, true);
await sleep(1000);
}
}
}
addExportButtons(allMagnets, newMagnets);
const newCount = processed - (cached ? cached.length : 0);
const fromCache = cached ? cached.length : 0;
const sortSuffix = sortMode !== 'default' ? `,已按${{ 'rating_desc': '评分降序', 'rating_asc': '评分升序', 'year_desc': '年份降序', 'year_asc': '年份升序' }[sortMode] || ''}排列` : '';
setStatus(`✅ 完成! 缓存 ${fromCache} + 新增 ${newCount} = 共 ${processed} 个影视(耗时 ${elapsed()}${sortSuffix})`);
} catch (err) {
const msg = err.name === 'AbortError' ? '❌ 请求超时,请检查网络后重试' : `❌ ${err.message}`;
setStatus(msg);
console.error(err);
} finally {
fetchBtn.disabled = false;
cancelBtn.style.display = 'none';
}
});
})();