From c64855d4ad4190bdb43b68040e4f6f96a2bf88c9 Mon Sep 17 00:00:00 2001 From: q Date: Sun, 19 Apr 2026 08:43:27 +0800 Subject: [PATCH] feat: improve downloader integration for parsed files --- .../main/java/cn/qaiu/parser/impl/FsTool.java | 61 +- web-front/public/index.html | 2 + web-front/src/components/DirectoryTree.vue | 879 ++++++++++++++++-- web-front/src/components/DownloadDialog.vue | 266 ++++++ web-front/src/utils/downloaderService.js | 463 +++++++++ web-front/src/views/Home.vue | 465 +++++++-- 6 files changed, 1981 insertions(+), 155 deletions(-) create mode 100644 web-front/src/components/DownloadDialog.vue create mode 100644 web-front/src/utils/downloaderService.js diff --git a/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java b/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java index 645cf1d..1f0c09b 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java @@ -3,6 +3,7 @@ package cn.qaiu.parser.impl; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.util.CommonUtils; import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.json.JsonArray; @@ -124,7 +125,10 @@ public class FsTool extends PanBase { if (fileName != null) { FileInfo fileInfo = new FileInfo(); fileInfo.setFileName(fileName); + fileInfo.setFileId(token); + fileInfo.setFileType("file"); fileInfo.setPanType(shareLinkInfo.getType()); + fileInfo.setParserUrl(buildRedirectUrl(shareUrl, token)); parseSizeFromContentRange( probeRes.getHeader("Content-Range"), fileInfo); shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); @@ -309,8 +313,17 @@ public class FsTool extends PanBase { log.warn("无法解析文件大小: {}", extra.getString("size"), e); } - fileInfo.setParserUrl( - buildDownloadUrl(tenant, objToken)); + fileInfo.setParserUrl(buildRedirectUrl( + shareLinkInfo.getShareUrl(), objToken)); + + // 添加下载所需的请求头到extParameters + Map extParams = new HashMap<>(); + Map downloadHeaders = new HashMap<>(); + downloadHeaders.put("Referer", referer); + downloadHeaders.put("User-Agent", UA); + extParams.put("downloadHeaders", downloadHeaders); + fileInfo.setExtParameters(extParams); + items.add(fileInfo); } } @@ -353,7 +366,16 @@ public class FsTool extends PanBase { fileInfo.setFileId(token); fileInfo.setPanType(shareLinkInfo.getType()); fileInfo.setFileType("file"); - fileInfo.setParserUrl(dlUrl); + fileInfo.setParserUrl(buildRedirectUrl(referer, token)); + + // 添加下载所需的请求头到extParameters + Map extParams = new HashMap<>(); + Map downloadHeaders = new HashMap<>(); + downloadHeaders.put("Referer", referer); + downloadHeaders.put("User-Agent", UA); + extParams.put("downloadHeaders", downloadHeaders); + fileInfo.setExtParameters(extParams); + p.complete(fileInfo); }) .onFailure(t -> p.fail("探测文件失败: " + t.getMessage())); @@ -361,8 +383,41 @@ public class FsTool extends PanBase { return p.future(); } + @Override + public Future parseById() { + Promise parsePromise = Promise.promise(); + + try { + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + String shareUrl = paramJson.getString("shareUrl"); + String objToken = paramJson.getString("objToken"); + String tenant = extractTenant(shareUrl); + + if (shareUrl == null || objToken == null || tenant == null) { + parsePromise.fail("飞书目录文件下载参数不完整"); + return parsePromise.future(); + } + + parsePromise.complete(buildDownloadUrl(tenant, objToken)); + } catch (Exception e) { + parsePromise.fail("解析飞书目录文件参数失败: " + e.getMessage()); + } + + return parsePromise.future(); + } + // ─── 工具方法 ──────────────────────────────────────── + private String buildRedirectUrl(String shareUrl, String objToken) { + JsonObject paramJson = new JsonObject() + .put("shareUrl", shareUrl) + .put("objToken", objToken); + return String.format("%s/v2/redirectUrl/%s/%s", + getDomainName(), + shareLinkInfo.getType(), + CommonUtils.urlBase64Encode(paramJson.encode())); + } + private String buildDownloadUrl(String tenant, String objToken) { return "https://" + tenant + ".feishu.cn/space/api/box/stream/download/all/" + objToken; diff --git a/web-front/public/index.html b/web-front/public/index.html index d3e118b..35d7ba4 100644 --- a/web-front/public/index.html +++ b/web-front/public/index.html @@ -11,6 +11,8 @@ content="Netdisk fast download 网盘直链解析工具"> + + \ No newline at end of file + diff --git a/web-front/src/components/DownloadDialog.vue b/web-front/src/components/DownloadDialog.vue new file mode 100644 index 0000000..7873d11 --- /dev/null +++ b/web-front/src/components/DownloadDialog.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/web-front/src/utils/downloaderService.js b/web-front/src/utils/downloaderService.js new file mode 100644 index 0000000..9576544 --- /dev/null +++ b/web-front/src/utils/downloaderService.js @@ -0,0 +1,463 @@ +/** + * 下载器服务 - 统一管理 Aria2/Motrix/Gopeed/迅雷 的配置读取、连接检测、RPC 调用 + * 供 Home.vue、DirectoryTree.vue、DownloadDialog.vue 等共用 + */ +import axios from 'axios' + +const STORAGE_KEY = 'nfd-aria2-local-config' + +const DEFAULT_CONFIG = { + downloaderType: 'aria2', + rpcUrl: 'http://localhost:6800/jsonrpc', + rpcSecret: '', + downloadDir: '' +} + +/** + * 从 localStorage 读取下载器配置 + * @returns {{ downloaderType: string, rpcUrl: string, rpcSecret: string, downloadDir: string }} + */ +export function getConfig() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + const parsed = JSON.parse(raw) + return { + downloaderType: parsed.downloaderType || DEFAULT_CONFIG.downloaderType, + rpcUrl: parsed.rpcUrl || DEFAULT_CONFIG.rpcUrl, + rpcSecret: parsed.rpcSecret || '', + downloadDir: parsed.downloadDir || '' + } + } + } catch (e) { + console.warn('读取下载器配置失败', e) + } + return { ...DEFAULT_CONFIG } +} + +/** + * 保存下载器配置到 localStorage + * @param {{ downloaderType?: string, rpcUrl?: string, rpcSecret?: string, downloadDir?: string }} config + */ +export function saveConfig(config) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) +} + +/** + * 构建 RPC 参数数组(自动添加 token) + * @param {string} rpcSecret + * @param {Array} extraParams + * @returns {Array} + */ +function buildRpcParams(rpcSecret, extraParams = []) { + const params = [] + if (rpcSecret && rpcSecret.trim()) { + params.push(`token:${rpcSecret}`) + } + if (extraParams && extraParams.length > 0) { + params.push(...extraParams) + } + return params +} + +/** + * 调用 Aria2 JSON-RPC 接口 + * @param {string} rpcUrl + * @param {string} rpcSecret + * @param {string} method - 例如 'aria2.getVersion', 'aria2.addUri' + * @param {Array} [extraParams] - 除 token 外的参数 + * @param {number} [timeout=5000] + * @returns {Promise} RPC 响应的 data + */ +export async function callRpc(rpcUrl, rpcSecret, method, extraParams = [], timeout = 5000) { + const requestBody = { + jsonrpc: '2.0', + id: Date.now().toString(), + method, + params: buildRpcParams(rpcSecret, extraParams) + } + const response = await axios.post(rpcUrl, requestBody, { + headers: { 'Content-Type': 'application/json' }, + timeout + }) + if (response.data && response.data.error) { + throw new Error(response.data.error.message || 'Aria2 RPC 错误') + } + return response.data +} + +/** + * 判断 rpcUrl 是否指向 Gopeed(端口 9999 或 URL 含 /api/v1) + * @param {string} url + * @returns {boolean} + */ +function isGopeedUrl(url) { + if (!url) return false + return url.includes(':9999') || url.includes('/api/v1') +} + +/** + * 从 Gopeed rpcUrl 中提取 baseUrl(去掉 /jsonrpc 或 /api/v1 后缀) + * 例如 "http://localhost:9999/jsonrpc" → "http://localhost:9999" + * @param {string} rpcUrl + * @returns {string} + */ +function gopeedBaseUrl(rpcUrl) { + return rpcUrl.replace(/\/jsonrpc$/, '').replace(/\/api\/v1.*$/, '') +} + +/** + * 调用 Gopeed REST API + * @param {string} baseUrl - 例如 "http://localhost:9999" + * @param {string} rpcSecret - Bearer token + * @param {string} method - 'GET' | 'POST' + * @param {string} path - 例如 '/api/v1/version' + * @param {Object} [body] - POST body + * @param {number} [timeout=5000] + * @returns {Promise} 响应 data + */ +async function callGopeedApi(baseUrl, rpcSecret, method, path, body, timeout = 5000) { + const headers = { 'Content-Type': 'application/json' } + if (rpcSecret && rpcSecret.trim()) { + headers['X-Api-Token'] = rpcSecret + } + const url = baseUrl.replace(/\/$/, '') + path + const response = await axios({ method, url, headers, data: body, timeout }) + return response.data +} + +/** + * 测试下载器连接(自动识别 迅雷 / Gopeed / Aria2 / Motrix) + * @param {string} [rpcUrl] - 不传则自动读取配置 + * @param {string} [rpcSecret] - 不传则自动读取配置 + * @returns {Promise<{ connected: boolean, version: string }>} + */ +export async function testConnection(rpcUrl, rpcSecret) { + if (!rpcUrl) { + const config = getConfig() + // 迅雷不需要 RPC,直接检测 JS SDK + if (config.downloaderType === 'thunder') { + const available = typeof window !== 'undefined' && window.thunderLink && typeof window.thunderLink.newTask === 'function' + return { connected: available, version: available ? 'JS-SDK' : '' } + } + rpcUrl = config.rpcUrl + rpcSecret = rpcSecret ?? config.rpcSecret + } + try { + if (isGopeedUrl(rpcUrl)) { + // Gopeed 使用 REST API:GET /api/v1/info + const base = gopeedBaseUrl(rpcUrl) + const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000) + const d = (res && res.code === 0 && res.data) ? res.data : {} + const version = [d.version, d.runtime].filter(Boolean).join(' + ') || '' + return { connected: true, version } + } else { + // Aria2 / Motrix 使用 JSON-RPC + const res = await callRpc(rpcUrl, rpcSecret || '', 'aria2.getVersion', [], 3000) + if (res && res.result && res.result.version) { + return { connected: true, version: res.result.version } + } + return { connected: false, version: '' } + } + } catch { + return { connected: false, version: '' } + } +} + +/** + * 自动检测本地下载器(依次尝试 Motrix/Gopeed/Aria2) + * @param {string} [rpcSecret] - 可选密钥 + * @returns {Promise<{ found: boolean, type: string, rpcUrl: string, version: string }>} + */ +export async function autoDetect(rpcSecret = '') { + const candidates = [ + { type: 'motrix', port: 16800, path: '/jsonrpc' }, + { type: 'gopeed', port: 9999, path: '/api/v1/info', gopeed: true }, + { type: 'aria2', port: 6800, path: '/jsonrpc' } + ] + for (const c of candidates) { + try { + if (c.gopeed) { + // Gopeed:直接调 REST GET /api/v1/info + const base = `http://localhost:${c.port}` + const res = await callGopeedApi(base, rpcSecret || '', 'GET', '/api/v1/info', undefined, 3000) + const d = (res && res.code === 0 && res.data) ? res.data : {} + const version = [d.version, d.runtime].filter(Boolean).join(' + ') || 'unknown' + return { found: true, type: c.type, rpcUrl: `${base}/api/v1`, version } + } else { + const url = `http://localhost:${c.port}${c.path}` + const result = await testConnection(url, rpcSecret) + if (result.connected) { + return { found: true, type: c.type, rpcUrl: url, version: result.version } + } + } + } catch { + // 该端口未响应,继续下一个 + } + } + return { found: false, type: '', rpcUrl: '', version: '' } +} + +/** + * 发送下载任务到下载器(自动识别 迅雷 / Gopeed / Aria2 / Motrix) + * @param {string} downloadUrl - 文件下载地址 + * @param {Object} [headers] - 请求头 {cookie, referer, user-agent, ...} + * @param {string} [fileName] - 输出文件名 + * @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride] - 覆盖配置 + * @returns {Promise} 任务 ID / GID + */ +export async function addDownload(downloadUrl, headers, fileName, configOverride) { + const config = { ...getConfig(), ...configOverride } + + if (config.downloaderType === 'thunder') { + return addThunderDownload([{ url: downloadUrl, headers, fileName }], config) + } + + if (isGopeedUrl(config.rpcUrl)) { + // Gopeed REST API:POST /api/v1/tasks + const base = gopeedBaseUrl(config.rpcUrl) + const extraHeader = {} + if (headers && typeof headers === 'object') { + for (const [key, value] of Object.entries(headers)) { + if (key && value) extraHeader[key] = value + } + } + const body = { + req: { url: downloadUrl, extra: { header: extraHeader } }, + opt: {} + } + if (config.downloadDir) body.opt.path = config.downloadDir + const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks', body, 10000) + // Gopeed 返回 { code: 0, data: "task-id" } + if (res && res.code !== undefined && res.code !== 0) throw new Error(res.message || 'Gopeed 发送失败') + if (res && res.data) return typeof res.data === 'string' ? res.data : JSON.stringify(res.data) + return 'ok' + } + + // Aria2 / Motrix JSON-RPC + const options = {} + if (headers && typeof headers === 'object') { + const headerArray = [] + for (const [key, value] of Object.entries(headers)) { + if (key && value) headerArray.push(`${key}: ${value}`) + } + if (headerArray.length > 0) options.header = headerArray + } + if (fileName) options.out = fileName + if (config.downloadDir) options.dir = config.downloadDir + + const res = await callRpc(config.rpcUrl, config.rpcSecret, 'aria2.addUri', [[downloadUrl], options], 10000) + if (res && res.result) return res.result // GID + throw new Error('未知错误') +} + +/** + * 批量发送下载任务到下载器(aria2 用 system.multicall,gopeed 用 batch API,迅雷用 JS-SDK newTask) + * @param {{ url: string, headers?: Object, fileName?: string }[]} tasks - 下载任务列表 + * @param {{ rpcUrl?: string, rpcSecret?: string, downloadDir?: string, downloaderType?: string }} [configOverride] + * @returns {Promise<{ succeeded: number, failed: number, errors: string[] }>} + */ +export async function batchAddDownload(tasks, configOverride) { + if (!tasks || tasks.length === 0) return { succeeded: 0, failed: 0, errors: [] } + if (tasks.length === 1) { + try { + await addDownload(tasks[0].url, tasks[0].headers, tasks[0].fileName, configOverride) + return { succeeded: 1, failed: 0, errors: [] } + } catch (e) { + return { succeeded: 0, failed: 1, errors: [e.message || '未知错误'] } + } + } + + const config = { ...getConfig(), ...configOverride } + + if (config.downloaderType === 'thunder') { + try { + await addThunderDownload(tasks, config) + return { succeeded: tasks.length, failed: 0, errors: [] } + } catch (e) { + return { succeeded: 0, failed: tasks.length, errors: [e.message || '迅雷下载失败'] } + } + } + + if (isGopeedUrl(config.rpcUrl)) { + return batchAddGopeed(tasks, config) + } else { + return batchAddAria2(tasks, config) + } +} + +async function batchAddAria2(tasks, config) { + const calls = tasks.map(task => { + const options = {} + if (task.headers && typeof task.headers === 'object') { + const headerArray = [] + for (const [key, value] of Object.entries(task.headers)) { + if (key && value) headerArray.push(`${key}: ${value}`) + } + if (headerArray.length > 0) options.header = headerArray + } + if (task.fileName) options.out = task.fileName + if (config.downloadDir) options.dir = config.downloadDir + + const params = [] + if (config.rpcSecret && config.rpcSecret.trim()) { + params.push(`token:${config.rpcSecret}`) + } + params.push([task.url], options) + return { methodName: 'aria2.addUri', params } + }) + + try { + const requestBody = { + jsonrpc: '2.0', + id: Date.now().toString(), + method: 'system.multicall', + params: [calls] + } + const response = await axios.post(config.rpcUrl, requestBody, { + headers: { 'Content-Type': 'application/json' }, + timeout: Math.max(10000, tasks.length * 500) + }) + const results = response.data && response.data.result + if (!Array.isArray(results)) { + throw new Error(response.data?.error?.message || 'system.multicall 返回异常') + } + let succeeded = 0, failed = 0 + const errors = [] + for (let i = 0; i < results.length; i++) { + const r = results[i] + if (Array.isArray(r) && r.length > 0 && typeof r[0] === 'string') { + succeeded++ + } else if (r && r.faultCode) { + failed++ + errors.push(`${tasks[i].fileName || tasks[i].url}: ${r.faultString || '未知错误'}`) + } else { + succeeded++ + } + } + return { succeeded, failed, errors } + } catch (e) { + return { succeeded: 0, failed: tasks.length, errors: [e.message || 'multicall 请求失败'] } + } +} + +async function batchAddGopeed(tasks, config) { + const base = gopeedBaseUrl(config.rpcUrl) + const reqs = tasks.map(task => { + const extraHeader = {} + if (task.headers && typeof task.headers === 'object') { + for (const [key, value] of Object.entries(task.headers)) { + if (key && value) extraHeader[key] = value + } + } + const item = { req: { url: task.url, extra: { header: extraHeader } } } + if (task.fileName) { + item.opts = { name: task.fileName } + } + return item + }) + + const body = { reqs } + if (config.downloadDir) body.opts = { path: config.downloadDir } + + try { + const res = await callGopeedApi(base, config.rpcSecret, 'POST', '/api/v1/tasks/batch', body, + Math.max(10000, tasks.length * 500)) + if (res && res.code !== undefined && res.code !== 0) { + return { succeeded: 0, failed: tasks.length, errors: [res.message || 'Gopeed batch 失败'] } + } + const ids = Array.isArray(res?.data) ? res.data : [] + return { succeeded: ids.length || tasks.length, failed: 0, errors: [] } + } catch (e) { + return { succeeded: 0, failed: tasks.length, errors: [e.message || 'Gopeed batch 请求失败'] } + } +} + +/** + * 通过迅雷 JS-SDK 发送下载任务 + * @param {{ url: string, headers?: Object, fileName?: string }[]} tasks + * @param {{ downloadDir?: string }} config + * @returns {Promise} + */ +function addThunderDownload(tasks, config) { + if (typeof window === 'undefined' || !window.thunderLink || typeof window.thunderLink.newTask !== 'function') { + return Promise.reject(new Error('迅雷客户端未检测到,请确认已安装并启动迅雷')) + } + // 迅雷 JS-SDK 不支持自定义 Cookie,含 Cookie 的下载链接无法通过迅雷下载 + const firstHeaders = (tasks[0] && tasks[0].headers) || {} + if (firstHeaders.cookie || firstHeaders.Cookie) { + return Promise.reject(new Error('该文件需要 Cookie 认证,迅雷不支持自定义 Cookie,请使用 Aria2/Motrix/Gopeed')) + } + + // 遍历所有 header key 大小写不敏感地提取 referer / user-agent + let referer = '' + let userAgent = '' + for (const [key, value] of Object.entries(firstHeaders)) { + const lk = key.toLowerCase() + if (lk === 'referer' && value) referer = value + if (lk === 'user-agent' && value) userAgent = value + } + + const taskParam = { + tasks: tasks.map(t => { + const item = { url: t.url } + if (t.fileName) item.name = t.fileName + return item + }) + } + if (config.downloadDir) taskParam.downloadDir = config.downloadDir + if (referer) taskParam.referer = referer + if (userAgent) taskParam.userAgent = userAgent + taskParam.threadCount = '1' + + console.log('[Thunder SDK] newTask params:', JSON.stringify(taskParam)) + window.thunderLink.newTask(taskParam) + return Promise.resolve('thunder-ok') +} + +/** + * 根据 RPC URL 猜测下载器类型 + * @param {string} url + * @returns {string} + */ +export function guessDownloaderType(url) { + if (!url) return 'aria2' + if (url.includes(':16800')) return 'motrix' + if (url.includes(':9999')) return 'gopeed' + return 'aria2' +} + +/** + * 检查下载头中是否含有 Cookie(迅雷不支持) + * @param {Object} [headers] + * @returns {boolean} + */ +export function hasCookieHeader(headers) { + if (!headers || typeof headers !== 'object') return false + return !!(headers.cookie || headers.Cookie) +} + +/** + * 检查下载头中是否含有自定义 User-Agent(迅雷客户端可能不支持) + * @param {Object} [headers] + * @returns {boolean} + */ +export function hasCustomUaHeader(headers) { + if (!headers || typeof headers !== 'object') return false + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'user-agent' && headers[key]) return true + } + return false +} + +export default { + getConfig, + saveConfig, + callRpc, + testConnection, + autoDetect, + addDownload, + batchAddDownload, + guessDownloaderType, + hasCookieHeader +} diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index d819301..77ab2e0 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -35,27 +35,36 @@ 部署 - -
- - - - +
+ +
+ + + + + + + + + + +
+ +
+ + + {{ aria2Connected ? ('已连接 - ' + downloaderTypeName) : '下载器' }} - - - + +
@@ -107,7 +116,7 @@ 生成Markdown 扫码下载 分享统计 - 生成命令行链接 +

@@ -115,31 +124,92 @@
解析结果: - -
-
- 下载链接: - 点击下载 -
- -
- 文件预览: - 点击预览 -
-
- 文件名:{{ extractFileNameAndExt(downloadUrl).name }} -
-
- 文件类型:{{ getFileTypeClass({ fileName: extractFileNameAndExt(downloadUrl).name }) }} -
-
- 文件大小:{{ parseResult.data.sizeStr }} -
+ +
+ + + + + + +
+ + 大小: {{ parseResult.data.sizeStr }} + + + 短链: {{ parseResult.data.downloadShortUrl }} + +
+ +
+ + + +
+
Aria2 下载命令
+ +
+ + 复制 + +
+
+
+
Aria2 JSON-RPC
+ +
+ + 复制 + +
+
+
+
curl 下载命令
+ +
+ + 复制 + +
+
+
+
+
+
+ +
@@ -334,6 +404,79 @@
+ + + +
+
+ + 下载器类型 +
+ + + + + + + + +
+ 没有下载器? + Motrix / + Gopeed / + 迅雷 +
+
+
+
RPC 地址
+ +
+
+
RPC 密钥 (可选)
+ +
+
+ + {{ aria2ShowAdvanced ? '收起选项 ▲' : '更多选项 ▼' }} + + +
+
下载目录
+ +
+
+
+
+ + + 已连接 - {{ downloaderTypeName }} {{ aria2Version }} + +
+
+ 迅雷通过浏览器唤起本地客户端,无需测试连接 +
+
+ + 测试连接 + + + 自动检测 + +
+
+ +