Greasy Fork

Greasy Fork is available in English.

115Aria

115.com OpenList直链发送到aria2 RPC

当前为 2026-04-30 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
    }
})();