Greasy Fork is available in English.
115.com OpenList直链发送到aria2 RPC
当前为
// ==UserScript==
// @name 115Aria
// @namespace http://tampermonkey.net/
// @version 0.4
// @description 115.com OpenList直链发送到aria2 RPC
// @author jiemo
// @match *://115.com/*
// @match *://*.115.com/*
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect *
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const STORE_PREFIX = 'aria115_';
const SETTINGS_KEY = 'aria115_settings_v1';
const DEFAULT_OPENLIST_HOST = 'https://abc.com';
const DEFAULT_OPENLIST_MOUNT_PATH = '115';
const BREADCRUMB_CLASS = 'flex items-center text-xs overflow-x-auto gap-3 flex-wrap pr-6';
const DEFAULT_DIR = '/Users/Administrator/Downloads';
const CONTAINER_ID = 'aria115-container';
const MODAL_ID = 'aria115-settings-modal';
const STYLE_ID = 'aria115-style';
const FOLDER_SCAN_LIMIT = 5000;
const DEFAULT_SETTINGS = {
version: 1,
openlistHost: DEFAULT_OPENLIST_HOST,
openlistMountPath: DEFAULT_OPENLIST_MOUNT_PATH,
activeRpcId: 'local-win',
activePath: DEFAULT_DIR,
rpcConfigs: [
{
id: 'local-win',
name: '本地Win',
endpoint: 'http://127.0.0.1:6800',
token: '',
path: '/jsonrpc'
},
{
id: 'remote-linux',
name: '远程Linux',
endpoint: 'https://your-linux-server.example.com:443',
token: '',
path: '/jsonrpc'
}
],
downloadPaths: [
DEFAULT_DIR,
'/Users/Administrator/Desktop',
'/root/downloads'
]
};
const BAD_NAME_TEXT = new Set([
'下载', '分享', '删除', '移动', '复制', '重命名', '更多', '选择', '全选', '文件名', '大小', '时间', '拖拽移动', '置顶',
'download', 'share', 'delete', 'move', 'copy', 'rename', 'more', 'select'
]);
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function normalizePath(value) {
return String(value || '').trim();
}
function stripSlashes(value) {
return String(value || '').replace(/^\/+|\/+$/g, '');
}
function parseOpenlistHost(value) {
const raw = normalizePath(value);
if (!raw) return null;
const urlText = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
try {
const url = new URL(urlText);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return url.origin;
} catch (err) {
return null;
}
}
function normalizeOpenlistHost(value) {
return parseOpenlistHost(value) || DEFAULT_OPENLIST_HOST;
}
function normalizeOpenlistMountPath(value) {
const raw = stripSlashes(normalizePath(value) || DEFAULT_OPENLIST_MOUNT_PATH).replace(/^d\//i, '');
return raw || DEFAULT_OPENLIST_MOUNT_PATH;
}
function safeDecode(value) {
try {
return decodeURIComponent(value);
} catch (err) {
return value;
}
}
function buildOlistPrefix(settings) {
const source = settings || DEFAULT_SETTINGS;
const host = normalizeOpenlistHost(source.openlistHost);
const mountPath = normalizeOpenlistMountPath(source.openlistMountPath);
return `${host}/d/${encodePath(mountPath)}/`;
}
function parseStoredValue(raw, fallbackValue) {
if (raw === undefined || raw === null || raw === '') return fallbackValue;
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch (err) {
return raw;
}
}
function readStorage(key, fallbackValue) {
try {
if (typeof GM_getValue === 'function') {
const gmValue = GM_getValue(key);
if (gmValue !== undefined && gmValue !== null && gmValue !== '') {
return parseStoredValue(gmValue, fallbackValue);
}
}
} catch (err) {
console.warn('[115Aria] GM_getValue failed:', err);
}
try {
const prefixedValue = localStorage.getItem(STORE_PREFIX + key);
if (prefixedValue !== undefined && prefixedValue !== null && prefixedValue !== '') {
return parseStoredValue(prefixedValue, fallbackValue);
}
return fallbackValue;
} catch (err) {
console.warn('[115Aria] localStorage read failed:', err);
return fallbackValue;
}
}
function writeStorage(key, value) {
const data = JSON.stringify(value);
try {
if (typeof GM_setValue === 'function') {
GM_setValue(key, data);
return true;
}
} catch (err) {
console.warn('[115Aria] GM_setValue failed:', err);
}
try {
localStorage.setItem(STORE_PREFIX + key, data);
return true;
} catch (err) {
console.warn('[115Aria] localStorage write failed:', err);
return false;
}
}
function makeId(prefix) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
function splitEndpoint(endpoint) {
const raw = String(endpoint || '').trim();
if (!raw) return null;
try {
const url = new URL(raw);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
return {
domain: `${url.protocol}//${url.hostname}`,
port: String(url.port || (url.protocol === 'https:' ? '443' : '80')),
endpoint: `${url.protocol}//${url.hostname}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`
};
} catch (err) {
return null;
}
}
function sanitizeRpcConfig(input, index) {
const source = input && typeof input === 'object' ? input : {};
const parsed = splitEndpoint(source.endpoint || `${source.domain || ''}${source.port ? `:${source.port}` : ''}`);
const fallback = DEFAULT_SETTINGS.rpcConfigs[index] || DEFAULT_SETTINGS.rpcConfigs[0];
return {
id: String(source.id || fallback.id || makeId('rpc')),
name: String(source.name || fallback.name || `RPC-${index + 1}`).trim(),
endpoint: parsed ? parsed.endpoint : fallback.endpoint,
token: String(source.token || ''),
path: normalizePath(source.path || fallback.path || '/jsonrpc') || '/jsonrpc'
};
}
function sanitizeSettings(rawInput) {
const source = rawInput && typeof rawInput === 'object' ? rawInput : {};
let rpcConfigs = Array.isArray(source.rpcConfigs) ? source.rpcConfigs : DEFAULT_SETTINGS.rpcConfigs;
rpcConfigs = rpcConfigs.map(sanitizeRpcConfig).filter((item) => splitEndpoint(item.endpoint));
if (rpcConfigs.length === 0) rpcConfigs = clone(DEFAULT_SETTINGS.rpcConfigs);
let downloadPaths = Array.isArray(source.downloadPaths) ? source.downloadPaths : DEFAULT_SETTINGS.downloadPaths;
downloadPaths = downloadPaths.map(normalizePath).filter(Boolean);
downloadPaths = Array.from(new Set(downloadPaths));
if (downloadPaths.length === 0) downloadPaths = clone(DEFAULT_SETTINGS.downloadPaths);
const activeRpcId = rpcConfigs.some((item) => item.id === source.activeRpcId) ? source.activeRpcId : rpcConfigs[0].id;
const activePath = downloadPaths.includes(source.activePath) ? source.activePath : downloadPaths[0];
return {
version: 1,
openlistHost: normalizeOpenlistHost(source.openlistHost),
openlistMountPath: normalizeOpenlistMountPath(source.openlistMountPath),
activeRpcId,
activePath,
rpcConfigs,
downloadPaths
};
}
function loadSettings() {
const settings = sanitizeSettings(readStorage(SETTINGS_KEY));
saveSettings(settings);
return settings;
}
function saveSettings(settings) {
writeStorage(SETTINGS_KEY, settings);
}
function getActiveRpc(settings) {
return settings.rpcConfigs.find((item) => item.id === settings.activeRpcId) || settings.rpcConfigs[0];
}
function buildRpcUrl(rpc) {
const endpoint = splitEndpoint(rpc.endpoint);
if (!endpoint) throw new Error('RPC服务器未配置。');
const url = new URL(endpoint.endpoint);
url.pathname = normalizePath(rpc.path || '/jsonrpc') || '/jsonrpc';
url.search = '';
url.hash = '';
return url.toString();
}
function parseResponseJson(response) {
if (response.response && typeof response.response === 'object') return response.response;
const text = response.responseText || '';
return text ? JSON.parse(text) : null;
}
function gmRequestJson(options) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== 'function') {
reject(new Error('GM_xmlhttpRequest不可用。'));
return;
}
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
headers: options.headers || {},
data: options.data,
responseType: 'json',
timeout: options.timeout || 30000,
onload(response) {
if (response.status >= 400) {
reject(new Error(`HTTP ${response.status}: ${response.responseText || response.statusText}`));
return;
}
try {
resolve(parseResponseJson(response));
} catch (err) {
reject(err);
}
},
onerror() {
reject(new Error('网络请求失败。'));
},
ontimeout() {
reject(new Error('网络请求超时。'));
}
});
});
}
async function postJson(url, payload) {
const data = JSON.stringify(payload);
const headers = { 'Content-Type': 'application/json' };
if (typeof GM_xmlhttpRequest === 'function') {
return gmRequestJson({ method: 'POST', url, headers, data });
}
const response = await fetch(url, { method: 'POST', headers, body: data });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
async function getJson(url) {
try {
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
if (typeof GM_xmlhttpRequest !== 'function') throw err;
return gmRequestJson({ method: 'GET', url });
}
}
async function sendToAria2(rpc, dir, url) {
const params = [];
const token = normalizePath(rpc.token);
if (token) params.push(`token:${token}`);
params.push([url]);
params.push(dir ? { dir } : {});
const result = await postJson(buildRpcUrl(rpc), {
jsonrpc: '2.0',
id: `aria115-${Date.now()}-${Math.random().toString(16).slice(2)}`,
method: 'aria2.addUri',
params
});
if (result && result.error) {
const message = result.error.message || JSON.stringify(result.error);
if (/Unauthorized/i.test(message)) {
throw new Error('RPC认证失败,请在“设置”里填写正确的 aria2 token。');
}
throw new Error(message);
}
return result ? result.result : null;
}
async function testAria2(rpc) {
const params = [];
const token = normalizePath(rpc.token);
if (token) params.push(`token:${token}`);
const result = await postJson(buildRpcUrl(rpc), {
jsonrpc: '2.0',
id: `aria115-test-${Date.now()}-${Math.random().toString(16).slice(2)}`,
method: 'aria2.getVersion',
params
});
if (result && result.error) {
const message = result.error.message || JSON.stringify(result.error);
if (/Unauthorized/i.test(message)) {
throw new Error('RPC认证失败,请检查 aria2 token。');
}
throw new Error(message);
}
return result && result.result ? result.result.version || 'OK' : 'OK';
}
function encodePath(path) {
return stripSlashes(path)
.split('/')
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join('/');
}
function buildOlistUrl(filePath, source) {
const prefix = buildOlistPrefix(source || DEFAULT_SETTINGS);
return prefix + encodePath(filePath);
}
function getUrlParam(name) {
const sources = [location.search, location.hash, location.href];
const matcher = new RegExp(`[?&#]${name}=([^&#]+)`);
for (const source of sources) {
const match = String(source || '').match(matcher);
if (match) return safeDecode(match[1]);
}
return '';
}
function getCurrentCid() {
return getUrlParam('cid') || '0';
}
function buildFilesApiUrl(cid, offset, limit) {
const url = new URL('/api/proxy/115', location.origin);
const params = new URLSearchParams({
domain: 'webapi',
path: '/files',
aid: '1',
cid: cid || '0',
offset: String(offset || 0),
limit: String(limit || 1150),
type: '0',
show_dir: '1',
fc_mix: '0',
natsort: '1',
count_folders: '1',
record_open_time: '1',
format: 'json',
o: 'user_ptime',
asc: '0'
});
url.search = params.toString();
return url.toString();
}
function getApiItems(payload) {
if (!payload || typeof payload !== 'object') return [];
if (Array.isArray(payload.data)) return payload.data;
if (payload.data && Array.isArray(payload.data.list)) return payload.data.list;
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data;
if (payload.data && Array.isArray(payload.data.files)) return payload.data.files;
if (Array.isArray(payload.list)) return payload.list;
if (Array.isArray(payload.files)) return payload.files;
if (Array.isArray(payload.items)) return payload.items;
return [];
}
function getApiItemName(item) {
if (!item || typeof item !== 'object') return '';
return normalizeText(item.n || item.name || item.file_name || item.filename || item.title || '');
}
function getApiItemCid(item) {
if (!item || typeof item !== 'object') return '';
const value = item.cid || item.folder_id || item.category_id || item.id;
return value === undefined || value === null ? '' : String(value);
}
function isApiFolder(item) {
if (!item || typeof item !== 'object') return false;
if (item.fid || item.file_id || item.pick_code || item.pickcode || item.pc) return false;
if (String(item.is_dir || item.isdir || item.isFolder || item.fc || '') === '1') return true;
if (String(item.type || '').toLowerCase() === 'dir') return true;
if (String(item.type || '').toLowerCase() === 'folder') return true;
if (item.cid || item.folder_id || item.category_id) return true;
return false;
}
async function fetchFolderItems(cid) {
const result = [];
const limit = 1150;
let offset = 0;
while (true) {
const payload = await getJson(buildFilesApiUrl(cid, offset, limit));
const items = getApiItems(payload);
result.push(...items);
if (items.length < limit) break;
offset += items.length;
if (offset > FOLDER_SCAN_LIMIT) break;
}
return result;
}
async function collectFilesFromFolder(folderCid, folderParts, output) {
if (output.length >= FOLDER_SCAN_LIMIT) return;
const items = await fetchFolderItems(folderCid);
for (const item of items) {
if (output.length >= FOLDER_SCAN_LIMIT) return;
const name = getApiItemName(item);
if (!name) continue;
if (isApiFolder(item)) {
const childCid = getApiItemCid(item);
if (childCid) await collectFilesFromFolder(childCid, folderParts.concat(name), output);
} else {
output.push(folderParts.concat(name).map(stripSlashes).filter(Boolean).join('/'));
}
}
}
function escapeCssIdent(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
return String(value).replace(/([^a-zA-Z0-9_-])/g, '\\$1');
}
function exactClassSelector(className) {
return className.split(/\s+/).filter(Boolean).map((item) => `.${escapeCssIdent(item)}`).join('');
}
function cleanBreadcrumbPart(value) {
return normalizeText(value)
.replace(/^根目录\s*/, '')
.replace(/^[/\\>›»]+|[/\\>›»]+$/g, '')
.trim();
}
function get115FolderParts() {
const selector = `div${exactClassSelector(BREADCRUMB_CLASS)}`;
const nodes = Array.from(document.querySelectorAll(selector));
const target = nodes.find((node) => Array.from(node.querySelectorAll('button')).some((button) => normalizeText(button.getAttribute('title') || button.textContent) === '根目录'))
|| nodes.find((node) => normalizeText(node.innerText || node.textContent).includes('根目录'))
|| nodes[0];
if (!target) return [];
const buttonParts = Array.from(target.querySelectorAll('button'))
.map((node) => cleanBreadcrumbPart(node.getAttribute('title') || node.innerText || node.textContent))
.filter((text) => text && text !== '根目录');
if (buttonParts.length > 0) return buttonParts;
return normalizeText(target.innerText || target.textContent)
.split(/[\n/>›»]+/)
.map(cleanBreadcrumbPart)
.filter((text) => text && text !== '根目录');
}
function isUsefulName(value) {
const name = normalizeText(value);
if (!name || BAD_NAME_TEXT.has(name.toLowerCase())) return false;
if (/^\d+(\.\d+)?\s*(B|KB|MB|GB|TB)$/i.test(name)) return false;
if (/^\d{4}[-/]\d{1,2}[-/]\d{1,2}/.test(name)) return false;
return true;
}
function readAttrDeep(element, names) {
for (const name of names) {
const value = element.getAttribute && element.getAttribute(name);
if (value && isUsefulName(value)) return normalizeText(value);
}
const selector = names.map((name) => `[${name}]`).join(',');
const child = selector ? element.querySelector(selector) : null;
if (!child) return '';
for (const name of names) {
const value = child.getAttribute(name);
if (value && isUsefulName(value)) return normalizeText(value);
}
return '';
}
function readNameFromElement(element) {
const attrName = readAttrDeep(element, ['data-name', 'data-filename', 'data-file-name', 'file_name', 'filename']);
if (attrName) return attrName;
const nameNode = element.querySelector('.file-name-responsive[title], [class*="file-name"][title], [class*="filename"][title], [class*="name"][title], [class*="Name"][title]');
if (nameNode) {
const value = normalizeText(nameNode.getAttribute('title') || nameNode.innerText || nameNode.textContent);
if (isUsefulName(value)) return value;
}
const imgNode = element.querySelector('img[title], img[alt]');
if (imgNode) {
const value = normalizeText(imgNode.getAttribute('title') || imgNode.getAttribute('alt'));
if (isUsefulName(value)) return value;
}
const titleName = readAttrDeep(element, ['title', 'aria-label']);
if (titleName) return titleName;
return '';
}
function isFolderElement(element) {
const text = normalizeText(element.innerText || element.textContent);
if (text.includes('文件夹')) return true;
const icon = element.querySelector('img[src*="folder"], img[src*="dir"], img[alt*="文件夹"], img[title*="文件夹"], i[class*="folder"], i[class*="Folder"], i[class*="dir"], i[class*="Dir"]');
return Boolean(icon);
}
function hasFileLikeExtension(name) {
const cleanName = stripSlashes(name).split('/').pop() || '';
return /\.[a-z0-9]{1,10}$/i.test(cleanName);
}
function normalizeSelectedElement(element) {
return element.closest('[data-fid], [data-file-id], [data-pickcode], [data-pick-code], [data-id], tr, li, [role="row"], [class*="file"], [class*="File"], [class*="row"], [class*="Row"], [class*="item"], [class*="Item"]') || element;
}
function getSelectedEntriesFromDom() {
const selected = new Set();
const selectors = [
'tr.selected',
'tr[aria-selected="true"]',
'li.selected',
'li[aria-selected="true"]',
'[role="row"].selected',
'[role="row"][aria-selected="true"]',
'.file-item.selected',
'.file-item[aria-selected="true"]',
'.list-item.selected',
'.list-item[aria-selected="true"]',
'[data-fid].selected',
'[data-file-id].selected',
'[data-pickcode].selected',
'[data-pick-code].selected',
'.file-list-item.bg-blue-100',
'.file-list-item .bg-blue-100',
'[class*="selected"][data-fid]',
'[class*="selected"][data-file-id]',
'[class*="selected"][data-id]'
];
selectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((item) => selected.add(normalizeSelectedElement(item)));
});
document.querySelectorAll('input[type="checkbox"]:checked').forEach((item) => {
selected.add(normalizeSelectedElement(item));
});
return Array.from(selected)
.map((element) => ({
element,
name: readNameFromElement(element),
isFolder: isFolderElement(element)
}))
.filter((item) => isUsefulName(item.name));
}
function getSelectedFileNamesFromDom() {
return getSelectedEntriesFromDom().map((item) => item.name);
}
function buildRelativePath(folderParts, fileName) {
const name = stripSlashes(fileName);
if (!name) return '';
if (name.includes('/')) return name;
return folderParts.concat(name).map(stripSlashes).filter(Boolean).join('/');
}
function dedupe(list) {
return Array.from(new Set(list.filter(Boolean)));
}
function promptManualPaths(folderParts) {
const folderPath = folderParts.join('/') || '/';
const input = window.prompt(`未能识别115选中文件。\n当前目录: ${folderPath}\n请输入115文件路径或当前目录下文件名,每行一个:`, '');
if (!input) return [];
return input
.split(/\r?\n/)
.map(normalizeText)
.filter(Boolean)
.map((line) => buildRelativePath(folderParts, line));
}
function getCurrentFolderPath() {
return get115FolderParts().join('/') || '根目录';
}
function collectDetectedFilePaths() {
const folderParts = get115FolderParts();
const fileNames = getSelectedEntriesFromDom()
.filter((item) => !item.isFolder)
.map((item) => item.name);
return dedupe(fileNames.map((name) => buildRelativePath(folderParts, name)));
}
function matchApiItemByName(name, items, preferredFolder) {
const targetName = normalizeText(name);
const sameName = items.filter((item) => normalizeText(getApiItemName(item)) === targetName);
if (sameName.length === 0) return null;
if (preferredFolder) return sameName.find(isApiFolder) || sameName[0];
return sameName.find((item) => !isApiFolder(item)) || sameName[0];
}
async function collectSelectedFilePaths() {
const folderParts = get115FolderParts();
const selected = getSelectedEntriesFromDom();
if (selected.length === 0) {
return dedupe(promptManualPaths(folderParts));
}
let currentItems = [];
let apiError = null;
try {
currentItems = await fetchFolderItems(getCurrentCid());
} catch (err) {
apiError = err;
}
const output = [];
for (const selectedItem of selected) {
const matched = currentItems.length > 0 ? matchApiItemByName(selectedItem.name, currentItems, selectedItem.isFolder) : null;
if (matched && isApiFolder(matched)) {
const folderCid = getApiItemCid(matched);
if (!folderCid) throw new Error(`文件夹 ${selectedItem.name} 未取到 cid,无法递归。`);
await collectFilesFromFolder(folderCid, folderParts.concat(selectedItem.name), output);
continue;
}
if ((selectedItem.isFolder || !hasFileLikeExtension(selectedItem.name)) && !matched) {
throw new Error(`文件夹 ${selectedItem.name} 未能从115目录接口匹配到 cid,无法递归。${apiError ? ` ${apiError.message || apiError}` : ''}`);
}
output.push(buildRelativePath(folderParts, selectedItem.name));
}
return dedupe(output);
}
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
#${CONTAINER_ID} {
position: fixed;
top: 76px;
right: 22px;
z-index: 999998;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(760px, calc(100vw - 44px));
padding: 10px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(250, 253, 255, 0.96), rgba(236, 245, 255, 0.92));
border: 1px solid rgba(39, 119, 248, 0.22);
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(14px);
color: #172033;
font-size: 13px;
}
.aria115-top {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.aria115-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 180px;
font-weight: 800;
color: #1d4ed8;
}
.aria115-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 10px;
color: #fff;
background: linear-gradient(135deg, #2777f8, #74b8ff);
box-shadow: 0 8px 18px rgba(39, 119, 248, 0.28);
font-weight: 900;
}
.aria115-status {
flex: 1;
min-width: 0;
color: #50627c;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
}
.aria115-controls {
width: 100%;
display: grid;
grid-template-columns: auto minmax(120px, 1fr) minmax(180px, 1.4fr) auto auto auto;
gap: 8px;
align-items: center;
}
.aria115-select {
height: 34px;
min-width: 0;
border: 1px solid #c7d6e8;
border-radius: 10px;
padding: 0 10px;
background: #fff;
color: #172033;
outline: none;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.aria115-select:focus {
border-color: #2777f8;
box-shadow: 0 0 0 3px rgba(39, 119, 248, 0.12);
}
.aria115-btn {
height: 34px;
border: 1px solid #c7d6e8;
border-radius: 10px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.94);
color: #24415f;
cursor: pointer;
font-size: 13px;
transition: all .16s ease;
white-space: nowrap;
}
.aria115-btn:hover { border-color: #2777f8; color: #0d67e6; transform: translateY(-1px); }
.aria115-primary {
background: linear-gradient(135deg, #2777f8, #0d67e6);
border-color: #2777f8;
color: #fff;
box-shadow: 0 8px 18px rgba(39, 119, 248, 0.24);
}
.aria115-primary:hover { border-color: #0d67e6; color: #fff; }
.aria115-primary:disabled { cursor: wait; opacity: 0.72; }
#${MODAL_ID} {
position: fixed;
inset: 0;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.45);
}
.aria115-panel {
width: 980px;
max-width: 96vw;
max-height: 92vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 18px;
border: 1px solid #d4e2f4;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18);
color: #213547;
overflow: hidden;
}
.aria115-head, .aria115-footer { padding: 16px 18px; background: #f9fbff; }
.aria115-head { border-bottom: 1px solid #e6edf6; }
.aria115-head h3 { margin: 0; font-size: 18px; color: #17324d; }
.aria115-head p { margin: 6px 0 0; color: #6b7f96; font-size: 12px; }
.aria115-body { padding: 16px 18px; display: grid; gap: 14px; overflow: auto; }
.aria115-section { border: 1px solid #e3edf8; border-radius: 14px; background: #fbfdff; padding: 12px; }
.aria115-section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.aria115-label { font-weight: 800; font-size: 14px; color: #27486b; }
.aria115-tip { margin-top: 4px; color: #6b7280; font-size: 12px; }
.aria115-input {
width: 100%;
height: 34px;
box-sizing: border-box;
border: 1px solid #c7d6e8;
border-radius: 10px;
padding: 0 10px;
outline: none;
background: #fff;
color: #1f2937;
font-size: 13px;
}
.aria115-input:focus { border-color: #2777f8; box-shadow: 0 0 0 3px rgba(39, 119, 248, 0.12); }
.aria115-rpc-row, .aria115-path-row {
display: grid;
gap: 8px;
margin-top: 8px;
padding: 8px;
border: 1px solid #e6edf6;
border-radius: 12px;
background: #fff;
}
.aria115-olist-row { display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 10px; }
.aria115-rpc-row { grid-template-columns: 120px minmax(210px, 1.4fr) minmax(120px, 1fr) 100px auto; }
.aria115-path-row { grid-template-columns: 1fr auto; }
.aria115-field-title { margin: 0 0 5px; color: #64748b; font-size: 11px; }
.aria115-error { display: none; padding: 10px; border-radius: 8px; background: #fff4f2; border: 1px solid #ffb3ab; color: #b42318; font-size: 12px; }
.aria115-footer { display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid #e6edf6; }
@media (max-width: 860px) {
#${CONTAINER_ID} { left: 8px; right: 8px; top: 64px; width: auto; }
.aria115-top { align-items: flex-start; flex-direction: column; }
.aria115-status { width: 100%; text-align: left; }
.aria115-controls { grid-template-columns: 1fr 1fr; }
.aria115-olist-row, .aria115-rpc-row, .aria115-path-row { grid-template-columns: 1fr; }
}
`;
document.head.appendChild(style);
}
function createButton(text, className) {
const button = document.createElement('button');
button.type = 'button';
button.className = `aria115-btn ${className || ''}`.trim();
button.textContent = text;
return button;
}
function createInput(value, placeholder, type) {
const input = document.createElement('input');
input.className = 'aria115-input';
input.type = type || 'text';
input.value = value || '';
input.placeholder = placeholder || '';
if (input.type === 'password') input.autocomplete = 'new-password';
return input;
}
function createField(title, input) {
const wrap = document.createElement('label');
const label = document.createElement('div');
label.className = 'aria115-field-title';
label.textContent = title;
wrap.appendChild(label);
wrap.appendChild(input);
return wrap;
}
function renderSelectOptions(select, values, selectedValue, getText) {
select.innerHTML = '';
values.forEach((item) => {
const option = document.createElement('option');
option.value = typeof item === 'string' ? item : item.id;
option.text = getText ? getText(item) : option.value;
select.appendChild(option);
});
select.value = selectedValue;
}
function openSettingsModal(settings, onSave) {
const old = document.getElementById(MODAL_ID);
if (old) old.remove();
let draft = clone(settings);
const overlay = document.createElement('div');
overlay.id = MODAL_ID;
const panel = document.createElement('div');
panel.className = 'aria115-panel';
const head = document.createElement('div');
head.className = 'aria115-head';
head.innerHTML = '<h3>115Aria 设置</h3>';
const body = document.createElement('div');
body.className = 'aria115-body';
const olistSection = document.createElement('div');
olistSection.className = 'aria115-section';
const olistHead = document.createElement('div');
olistHead.className = 'aria115-section-head';
const olistLabel = document.createElement('div');
olistLabel.className = 'aria115-label';
olistLabel.textContent = 'OpenList 直链配置';
olistHead.appendChild(olistLabel);
const olistRow = document.createElement('div');
olistRow.className = 'aria115-olist-row';
const openlistHostInput = createInput(draft.openlistHost || DEFAULT_OPENLIST_HOST, '');
const openlistMountInput = createInput(draft.openlistMountPath || DEFAULT_OPENLIST_MOUNT_PATH, '例如 115');
const olistTip = document.createElement('div');
olistTip.className = 'aria115-tip';
olistTip.textContent = '需openlist关闭签名。填写openlist主机,填写挂载路径 如:媒体/115。';
olistRow.appendChild(createField('OpenList 主机', openlistHostInput));
olistRow.appendChild(createField('115 挂载路径', openlistMountInput));
olistSection.appendChild(olistHead);
olistSection.appendChild(olistRow);
olistSection.appendChild(olistTip);
const rpcSection = document.createElement('div');
rpcSection.className = 'aria115-section';
const rpcHead = document.createElement('div');
rpcHead.className = 'aria115-section-head';
const rpcLabel = document.createElement('div');
rpcLabel.className = 'aria115-label';
rpcLabel.textContent = 'RPC 服务器';
const rpcAdd = createButton('+ 新增RPC');
const rpcWrap = document.createElement('div');
rpcHead.appendChild(rpcLabel);
rpcHead.appendChild(rpcAdd);
rpcSection.appendChild(rpcHead);
rpcSection.appendChild(rpcWrap);
const pathSection = document.createElement('div');
pathSection.className = 'aria115-section';
const pathHead = document.createElement('div');
pathHead.className = 'aria115-section-head';
const pathLabel = document.createElement('div');
pathLabel.className = 'aria115-label';
pathLabel.textContent = '下载路径';
const pathAdd = createButton('+ 新增路径');
const pathWrap = document.createElement('div');
pathHead.appendChild(pathLabel);
pathHead.appendChild(pathAdd);
pathSection.appendChild(pathHead);
pathSection.appendChild(pathWrap);
const errorBox = document.createElement('div');
errorBox.className = 'aria115-error';
const footer = document.createElement('div');
footer.className = 'aria115-footer';
const reset = createButton('恢复默认');
const cancel = createButton('取消');
const save = createButton('保存', 'aria115-primary');
body.appendChild(olistSection);
body.appendChild(rpcSection);
body.appendChild(pathSection);
body.appendChild(errorBox);
footer.appendChild(reset);
footer.appendChild(cancel);
footer.appendChild(save);
panel.appendChild(head);
panel.appendChild(body);
panel.appendChild(footer);
overlay.appendChild(panel);
document.body.appendChild(overlay);
function showError(message) {
errorBox.textContent = message;
errorBox.style.display = 'block';
}
function renderRpcRows() {
rpcWrap.innerHTML = '';
draft.rpcConfigs.forEach((rpc, index) => {
const row = document.createElement('div');
row.className = 'aria115-rpc-row';
const name = createInput(rpc.name, '名称');
const endpoint = createInput(rpc.endpoint, 'http://127.0.0.1:6800');
const token = createInput('', rpc.token ? '已保存,留空不修改' : 'Token 可留空', 'password');
const rpcPath = createInput(rpc.path || '/jsonrpc', '/jsonrpc');
const del = createButton('删除');
name.oninput = () => { draft.rpcConfigs[index].name = name.value; };
endpoint.oninput = () => { draft.rpcConfigs[index].endpoint = endpoint.value; };
token.oninput = () => {
draft.rpcConfigs[index]._tokenEdited = true;
draft.rpcConfigs[index]._tokenValue = token.value;
};
rpcPath.oninput = () => { draft.rpcConfigs[index].path = rpcPath.value; };
del.onclick = () => {
const removed = draft.rpcConfigs[index];
draft.rpcConfigs.splice(index, 1);
if (removed && removed.id === draft.activeRpcId) {
draft.activeRpcId = draft.rpcConfigs[0] ? draft.rpcConfigs[0].id : '';
}
renderRpcRows();
};
row.appendChild(createField('名称', name));
row.appendChild(createField('地址', endpoint));
row.appendChild(createField('Token', token));
row.appendChild(createField('路径', rpcPath));
row.appendChild(del);
rpcWrap.appendChild(row);
});
}
function renderPathRows() {
pathWrap.innerHTML = '';
draft.downloadPaths.forEach((path, index) => {
const row = document.createElement('div');
row.className = 'aria115-path-row';
const input = createInput(path, '/Users/Administrator/Downloads');
const del = createButton('删除');
input.oninput = () => { draft.downloadPaths[index] = input.value; };
del.onclick = () => {
draft.downloadPaths.splice(index, 1);
if (!draft.downloadPaths.includes(draft.activePath)) {
draft.activePath = draft.downloadPaths[0] || '';
}
renderPathRows();
};
row.appendChild(input);
row.appendChild(del);
pathWrap.appendChild(row);
});
}
reset.onclick = () => {
draft = clone(DEFAULT_SETTINGS);
openlistHostInput.value = draft.openlistHost;
openlistMountInput.value = draft.openlistMountPath;
errorBox.style.display = 'none';
renderRpcRows();
renderPathRows();
};
cancel.onclick = () => overlay.remove();
overlay.addEventListener('click', (event) => {
if (event.target === overlay) overlay.remove();
});
save.onclick = () => {
const errors = [];
draft.openlistHost = normalizeOpenlistHost(openlistHostInput.value);
draft.openlistMountPath = normalizeOpenlistMountPath(openlistMountInput.value);
draft.rpcConfigs = draft.rpcConfigs.map((rpc, index) => ({
id: rpc.id || makeId(`rpc${index}`),
name: normalizeText(rpc.name) || `RPC-${index + 1}`,
endpoint: normalizePath(rpc.endpoint),
token: rpc._tokenEdited ? String(rpc._tokenValue || '') : String(rpc.token || ''),
path: normalizePath(rpc.path || '/jsonrpc') || '/jsonrpc'
}));
draft.downloadPaths = Array.from(new Set(draft.downloadPaths.map(normalizePath).filter(Boolean)));
if (!parseOpenlistHost(openlistHostInput.value)) {
errors.push('OpenList 主机必须是 http/https 地址。');
}
if (!normalizePath(openlistMountInput.value)) {
errors.push('115 挂载路径不能为空。');
}
if (draft.rpcConfigs.length === 0) {
errors.push('至少需要 1 个 RPC 配置。');
}
draft.rpcConfigs.forEach((rpc, index) => {
const parsed = splitEndpoint(rpc.endpoint);
if (!parsed) errors.push(`RPC #${index + 1} 地址格式错误。`);
else rpc.endpoint = parsed.endpoint;
if (!rpc.path.startsWith('/')) errors.push(`RPC #${index + 1} JSON-RPC 路径需以 / 开头。`);
});
if (draft.downloadPaths.length === 0) {
errors.push('至少需要 1 个下载路径。');
}
if (errors.length > 0) {
showError(errors.join(' '));
return;
}
if (!draft.rpcConfigs.some((rpc) => rpc.id === draft.activeRpcId)) {
draft.activeRpcId = draft.rpcConfigs[0].id;
}
if (!draft.downloadPaths.includes(draft.activePath)) {
draft.activePath = draft.downloadPaths[0];
}
onSave(sanitizeSettings(draft));
overlay.remove();
};
rpcAdd.onclick = () => {
draft.rpcConfigs.push({
id: makeId('rpc'),
name: '新RPC',
endpoint: 'http://127.0.0.1:6800',
token: '',
path: '/jsonrpc'
});
renderRpcRows();
};
pathAdd.onclick = () => {
draft.downloadPaths.push(DEFAULT_DIR);
renderPathRows();
};
renderRpcRows();
renderPathRows();
}
function mountUI() {
if (!document.body) {
window.setTimeout(mountUI, 200);
return;
}
ensureStyle();
let settings = loadSettings();
let summaryTimer = 0;
let container = document.getElementById(CONTAINER_ID);
if (container) container.remove();
container = document.createElement('div');
container.id = CONTAINER_ID;
const top = document.createElement('div');
top.className = 'aria115-top';
const brand = document.createElement('div');
brand.className = 'aria115-brand';
brand.innerHTML = '<span class="aria115-logo">A2</span><span>115Aria</span>';
const status = document.createElement('div');
status.className = 'aria115-status';
const controls = document.createElement('div');
controls.className = 'aria115-controls';
const settingsButton = createButton('设置');
const rpcSelect = document.createElement('select');
rpcSelect.className = 'aria115-select';
const pathSelect = document.createElement('select');
pathSelect.className = 'aria115-select';
const previewButton = createButton('预览');
const testButton = createButton('测试RPC');
const sendButton = createButton('发送RPC', 'aria115-primary');
function updateSummary() {
const folderPath = getCurrentFolderPath();
const selectedCount = getSelectedEntriesFromDom().length;
const prefix = buildOlistPrefix(settings);
status.textContent = `目录: ${folderPath} · 已选 ${selectedCount} 项 · ${prefix}`;
status.title = status.textContent;
}
function refresh() {
renderSelectOptions(rpcSelect, settings.rpcConfigs, settings.activeRpcId, (item) => item.name || item.endpoint);
renderSelectOptions(pathSelect, settings.downloadPaths, settings.activePath);
saveSettings(settings);
updateSummary();
}
settingsButton.onclick = () => {
openSettingsModal(settings, (next) => {
settings = next;
refresh();
});
};
rpcSelect.onchange = () => {
settings.activeRpcId = rpcSelect.value;
saveSettings(settings);
updateSummary();
};
pathSelect.onchange = () => {
settings.activePath = pathSelect.value;
saveSettings(settings);
updateSummary();
};
previewButton.onclick = async () => {
if (previewButton.disabled) return;
previewButton.disabled = true;
previewButton.textContent = '扫描中...';
try {
const filePaths = await collectSelectedFilePaths();
if (filePaths.length === 0) {
window.alert('[115Aria] 当前没有识别到可发送文件。');
return;
}
const urls = filePaths.map((filePath) => buildOlistUrl(filePath, settings));
window.alert(urls.slice(0, 20).join('\n') + (urls.length > 20 ? `\n... 还有 ${urls.length - 20} 个` : ''));
} catch (err) {
window.alert(`[115Aria] ${err.message || err}`);
} finally {
previewButton.disabled = false;
previewButton.textContent = '预览';
updateSummary();
}
};
testButton.onclick = async () => {
if (testButton.disabled) return;
testButton.disabled = true;
testButton.textContent = '测试中...';
try {
const version = await testAria2(getActiveRpc(settings));
window.alert(`RPC连接正常,aria2 ${version}`);
} catch (err) {
window.alert(`[115Aria] ${err.message || err}`);
} finally {
testButton.disabled = false;
testButton.textContent = '测试RPC';
}
};
sendButton.onclick = async () => {
if (sendButton.disabled) return;
sendButton.disabled = true;
sendButton.textContent = '识别中...';
try {
const filePaths = await collectSelectedFilePaths();
if (filePaths.length === 0) throw new Error('没有可发送的115文件路径。');
const rpc = getActiveRpc(settings);
const errors = [];
let success = 0;
for (let index = 0; index < filePaths.length; index += 1) {
const filePath = filePaths[index];
sendButton.textContent = `发送 ${index + 1}/${filePaths.length}`;
try {
await sendToAria2(rpc, settings.activePath, buildOlistUrl(filePath, settings));
success += 1;
} catch (err) {
errors.push(`${filePath}: ${err.message || err}`);
}
}
if (errors.length > 0) {
window.alert(`已发送 ${success}/${filePaths.length} 个任务,失败 ${errors.length} 个:\n${errors.slice(0, 5).join('\n')}`);
} else {
sendButton.textContent = `已发送${success}个`;
window.setTimeout(() => { sendButton.textContent = '发送RPC'; }, 1600);
}
updateSummary();
} catch (err) {
window.alert(`[115Aria] ${err.message || err}`);
} finally {
sendButton.disabled = false;
if (!/^已发送/.test(sendButton.textContent)) sendButton.textContent = '发送RPC';
}
};
top.appendChild(brand);
top.appendChild(status);
controls.appendChild(settingsButton);
controls.appendChild(rpcSelect);
controls.appendChild(pathSelect);
controls.appendChild(previewButton);
controls.appendChild(testButton);
controls.appendChild(sendButton);
container.appendChild(top);
container.appendChild(controls);
document.body.appendChild(container);
refresh();
summaryTimer = window.setInterval(() => {
if (!document.body.contains(container)) {
window.clearInterval(summaryTimer);
return;
}
updateSummary();
}, 1200);
}
unsafeWindow.Aria115 = {
buildOlistUrl,
collectSelectedFilePaths,
get115FolderParts,
loadSettings,
saveSettings,
sendToAria2
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mountUI, { once: true });
} else {
mountUI();
}
})();