// ==UserScript==
// @name 115云盘磁力链接助手-- 天黑了
// @namespace http://tampermonkey.net/
// @version 1.5
// @description 自动捕捉页面磁力链接并保存至115云盘, 可选择已有文件夹保存
// @author 天黑了
// @license MIT
// @match *://*/*
// @connect 115.com
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @grant GM_log
// @grant window.Notification
// @run-at document-end
// @homepage https://github.com/tianheil3/115-magnet-helper
// @supportURL https://github.com/tianheil3/115-magnet-helper/issues
// ==/UserScript==
(function() {
'use strict';
console.log('115云盘磁力链接助手已加载 (v1.5)');
// 调试函数
function debug(msg, ...args) {
console.log(`[115助手] ${msg}`, ...args);
}
// 匹配磁力链接的正则表达式
const magnetRegex = /magnet:\?xt=urn:btih:[a-zA-Z0-9]{32,40}/gi;
// 修改115图标的SVG,使用文字"115"
const icon115 = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16">
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="central"
fill="white" font-family="Arial" font-weight="bold" font-size="10">115</text>
</svg>`;
// 修改按钮样式,移除定位相关的属性
const buttonStyle = `
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background-color: #2777F8;
border-radius: 50%;
cursor: pointer;
margin-left: 5px;
color: white;
font-family: Arial, sans-serif;
font-size: 11px;
font-weight: bold;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.3s ease;
opacity: 0.9;
user-select: none;
vertical-align: middle;
`;
// 存储已创建的按钮
const createdButtons = new Set();
// 创建一个通用的通知函数
function showNotification(title, text, isWarning = false) {
debug('准备显示通知:', { title, text, isWarning });
// 直接使用 alert 显示通知
setTimeout(() => {
window.alert(`${title}\n${text}`);
}, 100);
// 同时尝试使用 GM_notification
try {
GM_notification({
title: title,
text: text,
timeout: isWarning ? 3000 : 5000,
onclick: () => debug('通知被点击了')
});
debug('GM_notification 已调用');
} catch (e) {
debug('GM_notification 调用失败:', e);
}
}
// 解析磁力链接中的 dn 参数
function getDisplayNameFromMagnet(magnetLink) {
try {
const urlParams = new URLSearchParams(magnetLink.substring(magnetLink.indexOf('?') + 1));
const dn = urlParams.get('dn');
if (dn) {
// 解码并清理非法字符
let decodedDn = decodeURIComponent(dn.replace(/\+/g, ' '));
// 移除 Windows 文件名非法字符: \ / : * ? " < > |
decodedDn = decodedDn.replace(/[\\/:*?"<>|]/g, '_');
// 移除控制字符
decodedDn = decodedDn.replace(/[\x00-\x1F\x7F]/g, '');
// 移除首尾空格
decodedDn = decodedDn.trim();
// 避免文件名过长(115 可能有限制,暂定 200)
return decodedDn.substring(0, 200);
}
} catch (e) {
debug('解析 dn 参数失败:', e);
}
return null; // 如果没有 dn 参数或解析失败,返回 null
}
// 获取 115 文件夹列表 (目前只获取根目录下的)
async function get115Folders() {
return new Promise((resolve) => {
debug('开始获取根目录文件夹列表');
// 尝试简化 URL 参数,并减少 limit
const apiUrl = 'https://aps.115.com/natsort/files.php?aid=1&cid=0&offset=0&limit=300&show_dir=1&natsort=1&format=json';
GM_xmlhttpRequest({
method: 'GET',
// 使用 115 Web API 获取文件列表,cid=0 表示根目录
// 参数可能随版本变化,limit 设置大一些以获取更多文件夹
url: apiUrl,
headers: {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Referer': 'https://115.com/',
'User-Agent': window.navigator.userAgent
},
withCredentials: true,
onload: function(response) {
try {
debug('获取文件夹列表 API 响应:', response.responseText.substring(0, 500) + '...'); // 避免日志过长
const result = JSON.parse(response.responseText);
if (result.state) {
// 115 API 返回的数据结构可能变化,这里尝试兼容常见的文件夹判断方式
const folders = result.data
// 主要判断方式:查找具有 cid (文件夹ID) 且 n (名称) 存在的项
// 可能需要结合其他字段,如 ico == 'folder',或检查是否存在 pid (父ID)
// 更可靠的判断:有 cid 和 n,但没有 fid (文件ID) 和 sha1 (文件哈希)
.filter(item => item.cid && item.n && typeof item.fid === 'undefined' && typeof item.sha1 === 'undefined')
.map(item => ({ id: item.cid, name: item.n }));
debug('成功获取文件夹列表:', folders.length, '个');
resolve(folders); // 返回 {id, name} 数组
} else {
// 改进错误日志,包含 errNo
const errorDetail = `errNo: ${result.errNo}, error: "${result.error || ''}", msg: "${result.msg || 'N/A'}"`;
console.error(`获取文件夹列表失败: API返回 state:false, ${errorDetail}`);
resolve([]); // 返回空数组
}
} catch (error) {
console.error('解析文件夹列表响应失败:', error, response.responseText);
resolve([]); // 解析失败返回空数组
}
},
onerror: function(error) {
console.error('获取文件夹列表请求失败:', error);
resolve([]); // 请求失败返回空数组
}
});
});
}
// 显示文件夹选择模态框
async function showFolderSelector(magnetLink, buttonElement) {
// --- 创建模态框基础结构 ---
const modalOverlay = document.createElement('div');
modalOverlay.id = 'magnet-helper-modal-overlay'; // 添加 ID 以便查找和移除
modalOverlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6); z-index: 9999;
display: flex; justify-content: center; align-items: center;
`;
const modalContent = document.createElement('div');
modalContent.style.cssText = `
background-color: white; padding: 25px; border-radius: 8px;
min-width: 300px; max-width: 80%; max-height: 80%;
overflow-y: auto; box-shadow: 0 5px 15px rgba(0,0,0,0.3);
color: #333; font-family: sans-serif; font-size: 14px;
`;
const title = document.createElement('h3');
title.textContent = '选择保存位置';
title.style.cssText = 'margin-top: 0; margin-bottom: 15px; color: #1E5AC8; border-bottom: 1px solid #eee; padding-bottom: 10px;';
modalContent.appendChild(title);
const loadingText = document.createElement('p');
loadingText.textContent = '正在加载文件夹列表...';
modalContent.appendChild(loadingText);
modalOverlay.appendChild(modalContent);
document.body.appendChild(modalOverlay);
// --- 获取并显示文件夹 ---
try {
const folders = await get115Folders();
if (modalContent.contains(loadingText)) {
modalContent.removeChild(loadingText); // 移除加载提示
}
const list = document.createElement('ul');
list.style.cssText = 'list-style: none; padding: 0; margin: 0 0 15px 0; max-height: 300px; overflow-y: auto;';
// 添加 "根目录" 选项
const rootOption = document.createElement('li');
rootOption.textContent = '根目录 (默认)';
rootOption.style.cssText = 'padding: 8px 12px; cursor: pointer; border-radius: 4px; margin-bottom: 5px; background-color: #f0f0f0;';
rootOption.addEventListener('mouseover', () => { rootOption.style.backgroundColor = '#e0e0e0'; });
rootOption.addEventListener('mouseout', () => { rootOption.style.backgroundColor = '#f0f0f0'; });
rootOption.addEventListener('click', () => {
selectFolder(0); // 根目录 ID 为 0
});
list.appendChild(rootOption);
// 添加获取到的文件夹
folders.forEach(folder => {
const item = document.createElement('li');
item.textContent = folder.name;
item.title = folder.name; // 防止名称过长显示不全
item.style.cssText = 'padding: 8px 12px; cursor: pointer; border-radius: 4px; margin-bottom: 5px; background-color: #f9f9f9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;';
item.addEventListener('mouseover', () => { item.style.backgroundColor = '#eee'; });
item.addEventListener('mouseout', () => { item.style.backgroundColor = '#f9f9f9'; });
item.addEventListener('click', () => {
selectFolder(folder.id);
});
list.appendChild(item);
});
modalContent.appendChild(list);
} catch (error) { // 网络或其他错误导致 get115Folders reject
if (modalContent.contains(loadingText)) {
modalContent.removeChild(loadingText);
}
const errorText = document.createElement('p');
errorText.textContent = '加载文件夹列表失败!将尝试保存到根目录。' + (error.message ? `(${error.message})` : '');
errorText.style.color = 'red';
modalContent.appendChild(errorText);
// 自动选择根目录并关闭
setTimeout(() => selectFolder(0), 2500);
}
// --- 添加取消按钮 ---
const cancelButton = document.createElement('button');
cancelButton.textContent = '取消';
cancelButton.style.cssText = `
padding: 8px 15px; background-color: #ccc; color: #333;
border: none; border-radius: 4px; cursor: pointer; float: right;
`;
cancelButton.addEventListener('click', closeAndCancel);
modalContent.appendChild(cancelButton);
// --- 点击遮罩层关闭 ---
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) {
closeAndCancel();
}
});
// --- 关闭模态框的通用函数 ---
function closeModal() {
setTimeout(() => { // Add delay
const existingModal = document.getElementById('magnet-helper-modal-overlay');
if (existingModal && existingModal.parentNode) {
existingModal.parentNode.removeChild(existingModal);
}
}, 100); // Delay of 100ms
}
// --- 选择文件夹并关闭模态框的函数 ---
async function selectFolder(folderId) {
closeModal();
debug(`用户选择文件夹 ID: ${folderId}`);
buttonElement.textContent = '...'; // 再次确认按钮是加载状态
buttonElement.style.backgroundColor = '#ff9800';
// 调用保存函数,并传递按钮元素用于状态恢复
const success = await saveTo115(magnetLink, folderId, buttonElement);
// 状态恢复在 saveTo115 内部处理
}
// --- 关闭模态框并不执行操作 ---
function closeAndCancel() {
closeModal();
debug('用户取消选择');
// 恢复按钮状态
buttonElement.textContent = '115';
buttonElement.style.backgroundColor = '#2777F8';
}
}
// 保存到115云盘
async function saveTo115(magnetLink, targetFolderId = 0, buttonElement = null) {
let success = false;
let isWarning = false;
try {
// 检查登录状态
const checkLogin = () => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://115.com/?ct=offline&ac=space',
headers: {
'Accept': 'application/json',
'Referer': 'https://115.com/',
'User-Agent': window.navigator.userAgent
},
withCredentials: true,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
resolve(data.state);
} catch (error) {
resolve(false);
}
},
onerror: () => resolve(false)
});
});
};
// 获取离线空间和用户ID
const getOfflineSpace = () => {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://115.com/?ct=offline&ac=space',
headers: {
'Accept': 'application/json',
'Referer': 'https://115.com/'
},
withCredentials: true,
onload: function(response) {
try {
const data = JSON.parse(response.responseText);
resolve(data);
} catch (error) {
resolve(null);
}
},
onerror: () => resolve(null)
});
});
};
// 检查登录状态
const isLoggedIn = await checkLogin();
if (!isLoggedIn) {
GM_notification({
text: '请先登录115云盘',
title: '115云盘助手',
timeout: 3000
});
window.open('https://115.com/?ct=login', '_blank');
return false;
}
// 获取离线空间信息
const spaceInfo = await getOfflineSpace();
if (!spaceInfo || !spaceInfo.state) {
debug('获取离线空间信息失败,但仍尝试添加任务');
}
// 添加离线任务,并指定目标文件夹ID (wp_path_id)
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'POST',
url: 'https://115.com/web/lixian/?ct=lixian&ac=add_task_url',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': 'https://115.com/',
'Origin': 'https://115.com',
'User-Agent': window.navigator.userAgent
},
// 在 data 中添加 wp_path_id 参数
data: `url=${encodeURIComponent(magnetLink)}&wp_path_id=${targetFolderId}`,
withCredentials: true,
onload: function(response) {
try {
// 增加详细的调试信息
debug('API响应:', response.responseText);
debug('响应状态:', response.status);
const result = JSON.parse(response.responseText);
debug('解析后的结果:', {
state: result.state,
errtype: result.errtype,
errcode: result.errcode,
errno: result.errno,
error_msg: result.error_msg
});
success = result.state;
isWarning = result.errtype === 'war' || result.errcode === 10008; // 任务已存在算警告
if (success) {
showNotification(
'115云盘助手',
'磁力链接已成功添加到离线下载队列',
true
);
resolve(isWarning); // 失败时,如果是警告也算某种程度的"成功"
} else {
let errorMessage = '添加任务失败';
// 优先使用 error_msg
if (result.error_msg) {
errorMessage = result.error_msg;
debug('使用 error_msg 作为错误信息:', errorMessage);
} else {
const errorCode = result.errcode || result.errno;
debug('使用错误代码:', errorCode);
const errorTypes = {
911: '用户未登录',
10008: '任务已存在',
10009: '任务超出限制',
10004: '空间不足',
10002: '解析失败',
};
if (errorCode && errorTypes[errorCode]) {
errorMessage = errorTypes[errorCode];
debug('从错误类型映射获取错误信息:', errorMessage);
}
}
// 检查是否为警告类型
debug('是否为警告类型:', isWarning, '(errtype:', result.errtype, 'errcode:', result.errcode, ')');
// 显示通知
showNotification(
isWarning ? '115云盘助手 - 提示' : '115云盘助手 - 错误',
errorMessage,
isWarning
);
resolve(isWarning);
}
} catch (error) {
success = false;
console.error('解析响应失败:', error, response.responseText);
GM_notification({
text: '添加任务失败: ' + (error.message || '未知错误'),
title: '115云盘助手',
timeout: 3000
});
resolve(false);
}
},
onerror: function(error) {
success = false;
console.error('请求失败:', error);
GM_notification({
text: '网络请求失败',
title: '115云盘助手',
timeout: 3000
});
resolve(false);
},
// GM_xmlhttpRequest 的 finally 不可靠,在 onload 和 onerror 中处理
onloadend: function() {
// 恢复按钮状态
if (buttonElement) {
debug('恢复按钮状态, success:', success, 'isWarning:', isWarning);
buttonElement.textContent = '115';
// 成功或警告(任务已存在) 都用蓝色,否则用红色
buttonElement.style.backgroundColor = (success || isWarning) ? '#2777F8' : '#f44336';
if (!(success || isWarning)) { // 如果是彻底失败,一段时间后恢复蓝色
setTimeout(() => {
if (buttonElement.style.backgroundColor === 'rgb(244, 67, 54)') { // 检查是否仍是红色
buttonElement.style.backgroundColor = '#2777F8';
}
}, 2000);
}
}
}
});
});
} catch (error) {
success = false;
console.error('保存到115云盘外层失败:', error);
GM_notification({
text: '保存失败:' + error.message,
title: '115云盘助手',
timeout: 3000
});
// 恢复按钮状态 (如果需要)
if (buttonElement) {
buttonElement.textContent = '115';
buttonElement.style.backgroundColor = '#f44336'; // 红色表示错误
setTimeout(() => {
if (buttonElement.style.backgroundColor === 'rgb(244, 67, 54)') {
buttonElement.style.backgroundColor = '#2777F8';
}
}, 2000);
}
return false;
}
}
// 创建磁力链接按钮
function createMagnetButton(magnetLink, element) {
if (createdButtons.has(magnetLink)) return;
debug('创建按钮:', magnetLink);
// 创建一个包装容器
const wrapper = document.createElement('span');
wrapper.style.cssText = `
display: inline-flex;
align-items: center;
white-space: nowrap;
margin: 0 2px;
`;
// 创建按钮 - 改名为 buttonElement
const buttonElement = document.createElement('span');
buttonElement.innerHTML = '115';
buttonElement.style.cssText = buttonStyle;
buttonElement.title = '点击保存到115云盘';
if (element.nodeType === Node.TEXT_NODE) {
// 处理文本节点
const text = element.textContent;
const index = text.indexOf(magnetLink);
if (index !== -1) {
const beforeText = document.createTextNode(text.substring(0, index));
const afterText = document.createTextNode(text.substring(index + magnetLink.length));
const magnetSpan = document.createElement('span');
magnetSpan.textContent = magnetLink;
const parent = element.parentNode;
parent.insertBefore(beforeText, element);
parent.insertBefore(wrapper, element);
wrapper.appendChild(magnetSpan);
wrapper.appendChild(buttonElement);
parent.insertBefore(afterText, element);
parent.removeChild(element);
}
} else {
// 处理元素节点
if (element.tagName === 'A' || element.tagName === 'INPUT') {
element.parentNode.insertBefore(wrapper, element.nextSibling);
wrapper.appendChild(buttonElement);
} else {
element.appendChild(wrapper);
wrapper.appendChild(buttonElement);
}
}
// 添加按钮事件处理 - 直接使用 buttonElement
if (buttonElement) {
// 添加交互效果
buttonElement.addEventListener('mouseenter', () => {
buttonElement.style.transform = 'scale(1.1)';
buttonElement.style.opacity = '1';
});
buttonElement.addEventListener('mouseleave', () => {
buttonElement.style.transform = 'scale(1)';
buttonElement.style.opacity = '0.9';
});
// 点击处理
buttonElement.addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
debug('点击按钮,准备显示文件夹选择器:', magnetLink);
// 改变按钮外观,表示正在处理
buttonElement.textContent = '...';
buttonElement.style.backgroundColor = '#ff9800'; // 橙色表示等待
buttonElement.disabled = true; // 暂时禁用按钮防止重复点击
// 显示文件夹选择器,传递按钮元素以便后续恢复状态
try {
await showFolderSelector(magnetLink, buttonElement);
// 选择器内部会调用 saveTo115 并处理后续状态
} catch (error) {
console.error('显示文件夹选择器时出错:', error);
// 如果选择器本身出错,恢复按钮
buttonElement.textContent = '115';
buttonElement.style.backgroundColor = '#f44336'; // 显示错误
setTimeout(() => {
buttonElement.style.backgroundColor = '#2777F8';
}, 2000);
} finally {
buttonElement.disabled = false; // 无论如何最终都恢复按钮可用性
}
});
}
createdButtons.add(magnetLink);
}
// 查找并处理磁力链接
function findAndProcessMagnetLinks() {
debug('开始查找磁力链接');
// 使用 TreeWalker 遍历所有文本节点
const processedLinks = new Set();
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(node) {
// 过滤掉不可见元素和脚本标签
const parent = node.parentElement;
if (!parent ||
parent.tagName === 'SCRIPT' ||
parent.tagName === 'STYLE' ||
parent.tagName === 'NOSCRIPT' ||
getComputedStyle(parent).display === 'none' ||
getComputedStyle(parent).visibility === 'hidden') {
return NodeFilter.FILTER_REJECT;
}
// 只接受包含磁力链接的文本节点
return node.textContent.includes('magnet:?') ?
NodeFilter.FILTER_ACCEPT :
NodeFilter.FILTER_SKIP;
}
}
);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
// 处理找到的文本节点
textNodes.forEach(node => {
const matches = node.textContent.match(magnetRegex);
if (matches) {
matches.forEach(magnetLink => {
if (!processedLinks.has(magnetLink)) {
// 找到实际包含磁力链接的最小父元素
let targetElement = node;
let parent = node.parentElement;
while (parent && parent !== document.body) {
if (parent.textContent.trim() === node.textContent.trim()) {
targetElement = parent;
parent = parent.parentElement;
} else {
break;
}
}
createMagnetButton(magnetLink, targetElement);
processedLinks.add(magnetLink);
}
});
}
});
// 检查特殊属性(如链接和输入框)
const elements = document.querySelectorAll('a[href], input[value], [data-url], [title], [data-clipboard-text]');
elements.forEach(element => {
const attributes = ['href', 'data-url', 'value', 'title', 'data-clipboard-text'];
for (const attr of attributes) {
const value = element.getAttribute(attr);
if (value) {
const matches = value.match(magnetRegex);
if (matches) {
matches.forEach(magnetLink => {
if (!processedLinks.has(magnetLink)) {
createMagnetButton(magnetLink, element);
processedLinks.add(magnetLink);
}
});
}
}
}
});
}
// 初始化
function init() {
debug('初始化脚本');
findAndProcessMagnetLinks();
// 使用 MutationObserver 监听页面变化
const observer = new MutationObserver(() => {
setTimeout(findAndProcessMagnetLinks, 500);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// 等待页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();