// ==UserScript==
// @name MoviePilot 种子名自动测试与下载 (增强版 - API Key )
// @namespace http://tampermonkey.net/
// @version 3.2.0
// @description 缝合两版脚本功能,在各大 PT 站点详情页自动提取种子名称,发送至 MoviePilot 进行识别,支持手动匹配和一键推送下载。使用 API Key,无需账号密码。
// @author Arthur Cole、yubanmeiqin9048
// @match https://*/details.php?id=*
// @match https://*/details_movie.php?id=*
// @match https://*/details_tv.php?id=*
// @match https://*/details_animate.php?id=*
// @match https://totheglory.im/t/*
// @match https://bangumi.moe/*
// @match https://*.acgnx.se/*
// @match https://*.dmhy.org/*
// @match https://nyaa.si/*
// @match https://mikanani.me/*
// @match https://*.skyey2.com/*
// @match https://*.m-team.cc/detail/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @connect *
// @license MIT
// ==/UserScript==
GM_addStyle(`
.mp-modal { display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); font-family: sans-serif;}
.mp-modal-content { background-color: white; margin: 10% auto; padding: 20px; border-radius: 8px; width: 60%; max-width: 600px; position: relative; color:#333; box-shadow: 0 4px 12px rgba(0,0,0,0.2);}
.mp-close { color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer; position: absolute; right: 15px; top: 5px; }
.mp-close:hover { color: black; }
.mp-input-group { margin-bottom: 15px; }
.mp-input-group label { display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;}
.mp-input-group input { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; font-size: 14px;}
.mp-search-btn { background-color: #007bff; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px; margin-top: 5px; font-size: 14px;}
.mp-search-btn:hover { background-color: #0056b3; }
.mp-results-container { margin-top: 20px; max-height: 300px; overflow-y: auto;}
.mp-result-item { padding: 10px; border: 1px solid #ddd; margin-bottom: 8px; border-radius: 4px; cursor: pointer; transition: background 0.2s;}
.mp-result-item:hover { background-color: #f0f8ff; border-color:#007bff; }
.mp-result-title { font-weight: bold; margin-bottom: 5px; }
.mp-result-year { color: #666; font-size: 0.9em; }
.mp-result-type { display: inline-block; background-color: #2775b6; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.8em; margin-right: 5px; }
/* 进度通知 UI 样式 */
#mp-toast-container { position: fixed; top: 20px; right: 20px; z-index: 10001; display: flex; flex-direction: column; gap: 10px; }
.mp-toast { background: white; border-left: 5px solid #007bff; box-shadow: 0 4px 15px rgba(0,0,0,0.2); padding: 15px 20px; border-radius: 4px; display: flex; align-items: flex-start; justify-content: space-between; min-width: 280px; max-width: 400px; font-family: sans-serif; transition: opacity 0.3s ease; }
.mp-toast.info { border-color: #007bff; }
.mp-toast.success { border-color: #28a745; }
.mp-toast.error { border-color: #dc3545; }
.mp-toast-content { display: flex; flex-direction: column; width: 100%; }
.mp-toast-title { font-weight: bold; font-size: 14px; margin-bottom: 6px; color: #333; }
.mp-toast-msg { font-size: 13px; color: #666; word-break: break-all; line-height: 1.4; }
.mp-toast-close { cursor: pointer; color: #999; font-weight: bold; font-size: 18px; margin-left: 15px; line-height: 14px; }
.mp-toast-close:hover { color: #333; }
/* M-Team 标题下方专用展示块 */
.mp-mteam-panel { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--mt-line-color, #e5e7eb); }
.mp-mteam-title { font-size: 12px; color: #6b7280; margin-bottom: 8px; font-weight: 600; letter-spacing: .2px; }
.mp-mteam-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; line-height: 1.2; }
`);
// ---------- 全局配置管理 ----------
let mpUrl = GM_getValue('mp_url', '');
let mpApiKey = GM_getValue('mp_api_key', ''); // 改用 API Key
let mpTmdbKey = GM_getValue('mp_tmdb_key', '');
let mpDebug = GM_getValue('mp_debug', false);
let mpLastMTeamBtnLogKey = '';
let mpLastMTeamBtnMissingLogKey = '';
let mpLastMTeamMountMissingLogKey = '';
let mpMTeamRuntimeDownloadLinks = {};
let mpMTeamRuntimeListenerInstalled = false;
let mpMTeamAutoPrefetchState = {};
let mpMTeamMutationWatcherState = {};
function debugLog(...args) {
if (!mpDebug) return;
console.log('[MoviePilot-NameTest]', ...args);
}
// 通用认证请求头
function getAuthHeaders() {
return {
"user-agent": navigator.userAgent,
"content-type": "application/json",
"X-API-KEY": mpApiKey
};
}
function toPreview(data, limit = 360) {
try {
let text = typeof data === 'string' ? data : JSON.stringify(data);
return text.length > limit ? text.slice(0, limit) + ' ...' : text;
} catch (e) {
return String(data);
}
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function getHostFromUrl(rawUrl) {
try {
return new URL(rawUrl).hostname.toLowerCase();
} catch (e) {
return '';
}
}
function isAllowedRequestUrl(rawUrl) {
try {
const host = new URL(rawUrl, window.location.origin).hostname.toLowerCase();
if (host === 'api.themoviedb.org') return true;
const mpHost = getHostFromUrl(mpUrl);
return Boolean(mpHost) && host === mpHost;
} catch (e) {
return false;
}
}
function requestWithGuard(options) {
const targetUrl = options?.url || '';
if (!isAllowedRequestUrl(targetUrl)) {
debugLog('blocked request by host guard', { targetUrl, mpUrl });
if (typeof options?.onerror === 'function') {
options.onerror({ status: 0, responseText: 'Blocked by host guard' });
}
return;
}
GM_xmlhttpRequest(options);
}
function showConfigModal() {
let existingModal = document.getElementById('mp-config-modal');
if (existingModal) existingModal.remove();
const modal = document.createElement('div');
modal.id = 'mp-config-modal';
modal.className = 'mp-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = 'block';
document.getElementById('mp-config-close').onclick = () => modal.remove();
document.getElementById('mp-save-btn').onclick = () => {
let url = document.getElementById('mp-input-url').value.trim().replace(/\/$/, "");
let apiKey = document.getElementById('mp-input-api-key').value.trim();
let tmdbKey = document.getElementById('mp-input-tmdb-key').value.trim();
if (!url || !apiKey) {
alert("请完整填写 MoviePilot 地址和 API Key!");
return;
}
try {
const parsed = new URL(url);
if (!/^https?:$/i.test(parsed.protocol)) {
alert("MoviePilot 地址必须是 http:// 或 https:// 开头。");
return;
}
} catch (e) {
alert("MoviePilot 地址格式不正确,请输入完整 URL(含协议和端口)。");
return;
}
GM_setValue('mp_url', url);
GM_setValue('mp_api_key', apiKey);
GM_setValue('mp_tmdb_key', tmdbKey);
// 更新内存变量
mpUrl = url;
mpApiKey = apiKey;
mpTmdbKey = tmdbKey;
alert("保存成功!脚本将开始运行。");
modal.remove();
// 触发一次注入检测
insertMpRow();
};
}
// 注册油猴菜单栏命令
GM_registerMenuCommand("⚙️ 设置 MoviePilot", showConfigModal);
GM_registerMenuCommand("🧪 切换调试日志", () => {
mpDebug = !mpDebug;
GM_setValue('mp_debug', mpDebug);
alert(`调试日志已${mpDebug ? '开启' : '关闭'}(刷新页面后持续生效)`);
});
// ---------- 进度通知组件 ----------
function createToast(title, msg, type) {
let container = document.getElementById('mp-toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'mp-toast-container';
document.body.appendChild(container);
}
let id = 'toast-' + Date.now() + Math.floor(Math.random() * 1000);
let toast = document.createElement('div');
toast.id = id;
toast.className = `mp-toast ${type}`;
toast.innerHTML = `
${escapeHtml(title)}
${escapeHtml(msg)}
×
`;
container.appendChild(toast);
return id;
}
function updateToast(id, title, msg, type) {
let toast = document.getElementById(id);
if (toast) {
toast.className = `mp-toast ${type}`;
toast.querySelector('.mp-toast-title').textContent = title;
toast.querySelector(`#msg-${id}`).textContent = msg;
}
}
function removeToast(id) {
let toast = document.getElementById(id);
if (toast) {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}
}
// ---------- UI 辅助工具 ----------
function renderTag(type, string, background_color) {
const safeString = escapeHtml(string);
if (type == 'common') {
return `${safeString}`
} else if (type == 'mteam') {
return `${safeString}`;
} else {
return `${safeString}`
}
}
function renderMoviepilotTag(type, tag) {
if (type === "mteam") {
return `${tag}
`;
}
if (type == "common") {
return `MoviePilot | ${tag} | `;
} else {
return tag
}
}
function getSize(sizeStr) {
if (!sizeStr) return 0;
let match = sizeStr.match(/(\d+\.?\d*)\s*(GB|MB|KB|TB)/i);
if (!match) return 0;
let size = parseFloat(match[1]);
let unit = match[2].toUpperCase();
switch (unit) {
case 'MB': return size * 1024 ** 2;
case 'GB': return size * 1024 ** 3;
case 'TB': return size * 1024 ** 4;
default: return 0;
}
}
function escapeHtmlAttr(value) {
return escapeHtml(value).replace(/`/g, '`');
}
function normalizeTorrentTitle(rawTitle) {
if (!rawTitle) return '';
let title = String(rawTitle).replace(/\s+/g, ' ').trim();
title = title
.replace(/\.torrent$/i, '')
.replace(/\.(mkv|mp4|avi|ts|m2ts|flv|wmv)$/i, '');
for (let i = 0; i < 6; i++) {
let next = title.replace(/^\s*(?:\[[^\]]{1,30}\]|\([^\)]{1,30}\)|【[^】]{1,30}】|<[^>]{1,30}>)(?:[\s._-]+|$)/, '');
if (next === title) break;
title = next;
}
title = title
.replace(/^[^A-Za-z0-9\u4e00-\u9fa5]+/, '')
.replace(/^[A-Za-z0-9]{2,10}\]\s*/, '')
.replace(/^[\]\)\】\}]+/, '');
title = title.replace(/[._]+/g, ' ');
let noisyPart = title.match(/\b(?:4320p|2160p|1080p|720p|480p|web[-\s]?dl|webrip|bluray|bdrip|hdtv|dvdrip|remux|h\.?26[45]|x26[45]|hevc|avc|aac(?:\d\.\d)?|ddp?\d(?:\.\d)?|dts(?:-hd)?|atmos|hdr10\+?|dolby[\s-]?vision|10bit|8bit)\b/i);
if (noisyPart && noisyPart.index > 0) {
title = title.slice(0, noisyPart.index);
}
title = title.replace(/\s+free\s+\d+\s*h(?:\s+\d+\s*min)?$/i, '');
title = title.replace(/\s+-[A-Za-z0-9][A-Za-z0-9._-]*$/, '');
return title.replace(/\s+/g, ' ').trim();
}
function isMTeamHost() {
return /(^|\.)m-team\.cc$/i.test(window.location.hostname);
}
function isMTeamDetailPage() {
return isMTeamHost() && /^\/detail\/\d+/.test(window.location.pathname);
}
function shouldProcessCurrentPage() {
if (isMTeamHost()) return isMTeamDetailPage();
return true;
}
function isSpringSundaySite() {
return /(^|\.)springsunday\.net$/i.test(window.location.hostname);
}
function isQingwaSite() {
return /(^|\.)qingwa\./i.test(window.location.hostname) || /(^|\.)keepfrds\.com$/i.test(window.location.hostname);
}
function isQingwaLikeLayout() {
if (isSpringSundaySite() || isQingwaSite()) return true;
return Boolean(document.getElementById('qingwa-head') && document.querySelector('h1#top'));
}
function extractQingwaTitle() {
const h1 = document.querySelector('h1#top');
if (!h1) return '';
let title = cleanText(h1.textContent || '');
title = title
.replace(/\[[^\]]*(免费|free|折扣|促销)[^\]]*\]/ig, ' ')
.replace(/剩余时间[::].*$/i, ' ')
.replace(/\b(通过|未审|已审)\b.*$/i, ' ')
.replace(/\s+/g, ' ')
.trim();
title = normalizeTorrentTitle(title);
return title;
}
function cleanupMpUi() {
let row = document.getElementById('mp-row-flag');
if (row) {
let tr = row.closest('tr');
if (tr) tr.remove();
else row.remove();
}
document.querySelectorAll('.mp-modal').forEach(el => el.remove());
let toast = document.getElementById('mp-toast-container');
if (toast) toast.remove();
}
function findMTeamTitleAnchor() {
const exactSelector = '#app-content > div > div.app-content__inner.px-\\[40px\\].flex.flex-col.justify-between > div.mx-auto.w-full > div.flex.py-5.mb-5.border-0.border-b.border-solid.border-\\[--mt-line-color\\].sticky.items-start.top-0.z-\\[999\\].bg-mt-primary-1.text-\\[--mt-text-base\\] > div.flex-grow.w-1.flex.flex-col.justify-between > div:nth-child(2) > div.ant-space.css-4dguzz.ant-space-horizontal.ant-space-align-center.ant-space-gap-row-small.ant-space-gap-col-small.gap-x-\\[18px\\].gap-y-0.text-\\[--mt-text-base\\]';
const looseSelector = '#app-content div.app-content__inner.px-\\[40px\\] div.flex-grow.w-1.flex.flex-col.justify-between > div:nth-child(2) > div.ant-space.gap-x-\\[18px\\].gap-y-0.text-\\[--mt-text-base\\]';
let node = document.querySelector(exactSelector);
if (!node) {
node = document.querySelector(looseSelector)
|| document.querySelector('h2 > span:nth-child(3), h2 > span, h2');
}
return node || null;
}
function createMTeamMountNode() {
const anchor = findMTeamTitleAnchor();
if (!anchor) {
const missKey = `${window.location.pathname}|mount-missing`;
if (mpLastMTeamMountMissingLogKey !== missKey) {
mpLastMTeamMountMissingLogKey = missKey;
debugLog('m-team mount not found: waiting for title anchor');
}
return null;
}
const anchorText = cleanText(anchor.textContent || '');
if (!anchorText || /^m-team$/i.test(anchorText) || anchorText.length < 6) {
return null;
}
const panel = document.createElement('div');
panel.id = 'mp-row-flag';
panel.className = 'mp-mteam-panel';
panel.innerHTML = 'MoviePilot
初始化中...
';
anchor.insertAdjacentElement('afterend', panel);
return panel.querySelector('#mp-mteam-content') || panel;
}
function isMTeamPageDataReady() {
if (!isMTeamDetailPage()) return false;
const titleAnchor = findMTeamTitleAnchor();
const anchorText = cleanText(titleAnchor?.textContent || '');
const fieldCount = document.querySelectorAll('.ant-descriptions-item-label').length;
const pageTitle = cleanText(document.title || '');
const nativeBtn = findMTeamNativeDownloadButton();
if (!titleAnchor) return false;
if (!anchorText || /^m-team$/i.test(anchorText) || anchorText.length < 6) return false;
if (nativeBtn) return true;
if (fieldCount >= 2) return true;
if (pageTitle && !/^m-team$/i.test(pageTitle) && /detail|種子詳情|torrent/i.test(pageTitle)) return true;
return false;
}
function stripEpisodeInfo(title) {
return String(title || '')
.replace(/\bS\d{1,2}E\d{1,3}\b/ig, ' ')
.replace(/\bE\d{1,3}\b/ig, ' ')
.replace(/\b第\s*\d+\s*[季集]\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function extractSubtitleCandidates(subtitle) {
let results = [];
let add = (v) => {
let val = String(v || '').replace(/\s+/g, ' ').trim();
if (val && !results.includes(val)) results.push(val);
};
if (!subtitle) return results;
let clean = String(subtitle || '')
.replace(/\*[^*]{1,40}\*/g, ' ')
.replace(/\[[^\]]{1,40}\]/g, ' ')
.replace(/[||]/g, '/')
.replace(/\s+/g, ' ')
.trim();
clean.split(/\s*\/\s*/).forEach((part) => {
let p = String(part || '').trim();
if (!p) return;
p = p
.replace(/(?:评论音轨|多国语字幕|字幕|中字|簡繁|简繁|国语|日语|英语|粤语|双语|音轨|內封|外挂|內嵌).*/gi, '')
.replace(/\s+/g, ' ')
.trim();
add(p);
let words = p.match(/\b[A-Za-z][A-Za-z0-9'’-]{3,}\b/g) || [];
words.forEach(add);
});
return results.slice(0, 8);
}
function getRecognitionCandidates(rawTitle, subtitle = '') {
let candidates = [];
let addCandidate = (title) => {
let val = String(title || '')
.replace(/\s+/g, ' ')
.replace(/\.torrent$/i, '')
.replace(/\.(mkv|mp4|avi|ts|m2ts|flv|wmv)$/i, '')
.replace(/^[A-Za-z0-9]{2,10}\]\s*/, '')
.trim();
if (val && !candidates.includes(val)) candidates.push(val);
};
let normalized = normalizeTorrentTitle(rawTitle);
let withoutEpisode = stripEpisodeInfo(normalized);
let slashAlias = normalized.split(/\s*\/\s*/)[0].trim();
let hasTvPattern = /\bS\d{1,2}E\d{1,3}\b/i.test(normalized)
|| /\bE\d{1,3}\b/i.test(normalized)
|| /第\s*\d+\s*[季集]/.test(normalized);
addCandidate(rawTitle);
addCandidate(normalized);
addCandidate(slashAlias);
addCandidate(withoutEpisode);
extractSubtitleCandidates(subtitle).forEach(addCandidate);
// 电影常见格式兜底
if (!hasTvPattern) {
let yearMatch = normalized.match(/\b(?:19|20)\d{2}\b/);
if (yearMatch) {
let year = yearMatch[0];
let noYear = normalized
.replace(new RegExp(`\\b${year}\\b`, 'g'), ' ')
.replace(/\(\s*\)/g, ' ')
.replace(/\s+/g, ' ')
.trim();
addCandidate(noYear);
if (noYear) {
addCandidate(`${noYear} ${year}`);
addCandidate(`${noYear} (${year})`);
}
}
}
return candidates;
}
function cleanText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function addUnique(list, value) {
const val = cleanText(value);
if (val && !list.includes(val)) list.push(val);
}
function isLikelyTagCloudTitle(title) {
const val = cleanText(title);
if (!val) return true;
const pipeCount = (val.match(/\|/g) || []).length;
if (pipeCount >= 5 && !/\bS\d{1,2}E\d{1,3}\b/i.test(val)) return true;
const words = val.split(/\s+/).filter(Boolean);
if (!words.length) return true;
const upperShortWords = words.filter(w => /^[A-Z0-9]{2,10}$/.test(w)).length;
if (words.length >= 8 && upperShortWords / words.length > 0.65) return true;
return false;
}
function scoreTitleCandidate(title) {
const val = cleanText(title);
if (!val) return -999;
let score = 0;
if (val.length >= 6 && val.length <= 220) score += 2;
if (/[A-Za-z\u4e00-\u9fa5]/.test(val)) score += 1;
if (/\bS\d{1,2}E\d{1,3}\b/i.test(val)) score += 3;
if (/\b(?:4320p|2160p|1080p|720p|web[-\s]?dl|webrip|bluray|h\.?26[45]|x26[45]|hevc)\b/i.test(val)) score += 2;
if (/\b(?:19|20)\d{2}\b/.test(val)) score += 1;
const pipeCount = (val.match(/\|/g) || []).length;
if (pipeCount >= 3) score -= 3;
if (isLikelyTagCloudTitle(val)) score -= 4;
if (!/\s/.test(val) && val.length < 8) score -= 2;
return score;
}
function pickBestTitleCandidate(candidates) {
let best = '';
let bestScore = -999;
candidates.forEach((candidate) => {
const score = scoreTitleCandidate(candidate);
if (score > bestScore) {
best = cleanText(candidate);
bestScore = score;
}
});
return best;
}
function collectAntFields() {
const fields = [];
document.querySelectorAll('.ant-descriptions-item-label').forEach(label => {
const key = cleanText(label.textContent);
const value = cleanText(label.nextElementSibling?.textContent || '');
if (key && value) fields.push({ key, value });
});
return fields;
}
function pickAntFieldValue(fields, patterns) {
for (const field of fields) {
if (patterns.some(pattern => pattern.test(field.key))) {
return field.value;
}
}
return '';
}
function extractLinkFromString(text) {
const raw = String(text || '')
.replace(/\\\//g, '/')
.replace(/\\u002F/ig, '/')
.replace(/\\u003A/ig, ':')
.replace(/\\u003F/ig, '?')
.replace(/\\u003D/ig, '=')
.replace(/\\u0026/ig, '&')
.replace(/\\u0025/ig, '%')
.replace(/&/g, '&');
const match = raw.match(/https?:\/\/[^\s"'`<>\\]+|\/api\/rss\/dlv2\?[^\s"'`<>\\]+|\/api\/torrent\/download\?[^\s"'`<>\\]+/i);
if (!match) return '';
try {
return new URL(match[0], window.location.origin).href;
} catch (e) {
return '';
}
}
function findMTeamNativeDownloadButton() {
const exactSelector = '#app-content > div > div.app-content__inner.px-\\[40px\\].flex.flex-col.justify-between > div.mx-auto.w-full > div.flex.py-5.mb-5.border-0.border-b.border-solid.border-\\[--mt-line-color\\].sticky.items-start.top-0.z-\\[999\\].bg-mt-primary-1.text-\\[--mt-text-base\\] > div.flex-grow.w-1.flex.flex-col.justify-between > div.flex.justify-between > div > div > div > div:nth-child(4)';
return document.querySelector(exactSelector)
|| document.querySelector('div.flex.justify-between [class*="download"], div.flex.justify-between button, div.flex.justify-between a');
}
function deepFindDownloadLinkInObject(root, maxNodes = 5000) {
if (!root) return '';
const stack = [root];
const visited = new Set();
let count = 0;
while (stack.length && count < maxNodes) {
const current = stack.pop();
count++;
if (!current) continue;
const t = typeof current;
if (t === 'string') {
const parsed = extractLinkFromString(current);
if (parsed) return parsed;
continue;
}
if (t !== 'object' && t !== 'function') continue;
if (visited.has(current)) continue;
visited.add(current);
let keys = [];
try {
keys = Object.keys(current);
} catch (e) {
continue;
}
for (const key of keys) {
let val;
try {
val = current[key];
} catch (e) {
continue;
}
if (typeof val === 'string') {
const parsed = extractLinkFromString(val);
if (parsed) return parsed;
} else if (val && (typeof val === 'object' || typeof val === 'function')) {
stack.push(val);
}
}
}
return '';
}
function isLikelyTorrentDownloadUrl(abs) {
const url = String(abs || '');
if (!url) return false;
if (/\/api\/rss\/dlv2\?/i.test(url)) return true;
if (/\/api\/torrent\/download\?/i.test(url)) return true;
if (/\.torrent(?:\?|$)/i.test(url)) return true;
if (/[?&](?:app_id|payload|playload)=/i.test(url) && /[?&]sign=/i.test(url) && /[?&]t=/i.test(url)) return true;
if (/halomt\.com/i.test(url) && /[?&]sign=/i.test(url)) return true;
return false;
}
function isAllowedMTeamDownloadHost(abs) {
try {
const host = new URL(abs).hostname.toLowerCase();
return /(^|\.)m-team\.cc$|(^|\.)m-team\.io$|(^|\.)halomt\.com$/.test(host);
} catch (e) {
return false;
}
}
function getCurrentMTeamTid() {
const m = window.location.pathname.match(/\/detail\/(\d+)/);
return m ? m[1] : '';
}
function pruneMTeamCaches() {
const now = Date.now();
const maxAgeMs = 10 * 60 * 1000;
const maxEntries = 80;
const currentTid = getCurrentMTeamTid();
const pruneMapByAge = (mapObj, getTs) => {
Object.keys(mapObj).forEach((tid) => {
if (tid === currentTid) return;
const ts = getTs(mapObj[tid]) || 0;
if (!ts || now - ts > maxAgeMs) delete mapObj[tid];
});
};
pruneMapByAge(mpMTeamRuntimeDownloadLinks, (item) => item?.at);
pruneMapByAge(mpMTeamAutoPrefetchState, (item) => Number(item || 0));
Object.keys(mpMTeamMutationWatcherState).forEach((tid) => {
if (tid === currentTid) return;
const state = mpMTeamMutationWatcherState[tid];
if (!state?.active) delete mpMTeamMutationWatcherState[tid];
});
const trimMap = (mapObj, getTs) => {
const keys = Object.keys(mapObj);
if (keys.length <= maxEntries) return;
const sorted = keys
.map((k) => ({ k, ts: getTs(mapObj[k]) || 0 }))
.sort((a, b) => a.ts - b.ts);
const removeCount = sorted.length - maxEntries;
for (let i = 0; i < removeCount; i++) {
if (sorted[i].k !== currentTid) delete mapObj[sorted[i].k];
}
};
trimMap(mpMTeamRuntimeDownloadLinks, (item) => item?.at);
trimMap(mpMTeamAutoPrefetchState, (item) => Number(item || 0));
}
function setMTeamRuntimeDownloadLink(url, source) {
const tid = getCurrentMTeamTid();
if (!tid || !url) return;
const existing = mpMTeamRuntimeDownloadLinks[tid];
if (existing?.url === url) {
existing.source = source || existing.source;
existing.at = Date.now();
return;
}
mpMTeamRuntimeDownloadLinks[tid] = {
url,
source: source || 'runtime',
at: Date.now()
};
debugLog('m-team runtime link captured', { tid, source, url });
try {
document.dispatchEvent(new CustomEvent('mp-mteam-runtime-link-updated', { detail: { tid, url, source } }));
} catch (e) {}
}
function getMTeamRuntimeDownloadLink() {
const tid = getCurrentMTeamTid();
if (!tid) return '';
const item = mpMTeamRuntimeDownloadLinks[tid];
if (!item?.url) return '';
// 运行时签名链接可能过期,默认只使用 10 分钟内捕获的链接
if (Date.now() - (item.at || 0) > 10 * 60 * 1000) return '';
return item.url;
}
function getDownloadLikeAttrsFromElement(el) {
if (!el || typeof el.getAttribute !== 'function') return [];
const attrs = ['href', 'data-url', 'data-href', 'onclick', 'title', 'aria-label'];
const values = [];
attrs.forEach((attr) => {
const v = el.getAttribute(attr);
if (v) push(v);
});
if (el.outerHTML) values.push(el.outerHTML);
if (el.textContent) values.push(el.textContent);
return values;
}
function installMTeamRuntimeCaptureListener() {
if (mpMTeamRuntimeListenerInstalled) return;
mpMTeamRuntimeListenerInstalled = true;
document.addEventListener('mp-download-url-captured', (ev) => {
try {
const rawUrl = ev?.detail?.url || '';
const source = ev?.detail?.source || 'page-hook';
if (!rawUrl) return;
const abs = new URL(rawUrl, window.location.origin).href;
if (!isLikelyTorrentDownloadUrl(abs)) return;
if (!isAllowedMTeamDownloadHost(abs)) return;
const tid = getCurrentMTeamTid();
if (tid && /[?&]tid=\d+/.test(abs) && !new RegExp(`[?&]tid=${tid}(?:&|$)`).test(abs)) return;
setMTeamRuntimeDownloadLink(abs, `page:${source}`);
} catch (e) {}
}, true);
}
function installMTeamPageContextHook() {
if (!isMTeamDetailPage()) return;
if (document.getElementById('mp-mteam-page-hook-script')) return;
const script = document.createElement('script');
script.id = 'mp-mteam-page-hook-script';
script.textContent = `
(function() {
if (window.__mpMTeamPageHookInstalled) return;
window.__mpMTeamPageHookInstalled = true;
window.__mpMTeamPrefetchUntil = 0;
function shouldCapture(url) {
try {
var s = String(url || '');
var lower = s.toLowerCase();
if (lower.indexOf('dlv2') >= 0) return true;
if (lower.indexOf('torrent/download') >= 0) return true;
if (lower.indexOf('.torrent') >= 0) return true;
if (lower.indexOf('halomt.com') >= 0 && lower.indexOf('sign=') >= 0) return true;
if (
(lower.indexOf('app_id=') >= 0 || lower.indexOf('payload=') >= 0 || lower.indexOf('playload=') >= 0)
&& lower.indexOf('sign=') >= 0
) return true;
return false;
} catch (e) { return false; }
}
function inPrefetchMode() {
try {
return Date.now() < (window.__mpMTeamPrefetchUntil || 0);
} catch (e) { return false; }
}
try {
document.addEventListener('mp-mteam-prefetch-mode', function(ev) {
try {
var duration = Number(ev && ev.detail && ev.detail.duration) || 5000;
if (duration < 500) duration = 500;
window.__mpMTeamPrefetchUntil = Date.now() + duration;
} catch (e) {}
}, true);
} catch (e) {}
function emit(url, source) {
try {
if (!url || !shouldCapture(url)) return;
document.dispatchEvent(new CustomEvent('mp-download-url-captured', { detail: { url: String(url), source: source || 'page-hook' } }));
} catch (e) {}
}
try {
var origOpen = window.open;
if (typeof origOpen === 'function') {
window.open = function(url) {
emit(url, 'window.open');
if (inPrefetchMode() && shouldCapture(url)) return null;
return origOpen.apply(this, arguments);
};
}
} catch (e) {}
try {
if (window.fetch) {
var origFetch = window.fetch;
window.fetch = function(input) {
try {
var u = (typeof input === 'string') ? input : (input && input.url);
emit(u, 'fetch');
} catch (e) {}
return origFetch.apply(this, arguments);
};
}
} catch (e) {}
try {
var origXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
emit(url, 'xhr.open');
return origXhrOpen.apply(this, arguments);
};
} catch (e) {}
try {
var origAnchorClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function() {
emit(this && this.href, 'anchor.click');
if (inPrefetchMode() && shouldCapture(this && this.href)) return;
return origAnchorClick.apply(this, arguments);
};
} catch (e) {}
try {
document.addEventListener('click', function(ev) {
var path = ev.composedPath ? ev.composedPath() : [ev.target];
for (var i = 0; i < path.length; i++) {
var n = path[i];
if (!n || !n.getAttribute) continue;
emit(n.getAttribute('href'), 'dom.click.href');
emit(n.getAttribute('data-url'), 'dom.click.data-url');
emit(n.getAttribute('data-href'), 'dom.click.data-href');
emit(n.getAttribute('onclick'), 'dom.click.onclick');
}
}, true);
} catch (e) {}
})();
`;
(document.documentElement || document.head || document.body).appendChild(script);
}
function triggerMTeamPrefetchMode(durationMs = 5000) {
try {
document.dispatchEvent(new CustomEvent('mp-mteam-prefetch-mode', {
detail: { duration: durationMs }
}));
} catch (e) {}
}
function getMTeamPrefetchClickTargets(nativeBtn) {
const targets = [];
const seen = new Set();
const add = (el) => {
if (!el || typeof el !== 'object') return;
if (seen.has(el)) return;
seen.add(el);
targets.push(el);
};
add(nativeBtn);
nativeBtn.querySelectorAll?.('a,button,[role="button"],.ant-btn,[class*="download"],[title*="下載"],[aria-label*="下載"]').forEach(add);
let p = nativeBtn.parentElement;
for (let i = 0; i < 3 && p; i++) {
add(p);
p.querySelectorAll?.('a,button,[role="button"],.ant-btn,[class*="download"],[title*="下載"],[aria-label*="下載"]').forEach(add);
p = p.parentElement;
}
return targets.filter((el) => {
const txt = cleanText(el.textContent || '').toLowerCase();
if (txt.includes('下載') || txt.includes('下载') || txt.includes('download')) return true;
const title = String(el.getAttribute?.('title') || '').toLowerCase();
const aria = String(el.getAttribute?.('aria-label') || '').toLowerCase();
return title.includes('下載') || title.includes('下载') || aria.includes('下載') || aria.includes('下载');
});
}
function dispatchSyntheticClick(el) {
if (!el) return;
try { el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, view: window })); } catch (e) {}
try { el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); } catch (e) {}
try { el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); } catch (e) {}
try { el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); } catch (e) {}
try { if (typeof el.click === 'function') el.click(); } catch (e) {}
}
function startMTeamLinkMutationWatcher(reason = 'auto', windowMs = 9000) {
const tid = getCurrentMTeamTid();
if (!tid) return;
const state = mpMTeamMutationWatcherState[tid];
if (state?.active) return;
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'attributes') continue;
const node = m.target;
if (!node || typeof node.getAttribute !== 'function') continue;
const vals = getDownloadLikeAttrsFromElement(node);
for (const raw of vals) {
const parsed = extractLinkFromString(raw);
if (parsed && isLikelyTorrentDownloadUrl(parsed) && isAllowedMTeamDownloadHost(parsed)) {
setMTeamRuntimeDownloadLink(parsed, `mutation:${reason}:${m.attributeName || 'attr'}`);
return;
}
}
}
});
mpMTeamMutationWatcherState[tid] = { active: true, observer };
observer.observe(document.documentElement || document.body, {
subtree: true,
attributes: true,
attributeFilter: ['href', 'data-url', 'data-href', 'onclick', 'title', 'aria-label']
});
setTimeout(() => {
try { observer.disconnect(); } catch (e) {}
mpMTeamMutationWatcherState[tid] = { active: false };
}, windowMs);
}
function autoPrefetchMTeamDownloadLink(reason = 'auto', options = {}) {
if (!isMTeamDetailPage()) return;
const tid = getCurrentMTeamTid();
if (!tid) return;
if (getMTeamRuntimeDownloadLink()) return;
const force = Boolean(options.force);
const cooldownMs = Number(options.cooldownMs || 30000);
const lastTs = mpMTeamAutoPrefetchState[tid] || 0;
if (!force && Date.now() - lastTs < cooldownMs) return;
const nativeBtn = findMTeamNativeDownloadButton();
if (!nativeBtn) return;
mpMTeamAutoPrefetchState[tid] = Date.now();
debugLog('m-team auto prefetch start', { tid, reason });
triggerMTeamPrefetchMode(8000);
startMTeamLinkMutationWatcher(reason, 9000);
const targets = getMTeamPrefetchClickTargets(nativeBtn);
debugLog('m-team prefetch click targets', { count: targets.length });
if (!targets.length) {
dispatchSyntheticClick(nativeBtn);
} else {
targets.forEach((el, idx) => {
setTimeout(() => dispatchSyntheticClick(el), idx * 180);
});
}
[300, 900, 1600, 2600, 4000, 5200].forEach((delay) => {
setTimeout(() => {
const perfHit = captureMTeamDownloadLinkFromPerformance(`auto-prefetch+${delay}ms`);
if (perfHit) return;
const late = extractMTeamDownloadLink();
if (late.link) setMTeamRuntimeDownloadLink(late.link, `auto-prefetch-extract+${delay}ms`);
}, delay);
});
}
function captureMTeamDownloadLinkFromPerformance(source) {
const tid = getCurrentMTeamTid();
if (!tid) return '';
try {
const entries = performance.getEntriesByType('resource') || [];
for (let i = entries.length - 1; i >= 0; i--) {
const url = entries[i]?.name || '';
if (!isLikelyTorrentDownloadUrl(url)) continue;
if (!isAllowedMTeamDownloadHost(url)) continue;
if (/[?&]tid=\d+/.test(url) && !new RegExp(`[?&]tid=${tid}(?:&|$)`).test(url)) continue;
setMTeamRuntimeDownloadLink(url, source || 'performance');
return url;
}
} catch (e) {}
return '';
}
function installMTeamNativeDownloadHook() {
if (!isMTeamDetailPage()) return;
installMTeamRuntimeCaptureListener();
installMTeamPageContextHook();
const nativeBtn = findMTeamNativeDownloadButton();
if (!nativeBtn) return;
if (nativeBtn.dataset?.mpHooked === '1') return;
if (nativeBtn.dataset) nativeBtn.dataset.mpHooked = '1';
nativeBtn.addEventListener('click', (ev) => {
try {
const path = ev.composedPath ? ev.composedPath() : [ev.target, nativeBtn];
path.forEach((node) => {
if (!node || typeof node !== 'object') return;
getDownloadLikeAttrsFromElement(node).forEach((raw) => {
const parsed = extractLinkFromString(raw);
if (parsed && isLikelyTorrentDownloadUrl(parsed) && isAllowedMTeamDownloadHost(parsed)) {
setMTeamRuntimeDownloadLink(parsed, 'native-click-path');
}
});
});
} catch (e) {}
[250, 800, 1600, 2600, 4000].forEach((delay) => {
setTimeout(() => {
const perfHit = captureMTeamDownloadLinkFromPerformance(`native-click+${delay}ms`);
if (perfHit) return;
const late = extractMTeamDownloadLink();
if (late.link) setMTeamRuntimeDownloadLink(late.link, `native-click-extract+${delay}ms`);
}, delay);
});
}, true);
}
function extractMTeamDownloadLink() {
const tid = getCurrentMTeamTid();
const candidates = [];
const seen = new Set();
const pushCandidate = (raw, source) => {
if (!raw) return;
let abs = '';
try {
abs = new URL(String(raw).replace(/\\\//g, '/').replace(/&/g, '&'), window.location.origin).href;
} catch (e) {
return;
}
if (!isLikelyTorrentDownloadUrl(abs)) return;
if (!isAllowedMTeamDownloadHost(abs)) return;
if (tid && /[?&]tid=\d+/.test(abs) && !new RegExp(`[?&]tid=${tid}(?:&|$)`).test(abs)) return;
if (!seen.has(abs)) {
seen.add(abs);
candidates.push({ url: abs, source });
}
};
const nativeBtn = findMTeamNativeDownloadButton();
if (nativeBtn) {
const btnLogKey = [
window.location.pathname,
nativeBtn.tagName || '',
nativeBtn.className || '',
cleanText(nativeBtn.textContent || '').slice(0, 80)
].join('|');
if (btnLogKey !== mpLastMTeamBtnLogKey) {
mpLastMTeamBtnLogKey = btnLogKey;
debugLog('m-team native download button', { tag: nativeBtn.tagName, className: nativeBtn.className || '', text: cleanText(nativeBtn.textContent || '').slice(0, 80) });
}
const attrsToCheck = ['href', 'data-url', 'data-href', 'onclick', 'title', 'aria-label'];
attrsToCheck.forEach((attr) => {
const val = nativeBtn.getAttribute?.(attr);
if (val) {
const parsed = extractLinkFromString(val);
if (parsed) pushCandidate(parsed, `native-btn:${attr}`);
}
});
const outerHit = extractLinkFromString(nativeBtn.outerHTML || '');
if (outerHit) pushCandidate(outerHit, 'native-btn:outerHTML');
nativeBtn.querySelectorAll?.('a,button,[data-url],[data-href],[onclick]').forEach((el, idx) => {
attrsToCheck.forEach((attr) => {
const val = el.getAttribute?.(attr);
if (val) {
const parsed = extractLinkFromString(val);
if (parsed) pushCandidate(parsed, `native-desc[${idx}]:${attr}`);
}
});
const txtHit = extractLinkFromString(el.textContent || '');
if (txtHit) pushCandidate(txtHit, `native-desc[${idx}]:text`);
});
const reactKeys = Object.keys(nativeBtn).filter(k => /^__reactProps\$|^__reactFiber\$/i.test(k));
reactKeys.forEach((key) => {
const reactHit = deepFindDownloadLinkInObject(nativeBtn[key]);
if (reactHit) pushCandidate(reactHit, `native-btn:${key}`);
});
} else {
const missKey = `${window.location.pathname}|missing`;
if (mpLastMTeamBtnMissingLogKey !== missKey) {
mpLastMTeamBtnMissingLogKey = missKey;
debugLog('m-team native download button not found');
}
}
const runtimeCached = getMTeamRuntimeDownloadLink();
if (runtimeCached) pushCandidate(runtimeCached, 'runtime-cache');
const selectors = [
'a[href*="/api/rss/dlv2"]',
'a[href*="api.m-team.io/api/rss/dlv2"]',
'a[href*="dlv2"]',
'a[href*="torrent/download"]',
'a[href*="download?tid="]',
'a[href*=".torrent"]',
'[data-url*="dlv2"]',
'[data-href*="dlv2"]',
'[data-url*="download"]',
'[data-href*="download"]',
'[class*="download"]',
'[class*="Download"]',
'button[onclick*="dlv2"]',
'a[onclick*="dlv2"]',
'button[onclick*="download"]',
'a[onclick*="download"]'
];
for (const selector of selectors) {
const nodes = document.querySelectorAll(selector);
for (const node of nodes) {
const href = node.getAttribute('href');
if (href) pushCandidate(href, `${selector}:href`);
const dataUrl = node.getAttribute('data-url') || node.getAttribute('data-href');
if (dataUrl) pushCandidate(dataUrl, `${selector}:data-url`);
const inline = node.getAttribute('onclick') || node.getAttribute('title') || node.getAttribute('aria-label') || node.textContent;
const parsed = extractLinkFromString(inline);
if (parsed) pushCandidate(parsed, `${selector}:inline`);
}
}
try {
const entries = performance.getEntriesByType('resource') || [];
entries.forEach((entry) => {
const name = entry?.name || '';
if (/\/api\/rss\/dlv2\?/i.test(name)) pushCandidate(name, 'performance');
});
} catch (e) {}
document.querySelectorAll('script').forEach((script, idx) => {
const parsed = extractLinkFromString(script.textContent || '');
if (parsed) pushCandidate(parsed, `script[${idx}]`);
});
if (!candidates.length) {
const pageSourceHit = extractLinkFromString(document.documentElement?.innerHTML || '');
if (pageSourceHit) pushCandidate(pageSourceHit, 'document-html');
}
return {
link: candidates[0]?.url || '',
source: candidates[0]?.source || '',
candidates: candidates.slice(0, 8)
};
}
function extractMTeamInfo(rows) {
let candidates = [];
const fields = collectAntFields();
addUnique(candidates, rows[0]?.nextElementSibling?.textContent?.replace(/\.torrent$/i, ''));
fields.forEach(field => {
if (/(标题|標題|名称|名稱|片名|title|name)/i.test(field.key)) {
addUnique(candidates, field.value.replace(/\.torrent$/i, ''));
}
});
[
'.ant-page-header-heading-title',
'.ant-page-header-heading .ant-typography',
'h1',
'h2',
'[class*="title"]',
'[class*="Title"]'
].forEach(selector => {
document.querySelectorAll(selector).forEach(el => addUnique(candidates, el.textContent));
});
let pageTitle = cleanText(document.title).replace(/\s*[-|_]\s*M[\s-]?Team.*$/i, '').trim();
if (pageTitle && !/^m-team$/i.test(pageTitle)) {
addUnique(candidates, pageTitle);
}
let torrent_name = pickBestTitleCandidate(candidates);
let torrent_description = pickAntFieldValue(fields, [/(副标题|副標題|简介|簡介|描述|description|剧情|劇情|small\s*descr)/i]);
const dlResult = extractMTeamDownloadLink();
let download_link = dlResult.link
|| document.querySelector('a[href*="dlv2"], a[href*="download"], a[download], a[href*=".torrent"]')?.href
|| '';
let torrent_size = getSize(pickAntFieldValue(fields, [/(大小|體積|体积|size)/i]));
debugLog('m-team extracted', {
titleCandidates: candidates,
selectedTitle: torrent_name,
descriptionPreview: toPreview(torrent_description || ''),
downloadLink: download_link,
downloadLinkSource: dlResult.source,
downloadLinkCandidates: dlResult.candidates,
size: torrent_size
});
return { torrent_name, download_link, torrent_description, torrent_size };
}
function getLatestDownloadLink(type, currentLink, options = {}) {
if (currentLink) return currentLink;
if (type === 'mteam' || isMTeamDetailPage()) {
if (options.allowPrefetch) {
autoPrefetchMTeamDownloadLink(options.reason || 'late-refresh', {
force: Boolean(options.forcePrefetch),
cooldownMs: options.cooldownMs || 30000
});
}
const dlResult = extractMTeamDownloadLink();
if (dlResult.link) {
debugLog('m-team late download-link refresh', dlResult);
return dlResult.link;
}
}
return '';
}
function waitForMTeamLinkOnDemand(initialLink = '') {
return new Promise((resolve) => {
autoPrefetchMTeamDownloadLink('manual-push-click', { force: true, cooldownMs: 1200 });
let immediate = getLatestDownloadLink('mteam', initialLink, { allowPrefetch: false });
if (immediate) {
resolve(immediate);
return;
}
let attempts = 0;
const maxAttempts = 24;
const intervalMs = 700;
const cleanup = () => {
clearInterval(timer);
document.removeEventListener('mp-mteam-runtime-link-updated', onRuntimeLink, true);
};
const onRuntimeLink = () => {
const got = getLatestDownloadLink('mteam', '', { allowPrefetch: false });
if (got) {
cleanup();
resolve(got);
}
};
document.addEventListener('mp-mteam-runtime-link-updated', onRuntimeLink, true);
const timer = setInterval(() => {
attempts++;
if (attempts === 1 || attempts % 2 === 0) {
autoPrefetchMTeamDownloadLink('manual-push-click', { force: true, cooldownMs: 1200 });
}
const got = getLatestDownloadLink('mteam', '', { allowPrefetch: false });
if (got) {
cleanup();
resolve(got);
return;
}
if (attempts >= maxAttempts) {
cleanup();
resolve('');
}
}, intervalMs);
});
}
// ---------- API 请求逻辑 (基于 API Key 重构) ----------
function recognizeByTitle(title, subtitle) {
return new Promise(r => {
requestWithGuard({
url: `${mpUrl}/api/v1/media/recognize?title=${encodeURIComponent(title)}&subtitle=${encodeURIComponent(subtitle || '')}`,
method: "GET",
headers: getAuthHeaders(),
responseType: "json",
onload: (res) => {
debugLog('recognizeByTitle response', { status: res.status, title, subtitlePreview: toPreview(subtitle || ''), body: toPreview(res.response) });
r(res.status >= 200 && res.status < 300 ? res.response : null);
},
onerror: (err) => {
debugLog('recognizeByTitle network error', { title, subtitlePreview: toPreview(subtitle || ''), err });
r(null);
}
});
});
}
function recognizeById(tmdbId, typeName) {
return new Promise(r => {
requestWithGuard({
url: `${mpUrl}/api/v1/media/tmdb:${tmdbId}?type_name=${encodeURIComponent(typeName)}`,
method: "GET",
headers: getAuthHeaders(),
responseType: "json",
onload: (res) => r(res.status === 200 ? res.response : null),
onerror: () => r(null)
});
});
}
function searchTmdb(query, type = '') {
return new Promise(r => {
const key = (mpTmdbKey || '').trim();
if (!key) {
debugLog('searchTmdb skipped: missing tmdb key');
r([]);
return;
}
let url = `https://api.themoviedb.org/3/search/multi?api_key=${encodeURIComponent(key)}&query=${encodeURIComponent(query)}&language=zh-CN`;
if (type) url = `https://api.themoviedb.org/3/search/${type}?api_key=${encodeURIComponent(key)}&query=${encodeURIComponent(query)}&language=zh-CN`;
requestWithGuard({
url: url, method: "GET", responseType: "json",
onload: (res) => r(res.response?.results || []), onerror: () => r([])
});
});
}
function inferTmdbSearchTypeFromText(text) {
const raw = String(text || '');
if (/\bS\d{1,2}E\d{1,3}\b/i.test(raw)) return 'tv';
if (/\bE\d{1,3}\b/i.test(raw) || /第\s*\d+\s*季/.test(raw)) return 'tv';
return '';
}
function extractYearHintsFromText(text) {
const years = [];
String(text || '').replace(/\b(19|20)\d{2}\b/g, (m) => {
if (!years.includes(m)) years.push(m);
return m;
});
return years;
}
function normalizeForMatch(text) {
return String(text || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function scoreTmdbResult(item, query, yearHints, preferType) {
const title = item?.title || item?.name || '';
const nt = normalizeForMatch(title);
const nq = normalizeForMatch(query);
let score = 0;
if (!nt || !nq) return score;
if (nt === nq) score += 12;
else if (nt.includes(nq) || nq.includes(nt)) score += 8;
const qTokens = nq.split(' ').filter(Boolean);
const tTokens = nt.split(' ').filter(Boolean);
const overlap = qTokens.filter(t => tTokens.includes(t)).length;
score += Math.min(overlap, 6);
const mediaType = item?.media_type || '';
if (preferType && mediaType === preferType) score += 2;
const y = String(item?.release_date || item?.first_air_date || '').slice(0, 4);
if (y && yearHints.includes(y)) score += 4;
return score;
}
function autoRecognizeByTmdbFallback(candidates, subtitle) {
return new Promise(async (resolve) => {
try {
const uniqueQueries = [];
const addQuery = (q) => {
const val = String(q || '').trim();
if (val && !uniqueQueries.includes(val)) uniqueQueries.push(val);
};
candidates.forEach(addQuery);
extractSubtitleCandidates(subtitle).forEach(addQuery);
const yearHints = extractYearHintsFromText(`${candidates.join(' ')} ${subtitle || ''}`);
const preferType = inferTmdbSearchTypeFromText(`${candidates.join(' ')} ${subtitle || ''}`);
const topQueries = uniqueQueries.slice(0, 6);
let scored = [];
let dedup = new Set();
for (const q of topQueries) {
const modes = preferType ? [preferType, ''] : [''];
for (const mode of modes) {
const results = await searchTmdb(q, mode);
(results || []).slice(0, 12).forEach((item) => {
const key = `${item?.media_type || mode || ''}:${item?.id || ''}`;
if (!item?.id || dedup.has(key)) return;
dedup.add(key);
const mediaType = item?.media_type || (mode || 'movie');
const effectiveType = mediaType === 'tv' ? 'tv' : 'movie';
const score = scoreTmdbResult({ ...item, media_type: effectiveType }, q, yearHints, preferType);
scored.push({ id: item.id, mediaType: effectiveType, score });
});
}
}
scored.sort((a, b) => b.score - a.score);
const best = scored[0];
debugLog('tmdb fallback scored top', scored.slice(0, 5));
if (!best || best.score < 6) {
resolve(null);
return;
}
const typeName = best.mediaType === 'tv' ? '电视剧' : '电影';
const mediaInfo = await recognizeById(best.id, typeName);
resolve(mediaInfo && mediaInfo.tmdb_id ? mediaInfo : null);
} catch (e) {
debugLog('tmdb fallback failed', e);
resolve(null);
}
});
}
function downloadTorrent(btn, media_info, torrent_name, torrent_description, download_link, torrent_size) {
btn.disabled = true;
btn.textContent = "推送中...";
let toastId = createToast("📡 连接当前 PT 站点", "正在获取站点的 MoviePilot 代理/Cookie 规则...", "info");
requestWithGuard({
url: mpUrl + `/api/v1/site/domain/${window.location.hostname}`,
method: "GET",
headers: getAuthHeaders(),
responseType: "json",
onload: (siteRes) => {
let siteData = siteRes.response || {};
updateToast(toastId, "⏳ 正在下发到下载器", "后端处理解析与防重逻辑需要一定时间,请稍等...", "info");
let torrent_in = {
"site": siteData.id || 0, "site_name": siteData.name || "", "site_cookie": siteData.cookie || "",
"site_ua": navigator.userAgent, "site_proxy": siteData.proxy || null, "site_order": siteData.pri || null,
"title": torrent_name, "description": torrent_description || "", "imdbid": null,
"enclosure": download_link, "page_url": window.location.href, "size": torrent_size || 0,
"seeders": 0, "peers": 0, "grabs": 0, "pubdate": new Date().toISOString().replace(/T/, ' ').replace(/\..+/, ''),
"date_elapsed": null, "uploadvolumefactor": 1, "downloadvolumefactor": 0, "hit_and_run": false,
"labels": [], "pri_order": 0, "volume_factor": "普通"
};
let payload = { media_in: media_info, torrent_in: torrent_in };
requestWithGuard({
method: 'POST',
url: mpUrl + `/api/v1/download/`,
data: JSON.stringify(payload),
headers: getAuthHeaders(),
responseType: 'json',
onload: (res) => {
btn.disabled = false;
if (res.status === 200 && res.response && res.response.success) {
btn.textContent = "下载完成"; btn.style.backgroundColor = "#28a745";
let msg = res.response.message || "任务已成功接管";
updateToast(toastId, "✅ 推送成功", msg, "success");
setTimeout(() => removeToast(toastId), 3000);
} else {
btn.textContent = "下载失败"; btn.style.backgroundColor = "#dc3545";
let errMsg = res.response ? (res.response.message || res.response.msg) : `HTTP ${res.status} 状态异常`;
updateToast(toastId, "❌ 推送被拒绝或执行失败", errMsg, "error");
}
},
onerror: () => {
btn.disabled = false;
btn.textContent = "网络错误"; btn.style.backgroundColor = "#dc3545";
updateToast(toastId, "❌ 网络连接断开", "无法与 MoviePilot 服务建立网络连接", "error");
}
});
},
onerror: () => {
btn.disabled = false;
btn.textContent = "获取站点失败"; btn.style.backgroundColor = "#dc3545";
updateToast(toastId, "❌ 执行中断", "请求站点域配置接口 (/api/v1/site/domain) 失败,请检查配置或网络。", "error");
}
});
}
// ---------- 结果页面 UI 构建 ----------
function buildHtml(row, mediaInfo, type, torrentName, torrentDesc, downloadLink, torrentSize) {
if(!mediaInfo) return '';
let html = '';
const tmdbType = mediaInfo.type === '电视剧' ? 'tv' : 'movie';
const tmdbId = String(mediaInfo.tmdb_id || '').replace(/[^\d]/g, '');
html += mediaInfo.type ? renderTag(type, mediaInfo.type, '#2775b6') : '';
html += mediaInfo.category ? renderTag(type, mediaInfo.category, '#2775b6') : '';
html += mediaInfo.title ? renderTag(type, mediaInfo.title, '#c54640') : '';
html += mediaInfo.year ? renderTag(type, mediaInfo.year, '#e6702e') : '';
html += tmdbId ? `${renderTag(type, 'TMDB:' + tmdbId, '#5bb053')}` : '';
let btnId = "dl-btn-" + Math.floor(Math.random() * 10000);
let reBtnId = "re-btn-" + Math.floor(Math.random() * 10000);
const canPush = Boolean(downloadLink);
const canLazyPush = type === 'mteam';
const buttonText = canPush || canLazyPush ? '下载种子' : '无下载链接';
const buttonStyle = canPush ? '#cdae9c' : '#999999';
if (type === 'common') {
html += ``;
html += ``;
} else if (type === 'mteam') {
html += ``;
html += ``;
} else {
html += ``;
html += ``;
}
setTimeout(() => {
let btn = document.getElementById(btnId);
let reBtn = document.getElementById(reBtnId);
if (btn) {
if (!canPush && !canLazyPush) {
btn.disabled = true;
btn.title = "当前页面未提取到可用下载链接,正在重试...";
} else if (canPush) {
btn.onclick = () => downloadTorrent(btn, mediaInfo, torrentName, torrentDesc, downloadLink, torrentSize);
} else {
btn.onclick = async () => {
if (btn.disabled) return;
btn.disabled = true;
btn.textContent = '正在获取链接...';
btn.style.background = '#2775b6';
btn.title = '';
const fetched = await waitForMTeamLinkOnDemand(downloadLink);
if (fetched) {
btn.textContent = '获取成功,正在推送...';
btn.style.background = '#5bb053';
downloadTorrent(btn, mediaInfo, torrentName, torrentDesc, fetched, torrentSize);
} else {
btn.disabled = false;
btn.textContent = '获取失败,请刷新重试';
btn.style.background = '#dc3545';
btn.title = '获取失败,请刷新网页重试';
setTimeout(() => {
if (btn.textContent === '获取失败,请刷新重试') {
btn.textContent = '下载种子';
btn.style.background = '#cdae9c';
}
}, 2200);
}
};
}
}
if (reBtn) {
reBtn.onclick = () => openManualModal(row, type, torrentName, torrentDesc, downloadLink, torrentSize);
}
}, 100);
return html;
}
function openManualModal(row, type, torrentName, torrentDesc, downloadLink, torrentSize) {
const candidates = getRecognitionCandidates(torrentName, torrentDesc);
const defaultQuery = candidates[1] || candidates[0] || '';
const modal = document.createElement('div');
modal.className = 'mp-modal';
modal.innerHTML = `
`;
document.body.appendChild(modal);
modal.style.display = 'block';
modal.querySelector('.mp-close').onclick = () => modal.remove();
const doSearch = (stype) => {
if (!mpTmdbKey) {
alert("请先在脚本配置中填写 TMDB API Key。");
return;
}
const query = modal.querySelector('#mp-media-name').value.trim();
if (!query) return;
searchTmdb(query, stype).then(results => {
const list = modal.querySelector('#res-list');
modal.querySelector('#res-container').style.display = 'block';
list.innerHTML = results.length ? '' : '未找到相关结果
';
results.slice(0, 10).forEach(item => {
const title = item.title || item.name;
const mType = item.media_type || (stype === 'tv' ? 'tv' : 'movie');
const mpTypeName = mType === 'tv' ? '电视剧' : '电影';
const safeTitle = escapeHtml(title || '');
const safeDate = escapeHtml(item.release_date || item.first_air_date || 'N/A');
const safeId = escapeHtml(String(item.id || ''));
const div = document.createElement('div');
div.className = 'mp-result-item';
div.innerHTML = `
${mpTypeName}${safeTitle}
年份: ${safeDate} | ID: ${safeId}
`;
div.onclick = () => {
row.innerHTML = renderMoviepilotTag(type, "正在精确匹配...");
modal.remove();
recognizeById(item.id, mpTypeName).then(mediaInfo => {
if (mediaInfo && mediaInfo.tmdb_id) {
const latestLink = getLatestDownloadLink(type, downloadLink, { allowPrefetch: type === 'mteam', reason: 'manual-recognize' });
let h = buildHtml(row, mediaInfo, type, torrentName, torrentDesc, latestLink, torrentSize);
row.innerHTML = renderMoviepilotTag(type, h);
} else {
row.innerHTML = renderMoviepilotTag(type, `后端拉取信息失败,该条目可能未收录`);
}
});
};
list.appendChild(div);
});
});
};
modal.querySelector('#mp-search-all').onclick = () => doSearch('');
modal.querySelector('#mp-search-tv').onclick = () => doSearch('tv');
modal.querySelector('#mp-search-movie').onclick = () => doSearch('movie');
}
// ---------- 页面 DOM 解析核心 ----------
function insertMpRow(){
if (!shouldProcessCurrentPage()) return;
if(document.getElementById('mp-row-flag')) return;
let rows = document.querySelectorAll('.rowhead, .ant-descriptions-item-label');
let divs = document.getElementsByClassName('font-bold leading-6');
let type = 'common';
if (isMTeamDetailPage()) {
try {
if (!isMTeamPageDataReady()) return;
installMTeamNativeDownloadHook();
autoPrefetchMTeamDownloadLink('initial', { force: false });
const info = extractMTeamInfo(rows);
if (!info.torrent_name || /^m-team$/i.test(String(info.torrent_name).trim())) return;
const row = createMTeamMountNode();
if (!row) return;
processRecognition(row, 'mteam', info.torrent_name, info.torrent_description, info.download_link, info.torrent_size);
return;
} catch(e) { console.error("MoviePilot Error:", e); }
}
if (rows.length) {
let torrent_name = '', download_link = '', torrent_description = '', torrent_size = 0;
try {
if (window.location.href.includes('hdsky')) {
torrent_name = rows[0].nextElementSibling?.firstElementChild?.firstElementChild?.value || rows[0].nextElementSibling?.innerText;
download_link = rows[1].nextElementSibling?.firstElementChild?.href;
torrent_description = rows[2].nextElementSibling?.innerText || "";
torrent_size = getSize(rows[3].nextElementSibling?.innerText || "");
} else if (isQingwaLikeLayout()) {
torrent_name = extractQingwaTitle()
|| rows[0].nextElementSibling?.firstElementChild?.text
|| rows[0].nextElementSibling?.innerText;
download_link = document.querySelector('a[href*="download.php?id="], a[href*="download.php"], a[href*="download"], a[href*=".torrent"]')?.href
|| rows[0].nextElementSibling?.firstElementChild?.href;
torrent_description = rows[1].nextElementSibling?.innerText
|| cleanText(document.querySelector('#titleEditBox input[type="text"]')?.value || '')
|| "";
torrent_size = getSize(rows[2].nextElementSibling?.innerText || "");
debugLog('qingwa-like extracted', { host: window.location.hostname, title: torrent_name, downloadLink: download_link, subtitlePreview: toPreview(torrent_description) });
} else if (window.location.href.includes('totheglory')) {
torrent_name = rows[0].nextElementSibling?.firstElementChild?.nextElementSibling?.text || rows[0].nextElementSibling?.innerText;
let tds = document.getElementsByClassName('heading');
download_link = tds[0]?.nextElementSibling?.firstElementChild?.href;
torrent_size = getSize(tds[5]?.nextElementSibling?.innerText || "");
} else {
torrent_name = rows[0].nextElementSibling?.firstElementChild?.text || rows[0].nextElementSibling?.innerText;
download_link = rows[0].nextElementSibling?.firstElementChild?.href;
torrent_description = rows[1].nextElementSibling?.innerText || "";
torrent_size = getSize(rows[2].nextElementSibling?.innerText || "");
}
if (!torrent_name) return;
let table = rows[0].closest('table') || rows[0].parentNode.parentNode.parentNode;
if (!table) return;
let row = table.insertRow(2);
row.id = 'mp-row-flag';
processRecognition(row, type, torrent_name, torrent_description, download_link, torrent_size);
} catch(e) { console.error("MoviePilot Error:", e); }
} else if (divs.length) {
try {
let torrent_index_div = document.querySelector('a.index');
if(!torrent_index_div) return;
let torrent_name = torrent_index_div.textContent;
let torrent_description = divs[3].innerText;
let download_link = torrent_index_div.href;
let torrent_size = getSize(divs[5].nextElementSibling.innerText);
if (torrent_name) {
divs[3].insertAdjacentHTML('afterend', 'moviepilot
');
let row = document.getElementById("moviepilot");
processRecognition(row, '', torrent_name, torrent_description, download_link, torrent_size);
}
} catch(e) { console.error("MoviePilot Error:", e); }
}
}
function processRecognition(row, type, t_name, t_desc, d_link, t_size) {
row.innerHTML = renderMoviepilotTag(type, "正在连接 MoviePilot 接口...");
debugLog('processRecognition start', { page: window.location.href, rawTitle: t_name, subtitlePreview: toPreview(t_desc || '') });
const candidates = getRecognitionCandidates(t_name, t_desc);
debugLog('recognition candidates', candidates);
const tryRecognize = (index) => {
if (index >= candidates.length) {
debugLog('recognition failed after all candidates', candidates);
row.innerHTML = renderMoviepilotTag(type, "自动识别失败,尝试 TMDB 智能匹配...");
autoRecognizeByTmdbFallback(candidates, t_desc).then((mediaInfo) => {
if (mediaInfo && mediaInfo.tmdb_id) {
const latestLink = getLatestDownloadLink(type, d_link, { allowPrefetch: type === 'mteam', reason: 'tmdb-fallback' });
let html = buildHtml(row, mediaInfo, type, t_name, t_desc, latestLink, t_size);
row.innerHTML = renderMoviepilotTag(type, html);
} else {
row.innerHTML = renderMoviepilotTag(type, '');
row.querySelector("#manual-recognize-btn").onclick = () => openManualModal(row, type, t_name, t_desc, d_link, t_size);
}
});
return;
}
row.innerHTML = renderMoviepilotTag(
type,
index === 0 ? "正在自动识别种子名称..." : `自动识别重试中 (${index + 1}/${candidates.length})...`
);
let currentTitle = candidates[index];
let currentSubtitle = index === 0 ? t_desc : '';
debugLog('recognize attempt', { attempt: `${index + 1}/${candidates.length}`, title: currentTitle, withSubtitle: index === 0 });
recognizeByTitle(currentTitle, currentSubtitle).then(data => {
if (data && data.media_info) {
debugLog('recognize success', { title: currentTitle, media: toPreview(data.media_info) });
const latestLink = getLatestDownloadLink(type, d_link, { allowPrefetch: type === 'mteam', reason: 'recognize-success' });
let html = buildHtml(row, data.media_info, type, t_name, t_desc, latestLink, t_size);
row.innerHTML = renderMoviepilotTag(type, html);
} else {
debugLog('recognize miss', { title: currentTitle });
tryRecognize(index + 1);
}
});
};
tryRecognize(0);
}
// ---------- 运行守护 ----------
(function () {
'use strict';
let configChecked = false;
let lastUrl = window.location.href;
setInterval(() => {
if (window.location.href !== lastUrl) {
debugLog('route changed', { from: lastUrl, to: window.location.href });
lastUrl = window.location.href;
cleanupMpUi();
pruneMTeamCaches();
}
if (!shouldProcessCurrentPage()) {
if (document.getElementById('mp-row-flag') || document.querySelector('.mp-modal') || document.getElementById('mp-toast-container')) {
debugLog('cleanup on unsupported page', window.location.href);
cleanupMpUi();
}
pruneMTeamCaches();
return;
}
// 1. 检查配置是否填写,改为检查 url 和 API key
if (!mpUrl || !mpApiKey) {
if (!configChecked && !document.getElementById('mp-config-modal')) {
showConfigModal();
configChecked = true;
}
return; // 未配置前不执行注入逻辑
}
// 2. 检查页面是否需要注入
if(!document.getElementById('mp-row-flag')) {
insertMpRow();
}
pruneMTeamCaches();
}, 1500);
})();