Compare commits

..

9 Commits

Author SHA1 Message Date
q
b967c7a1bb Merge pull request #177 from qaiu/pr-177
# Conflicts:
#	parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java
#	parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java
2026-04-19 08:51:44 +08:00
q
519dbe1f77 docs: update fs support and dev proxy port 2026-04-19 08:47:01 +08:00
q
c64855d4ad feat: improve downloader integration for parsed files 2026-04-19 08:43:27 +08:00
qaiu
d50d10ba89 Merge pull request #178 from qaiu/copilot/implement-feishu-share-parser-java
feat: 支持飞书云盘分享解析
2026-04-18 16:47:33 +08:00
copilot-swe-agent[bot]
e79478c421 refactor: address code review - extract constants, improve logging
- Extract Pattern constants as static final fields
- Extract PAGE_SIZE constant for API pagination
- Add logging for NumberFormatException in file size parsing

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-18 08:46:06 +00:00
copilot-swe-agent[bot]
c401a84eb8 feat: add Feishu cloud disk share parser (file + folder support)
Add FsTool parser for Feishu (飞书) cloud disk share links.
Supports both file and folder share URL formats:
- File: https://xxx.feishu.cn/file/{token}
- Folder: https://xxx.feishu.cn/drive/folder/{token}

The parser:
- Fetches anonymous session cookies from share page
- Uses Range probe to detect filename and size
- Returns download URL with required headers (Cookie, Referer)
- Supports folder listing via v3 API with pagination
- Updates README with Feishu in supported cloud disk list

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-18 08:43:26 +00:00
copilot-swe-agent[bot]
a9978a6202 Initial plan 2026-04-18 08:36:17 +00:00
copilot-swe-agent[bot]
a45a64380c 优化乐云(LE)正则以支持 /mshare/ 格式,补充测试用例
Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/7341ab49-5648-498c-b153-0fcd3b3f8aad

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-12 11:40:55 +00:00
copilot-swe-agent[bot]
df7442c3dd Initial plan 2026-04-12 11:39:26 +00:00
11 changed files with 2510 additions and 517 deletions

View File

@@ -86,6 +86,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve) - [Cloudreve自建网盘-ce](https://github.com/cloudreve/Cloudreve)
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~ - ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com) - [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
- [飞书云盘-fs](https://www.feishu.cn/)
- [WPS云文档-pwps](https://www.kdocs.cn/) - [WPS云文档-pwps](https://www.kdocs.cn/)
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/) - [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
- [咪咕音乐-migu](https://music.migu.cn/) - [咪咕音乐-migu](https://music.migu.cn/)
@@ -340,6 +341,7 @@ json返回数据格式示例:
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) | | WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 | | 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 | | UC网盘 | x | √ | 10G | 不限大小 |
| 飞书云盘 | √ | X | 15G | 不限大小 |
# 打包部署 # 打包部署

View File

@@ -115,9 +115,9 @@ public enum PanDomainTemplate {
"https://www.feijix.com/s/{shareKey}", "https://www.feijix.com/s/{shareKey}",
FjTool.class), FjTool.class),
// https://lecloud.lenovo.com/share/ // https://lecloud.lenovo.com/share/ https://lecloud.lenovo.com/mshare/
LE("联想乐云", LE("联想乐云",
compile("https://lecloud\\.lenovo\\.com/share/(?<KEY>.+)"), compile("https://lecloud\\.lenovo\\.com/m?share/(?<KEY>.+)"),
"https://lecloud.lenovo.com/share/{shareKey}", "https://lecloud.lenovo.com/share/{shareKey}",
LeTool.class), LeTool.class),
@@ -313,6 +313,14 @@ public enum PanDomainTemplate {
"https://pan.quark.cn/s/{shareKey}", "https://pan.quark.cn/s/{shareKey}",
QkTool.class), QkTool.class),
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
FS("飞书云盘",
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
"https://feishu.cn/file/{shareKey}",
"https://www.feishu.cn/",
FsTool.class),
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)========================== // =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx // http://163cn.tv/xxx
MNES("网易云音乐分享", MNES("网易云音乐分享",

View File

@@ -0,0 +1,492 @@
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;
import io.vertx.core.json.JsonObject;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* <a href="https://www.feishu.cn/">飞书云盘</a>
* <p>
* 支持飞书公开分享文件和文件夹的解析。
* <ul>
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
* </ul>
* 飞书下载需要先获取匿名会话Cookie然后使用Cookie请求下载接口。
* </p>
*/
public class FsTool extends PanBase {
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
/**
* 飞书 obj_type: type=12 表示上传文件可下载
*/
private static final int OBJ_TYPE_FILE = 12;
/**
* v3 列表 API 支持的 obj_type
*/
private static final int[] LIST_OBJ_TYPES = {
0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124
};
/** 每页返回条目数 */
private static final int PAGE_SIZE = 50;
/**
* 从分享链接中提取 tenant 的正则
*/
private static final Pattern TENANT_PATTERN =
Pattern.compile("https://([^.]+)\\.feishu\\.cn/");
/** 解析 Content-Disposition: filename*=UTF-8''xxx */
private static final Pattern CD_FILENAME_STAR_PATTERN =
Pattern.compile("filename\\*=UTF-8''(.+?)(?:;|$)");
/** 解析 Content-Disposition: filename="xxx" 或 filename=xxx */
private static final Pattern CD_FILENAME_PATTERN =
Pattern.compile("filename=\"?([^\";]+)\"?");
/** 解析 Content-Range 中的总大小 */
private static final Pattern CONTENT_RANGE_SIZE_PATTERN =
Pattern.compile("/(\\d+)");
public FsTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
String shareUrl = shareLinkInfo.getShareUrl();
String tenant = extractTenant(shareUrl);
String token = shareLinkInfo.getShareKey();
if (tenant == null || token == null) {
fail("无法从链接中提取tenant或token: {}", shareUrl);
return promise.future();
}
boolean isFolder = shareUrl.contains("/drive/folder/");
if (isFolder) {
fetchSessionAndParseFolder(tenant, token, shareUrl);
} else {
fetchSessionAndParseFile(tenant, token, shareUrl);
}
return promise.future();
}
/**
* 获取匿名session后解析文件
*/
private void fetchSessionAndParseFile(String tenant, String token, String shareUrl) {
clientSession.getAbs(shareUrl)
.putHeader("User-Agent", UA)
.putHeader("Accept", "text/html,*/*")
.send()
.onSuccess(res -> {
String dlUrl = buildDownloadUrl(tenant, token);
// Range探测获取文件名和大小
clientSession.getAbs(dlUrl)
.putHeader("User-Agent", UA)
.putHeader("Referer", shareUrl)
.putHeader("Range", "bytes=0-0")
.send()
.onSuccess(probeRes -> {
String fileName = parseFileNameFromContentDisposition(
probeRes.getHeader("Content-Disposition"));
Map<String, String> headers = new HashMap<>();
headers.put("Referer", shareUrl);
headers.put("User-Agent", UA);
String cookies = extractCookiesFromResponse(probeRes);
if (cookies != null && !cookies.isEmpty()) {
headers.put("Cookie", cookies);
}
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);
}
completeWithMeta(dlUrl, headers);
})
.onFailure(handleFail("探测文件信息失败"));
})
.onFailure(handleFail("获取匿名会话失败"));
}
/**
* 获取匿名session后解析文件夹取第一个可下载文件
*/
private void fetchSessionAndParseFolder(String tenant, String folderToken,
String shareUrl) {
clientSession.getAbs(shareUrl)
.putHeader("User-Agent", UA)
.putHeader("Accept", "text/html,*/*")
.send()
.onSuccess(res ->
listFolderAll(tenant, folderToken, "").onSuccess(items -> {
if (items.isEmpty()) {
fail("文件夹中没有可下载的文件");
return;
}
FileInfo first = items.get(0);
String objToken = first.getFileId();
String dlUrl = buildDownloadUrl(tenant, objToken);
String referer = "https://" + tenant
+ ".feishu.cn/drive/folder/" + folderToken;
Map<String, String> headers = new HashMap<>();
headers.put("Referer", referer);
headers.put("User-Agent", UA);
shareLinkInfo.getOtherParam().put("fileInfo", first);
completeWithMeta(dlUrl, headers);
}).onFailure(t -> fail("列出文件夹内容失败: {}", t.getMessage())))
.onFailure(handleFail("获取匿名会话失败"));
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
String shareUrl = shareLinkInfo.getShareUrl();
String tenant = extractTenant(shareUrl);
String token = shareLinkInfo.getShareKey();
if (tenant == null || token == null) {
listPromise.fail("无法从链接中提取tenant或token: " + shareUrl);
return listPromise.future();
}
boolean isFolder = shareUrl.contains("/drive/folder/");
clientSession.getAbs(shareUrl)
.putHeader("User-Agent", UA)
.putHeader("Accept", "text/html,*/*")
.send()
.onSuccess(res -> {
if (isFolder) {
listFolderAll(tenant, token, "")
.onSuccess(listPromise::complete)
.onFailure(listPromise::fail);
} else {
probeSingleFile(tenant, token, shareUrl)
.onSuccess(fileInfo -> {
List<FileInfo> list = new ArrayList<>();
list.add(fileInfo);
listPromise.complete(list);
})
.onFailure(listPromise::fail);
}
})
.onFailure(t -> listPromise.fail("获取匿名会话失败: " + t.getMessage()));
return listPromise.future();
}
/**
* 分页获取文件夹所有可下载文件
*/
private Future<List<FileInfo>> listFolderAll(String tenant, String folderToken,
String pageLabel) {
Promise<List<FileInfo>> p = Promise.promise();
listFolderPage(tenant, folderToken, pageLabel).onSuccess(pageResult -> {
List<FileInfo> items = new ArrayList<>(pageResult.items);
if (pageResult.hasMore) {
listFolderAll(tenant, folderToken, pageResult.nextLabel)
.onSuccess(moreItems -> {
items.addAll(moreItems);
p.complete(items);
})
.onFailure(p::fail);
} else {
p.complete(items);
}
}).onFailure(p::fail);
return p.future();
}
/**
* 列出文件夹内容(单页)
*/
private Future<FolderPageResult> listFolderPage(String tenant, String folderToken,
String pageLabel) {
Promise<FolderPageResult> p = Promise.promise();
String baseUrl = "https://" + tenant + ".feishu.cn";
StringBuilder urlBuilder = new StringBuilder();
urlBuilder.append(baseUrl)
.append("/space/api/explorer/v3/children/list/")
.append("?length=").append(PAGE_SIZE)
.append("&asc=1&rank=5&token=").append(folderToken);
for (int type : LIST_OBJ_TYPES) {
urlBuilder.append("&obj_type=").append(type);
}
if (pageLabel != null && !pageLabel.isEmpty()) {
urlBuilder.append("&last_label=").append(pageLabel);
}
String url = urlBuilder.toString();
String referer = baseUrl + "/drive/folder/" + folderToken;
clientSession.getAbs(url)
.putHeader("User-Agent", UA)
.putHeader("Accept", "application/json, text/plain, */*")
.putHeader("Referer", referer)
.send()
.onSuccess(res -> {
try {
JsonObject json = asJson(res);
int code = json.getInteger("code", -1);
if (code != 0) {
p.fail("飞书API错误: " + json.getString("msg"));
return;
}
JsonObject data = json.getJsonObject("data");
JsonObject entities = data.getJsonObject("entities",
new JsonObject());
JsonObject nodes = entities.getJsonObject("nodes",
new JsonObject());
JsonArray nodeList = data.getJsonArray("node_list",
new JsonArray());
List<FileInfo> items = new ArrayList<>();
for (int i = 0; i < nodeList.size(); i++) {
String nid = nodeList.getString(i);
JsonObject node = nodes.getJsonObject(nid,
new JsonObject());
int objType = node.getInteger("type", -1);
String objToken = node.getString("obj_token", "");
String name = node.getString("name", "unknown");
// 排除文件夹自身节点
if (objToken.equals(folderToken)) {
continue;
}
// 只返回可下载的文件(type=12)
if (objType == OBJ_TYPE_FILE) {
FileInfo fileInfo = new FileInfo();
fileInfo.setFileName(name);
fileInfo.setFileId(objToken);
fileInfo.setPanType(shareLinkInfo.getType());
fileInfo.setFileType("file");
JsonObject extra = node.getJsonObject("extra",
new JsonObject());
try {
long size = Long.parseLong(
extra.getString("size", "0"));
fileInfo.setSize(size);
} catch (NumberFormatException e) {
log.warn("无法解析文件大小: {}", extra.getString("size"), e);
}
fileInfo.setParserUrl(buildRedirectUrl(
shareLinkInfo.getShareUrl(), objToken));
// 添加下载所需的请求头到extParameters
Map<String, Object> extParams = new HashMap<>();
Map<String, String> downloadHeaders = new HashMap<>();
downloadHeaders.put("Referer", referer);
downloadHeaders.put("User-Agent", UA);
extParams.put("downloadHeaders", downloadHeaders);
fileInfo.setExtParameters(extParams);
items.add(fileInfo);
}
}
boolean hasMore = data.getBoolean("has_more", false);
String nextLabel = data.getString("last_label", "");
p.complete(new FolderPageResult(items, hasMore, nextLabel));
} catch (Exception e) {
p.fail("解析文件列表响应失败: " + e.getMessage());
}
})
.onFailure(t -> p.fail("请求文件列表失败: " + t.getMessage()));
return p.future();
}
/**
* 探测单个文件信息
*/
private Future<FileInfo> probeSingleFile(String tenant, String token,
String referer) {
Promise<FileInfo> p = Promise.promise();
String dlUrl = buildDownloadUrl(tenant, token);
clientSession.getAbs(dlUrl)
.putHeader("User-Agent", UA)
.putHeader("Referer", referer)
.putHeader("Range", "bytes=0-0")
.send()
.onSuccess(probeRes -> {
FileInfo fileInfo = new FileInfo();
String fileName = parseFileNameFromContentDisposition(
probeRes.getHeader("Content-Disposition"));
if (fileName != null) {
fileInfo.setFileName(fileName);
}
parseSizeFromContentRange(
probeRes.getHeader("Content-Range"), fileInfo);
fileInfo.setFileId(token);
fileInfo.setPanType(shareLinkInfo.getType());
fileInfo.setFileType("file");
fileInfo.setParserUrl(buildRedirectUrl(referer, token));
// 添加下载所需的请求头到extParameters
Map<String, Object> extParams = new HashMap<>();
Map<String, String> 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()));
return p.future();
}
@Override
public Future<String> parseById() {
Promise<String> 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;
}
private String extractTenant(String url) {
if (url == null) return null;
Matcher m = TENANT_PATTERN.matcher(url);
if (m.find()) {
return m.group(1);
}
return null;
}
/**
* 从Content-Disposition头解析文件名。
* 支持 filename*=UTF-8''xxx 和 filename="xxx" 两种格式。
*/
private String parseFileNameFromContentDisposition(String cd) {
if (cd == null || cd.isEmpty()) return null;
// 优先解析 filename*=UTF-8''xxx
Matcher m1 = CD_FILENAME_STAR_PATTERN.matcher(cd);
if (m1.find()) {
try {
return URLDecoder.decode(m1.group(1).trim(), StandardCharsets.UTF_8);
} catch (Exception ignored) {
}
}
// 降级解析 filename="xxx" 或 filename=xxx
Matcher m2 = CD_FILENAME_PATTERN.matcher(cd);
if (m2.find()) {
try {
return URLDecoder.decode(m2.group(1).trim(), StandardCharsets.UTF_8);
} catch (Exception ignored) {
}
}
return null;
}
private void parseSizeFromContentRange(String cr, FileInfo fileInfo) {
if (cr != null) {
Matcher m = CONTENT_RANGE_SIZE_PATTERN.matcher(cr);
if (m.find()) {
fileInfo.setSize(Long.parseLong(m.group(1)));
}
}
}
private String extractCookiesFromResponse(
io.vertx.ext.web.client.HttpResponse<?> response) {
List<String> setCookies = response.cookies();
if (setCookies == null || setCookies.isEmpty()) return null;
StringBuilder sb = new StringBuilder();
for (String cookie : setCookies) {
String nameValue = cookie.split(";")[0].trim();
if (!sb.isEmpty()) sb.append("; ");
sb.append(nameValue);
}
return sb.toString();
}
/**
* 文件夹分页结果
*/
private record FolderPageResult(List<FileInfo> items, boolean hasMore,
String nextLabel) {
}
}

View File

@@ -1,355 +0,0 @@
#!/usr/bin/env python3
"""
飞书公开分享 直链解析 + 批量下载 (aria2/Motrix)
支持: 单文件链接 / 文件夹链接(递归子目录)
用法:
python feishu_dl.py <链接> # 推送到 Motrix
python feishu_dl.py <链接> -d D:/Downloads # 指定下载目录
python feishu_dl.py <链接> --list # 仅列出文件,不下载
python feishu_dl.py <链接> --aria2c # 输出 aria2c 命令行
"""
import sys, os, re, json, uuid, ssl, gzip, argparse
import http.cookiejar
import urllib.request, urllib.error
from urllib.parse import unquote, quote
# ─── Motrix aria2 RPC 默认配置 ──────────────────────────
ARIA2_RPC_URL = "http://localhost:16800/jsonrpc"
ARIA2_SECRET = "motrix"
# ────────────────────────────────────────────────────────
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36")
# 飞书 obj_type 映射 (type=12 上传文件可下载, type=0 文件夹可递归)
OBJ_TYPES = {
0: "📁 文件夹", 2: "📝 旧版文档", 3: "📊 表格", 8: "🧠 思维导图",
11: "📽 幻灯片", 12: "📄 文件", 22: "📝 新版文档", 30: "📋 画板",
44: "📊 多维表格", 84: "📑 知识库", 123: "❓ 未知", 124: "❓ 未知",
}
# v3 列表 API 支持的 obj_type
LIST_OBJ_TYPES = [0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124]
# ─── 网络工具 ────────────────────────────────────────────
def _ctx():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def make_opener(jar):
return urllib.request.build_opener(
urllib.request.HTTPSHandler(context=_ctx()),
urllib.request.HTTPCookieProcessor(jar),
)
def decode_body(resp):
data = resp.read()
if resp.headers.get("Content-Encoding") == "gzip":
data = gzip.decompress(data)
return data.decode("utf-8", errors="replace")
def cookie_string(jar):
return "; ".join(f"{c.name}={c.value}" for c in jar)
def human_size(n):
for u in ("B", "KB", "MB", "GB"):
if n < 1024: return f"{n:.1f} {u}"
n /= 1024
return f"{n:.1f} TB"
# ─── 飞书核心 API ────────────────────────────────────────
def parse_share_url(url):
"""返回 (tenant, token, link_type:'file'|'folder')"""
m = re.match(r'https://([^.]+)\.feishu\.cn/file/([A-Za-z0-9_-]+)', url)
if m: return m.group(1), m.group(2), "file"
m = re.match(r'https://([^.]+)\.feishu\.cn/drive/folder/([A-Za-z0-9_-]+)', url)
if m: return m.group(1), m.group(2), "folder"
return None, None, None
def fetch_session(share_url):
"""访问分享页拿匿名 session cookie"""
jar = http.cookiejar.CookieJar()
opener = make_opener(jar)
req = urllib.request.Request(share_url)
req.add_header("User-Agent", UA)
req.add_header("Accept", "text/html,*/*")
opener.open(req, timeout=15).read()
return jar
def list_folder(tenant, folder_token, jar, page_label=""):
"""
v3 API 列出文件夹内容 (单页)
GET /space/api/explorer/v3/children/list/?token=xxx&length=50&...
"""
base = f"https://{tenant}.feishu.cn"
params = ["length=50", "asc=1", "rank=5", f"token={folder_token}"]
for t in LIST_OBJ_TYPES:
params.append(f"obj_type={t}")
if page_label:
params.append(f"last_label={quote(page_label, safe='')}")
url = f"{base}/space/api/explorer/v3/children/list/?{'&'.join(params)}"
opener = make_opener(jar)
req = urllib.request.Request(url)
req.add_header("User-Agent", UA)
req.add_header("Accept", "application/json, text/plain, */*")
req.add_header("Referer", f"{base}/drive/folder/{folder_token}")
resp = opener.open(req, timeout=15)
data = json.loads(decode_body(resp))
if data.get("code") != 0:
raise RuntimeError(f"API error: {data.get('msg')}")
d = data["data"]
nodes = d.get("entities", {}).get("nodes", {})
node_list = d.get("node_list", [])
items = []
for nid in node_list:
node = nodes.get(nid, {})
obj_type = node.get("type", -1)
obj_token = node.get("obj_token", "")
name = node.get("name", "unknown")
extra = node.get("extra", {})
try: size = int(extra.get("size", "0"))
except: size = 0
items.append({
"name": name, "obj_token": obj_token, "type": obj_type,
"size": size, "url": node.get("url", ""),
"is_folder": obj_type == 0,
"type_name": OBJ_TYPES.get(obj_type, f"❓ type={obj_type}"),
})
# 排除文件夹自身节点
items = [it for it in items if it["obj_token"] != folder_token]
return items, d.get("has_more", False), d.get("last_label", "")
def list_folder_all(tenant, folder_token, jar):
"""分页获取文件夹全部内容"""
all_items, label = [], ""
while True:
items, has_more, label = list_folder(tenant, folder_token, jar, label)
all_items.extend(items)
if not has_more: break
return all_items
def walk_folder(tenant, folder_token, jar, prefix="", depth=0):
"""递归遍历, 返回扁平列表 [{..., path:"a/b/file.txt"}]"""
if depth > 10: # 防止无限递归
return []
items = list_folder_all(tenant, folder_token, jar)
result = []
for it in items:
if it["is_folder"]:
sub = walk_folder(tenant, it["obj_token"], jar,
prefix=f"{prefix}{it['name']}/", depth=depth+1)
result.extend(sub)
else:
it["path"] = f"{prefix}{it['name']}"
result.append(it)
return result
def probe_file(tenant, obj_token, jar, referer):
"""Range 探测文件名 + 大小 (只取1字节)"""
dl_url = f"https://{tenant}.feishu.cn/space/api/box/stream/download/all/{obj_token}"
opener = make_opener(jar)
req = urllib.request.Request(dl_url)
req.add_header("User-Agent", UA)
req.add_header("Referer", referer)
req.add_header("Range", "bytes=0-0")
resp = opener.open(req, timeout=15)
cd = resp.headers.get("Content-Disposition", "")
cr = resp.headers.get("Content-Range", "")
resp.read()
filename = ""
m = re.search(r"filename\*=UTF-8''(.+?)(?:;|$)", cd)
if m: filename = unquote(m.group(1).strip())
if not filename:
m = re.search(r'filename="?([^";]+)"?', cd)
if m: filename = unquote(m.group(1).strip())
total = 0
m = re.search(r'/(\d+)', cr)
if m: total = int(m.group(1))
return filename, total
# ─── aria2 RPC ───────────────────────────────────────────
def aria2_add(dl_url, cs, referer, filename, out_dir=None):
opts = {"header": [f"Cookie: {cs}", f"Referer: {referer}", f"User-Agent: {UA}"]}
if filename: opts["out"] = filename
if out_dir: opts["dir"] = out_dir
payload = json.dumps({
"jsonrpc": "2.0", "id": str(uuid.uuid4()),
"method": "aria2.addUri",
"params": [f"token:{ARIA2_SECRET}", [dl_url], opts],
}).encode()
req = urllib.request.Request(ARIA2_RPC_URL, data=payload,
headers={"Content-Type": "application/json"})
resp = urllib.request.urlopen(req, timeout=10)
return json.loads(resp.read().decode()).get("result", "")
def push_one(dl_url, cs, referer, filename, out_dir, quiet=False):
try:
gid = aria2_add(dl_url, cs, referer, filename, out_dir)
if gid:
if not quiet: print(f" [✓] GID={gid} {filename}")
return True
except urllib.error.URLError:
if not quiet:
print(f" [✗] Motrix 未启动, RPC: {ARIA2_RPC_URL}")
except Exception as e:
if not quiet: print(f" [✗] {e}")
return False
def print_aria2c(dl_url, cs, referer, filename, out_dir):
print(f'aria2c --header="Cookie: {cs}" \\')
print(f' --header="Referer: {referer}" \\')
print(f' --header="User-Agent: {UA}" \\')
if filename: print(f' -o "{filename}" \\')
if out_dir: print(f' -d "{out_dir}" \\')
print(f' "{dl_url}"')
# ─── 主流程 ──────────────────────────────────────────────
def handle_file(tenant, token, jar, args):
share_url = f"https://{tenant}.feishu.cn/file/{token}"
dl_url = f"https://{tenant}.feishu.cn/space/api/box/stream/download/all/{token}"
cs = cookie_string(jar)
print(f"[2/3] 探测文件 ...")
filename, size = probe_file(tenant, token, jar, share_url)
print(f" {filename} ({human_size(size)})")
if args.list: return
if args.aria2c:
print_aria2c(dl_url, cs, share_url, filename, args.dir); return
print(f"[3/3] 推送到 Motrix ...")
if not push_one(dl_url, cs, share_url, filename, args.dir):
print(f"\n 降级输出 aria2c 命令:\n")
print_aria2c(dl_url, cs, share_url, filename, args.dir)
def handle_folder(tenant, token, jar, args):
base = f"https://{tenant}.feishu.cn"
cs = cookie_string(jar)
print(f"[2/4] 递归扫描文件夹 ...")
all_files = walk_folder(tenant, token, jar)
downloadable = [f for f in all_files if f["type"] == 12]
skipped = [f for f in all_files if f["type"] != 12]
print(f"{len(all_files)} 项: "
f"{len(downloadable)} 可下载, {len(skipped)} 在线文档(跳过)")
if skipped:
print(f"\n ⏭ 跳过的在线文档:")
for f in skipped:
print(f" {f['type_name']} {f['path']}")
if not downloadable:
print("\n 没有可下载的文件"); return
# 探测真实文件名和大小
print(f"\n[3/4] 探测文件信息 ...")
total_size = 0
for f in downloadable:
referer = f.get("url", f"{base}/drive/folder/{token}")
try:
real_name, size = probe_file(tenant, f["obj_token"], jar, referer)
f["real_name"] = real_name or f["name"]
f["size"] = size or f["size"]
except:
f["real_name"] = f["name"]
total_size += f["size"]
# 打印文件列表
print(f"\n {''*60}")
print(f" {'#':>3} {'文件名':<35} {'大小':>10} 路径")
print(f" {''*60}")
for i, f in enumerate(downloadable):
sz = human_size(f["size"]) if f["size"] else " ?"
print(f" {i+1:>3} {f['real_name']:<35} {sz:>10} {f['path']}")
print(f" {''*60}")
print(f" 合计: {len(downloadable)} 文件, {human_size(total_size)}")
if args.list: return
# 下载
print(f"\n[4/4] {'aria2c 命令' if args.aria2c else '推送 Motrix'} ...")
ok = 0
for f in downloadable:
dl_url = f"{base}/space/api/box/stream/download/all/{f['obj_token']}"
sub = os.path.dirname(f["path"])
out_dir = os.path.join(args.dir, sub) if args.dir and sub else (args.dir or None)
if args.aria2c:
print_aria2c(dl_url, cs, base, f["real_name"], out_dir)
print()
ok += 1
else:
if push_one(dl_url, cs, base, f["real_name"], out_dir, quiet=True):
ok += 1
print(f" [✓] {f['real_name']}")
else:
print(f" [✗] {f['real_name']} — Motrix 未响应")
print(f" 降级 aria2c:\n")
print_aria2c(dl_url, cs, base, f["real_name"], out_dir)
return # Motrix 挂了就不继续了
print(f"\n 完成! {ok}/{len(downloadable)}")
# ─── 入口 ────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="飞书分享解析下载器 v2")
ap.add_argument("url", help="飞书分享链接 (文件/文件夹)")
ap.add_argument("-d", "--dir", default=None, help="下载目录")
ap.add_argument("--list", action="store_true", help="仅列出,不下载")
ap.add_argument("--aria2c", action="store_true", help="输出 aria2c 命令")
args = ap.parse_args()
tenant, token, lt = parse_share_url(args.url)
if not token:
print(f"[✗] 不支持的链接: {args.url}")
print(f" 格式: https://xxx.feishu.cn/file/xxxToken")
print(f" https://xxx.feishu.cn/drive/folder/xxxToken")
sys.exit(1)
print(f"\n{'📄' if lt=='file' else '📁'} 飞书分享解析 [{lt}] {tenant}/{token}")
print(f"[1/{3 if lt=='file' else 4}] 获取匿名会话 ...")
jar = fetch_session(args.url)
print(f" {sum(1 for _ in jar)} cookies")
if lt == "file":
handle_file(tenant, token, jar, args)
else:
handle_folder(tenant, token, jar, args)
print()
if __name__ == "__main__":
main()

View File

@@ -161,14 +161,23 @@ public class PanDomainTemplateTest {
public void testLePatternFix() { public void testLePatternFix() {
Pattern lePattern = PanDomainTemplate.LE.getPattern(); Pattern lePattern = PanDomainTemplate.LE.getPattern();
// lecloud.lenovo.com 应匹配 // /share/ 格式应匹配
Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123"); Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123");
assertTrue("LE should match lecloud.lenovo.com", m1.find()); assertTrue("LE should match /share/ format", m1.find());
assertEquals("abc123", m1.group("KEY")); assertEquals("abc123", m1.group("KEY"));
// leclou.lenovo.com (去掉'd') 不应匹配(原 lecloud? 的 bug // /mshare/ 格式应匹配
Matcher m2 = lePattern.matcher("https://lecloud.lenovo.com/mshare/xyz789");
assertTrue("LE should match /mshare/ format", m2.find());
assertEquals("xyz789", m2.group("KEY"));
// leclou.lenovo.com (去掉'd') 不应匹配
assertFalse("LE should NOT match leclou.lenovo.com", assertFalse("LE should NOT match leclou.lenovo.com",
lePattern.matcher("https://leclou.lenovo.com/share/abc123").find()); lePattern.matcher("https://leclou.lenovo.com/share/abc123").find());
// 错误路径不应匹配
assertFalse("LE should NOT match wrong path",
lePattern.matcher("https://lecloud.lenovo.com/s/abc123").find());
} }
@Test @Test
@@ -250,6 +259,72 @@ public class PanDomainTemplateTest {
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY")); assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
} }
@Test
public void testFsPatternMatching() {
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
// 文件链接
Matcher m1 = fsPattern.matcher(
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc");
assertTrue("FS should match file link", m1.matches());
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m1.group("KEY"));
// 文件链接带 ?from=from_copylink
Matcher m2 = fsPattern.matcher(
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink");
assertTrue("FS should match file link with query param", m2.matches());
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m2.group("KEY"));
// 文件夹链接
Matcher m3 = fsPattern.matcher(
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg");
assertTrue("FS should match folder link", m3.matches());
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m3.group("KEY"));
// 文件夹链接带 ?from=from_copylink
Matcher m4 = fsPattern.matcher(
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink");
assertTrue("FS should match folder link with query param", m4.matches());
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m4.group("KEY"));
// 不同的 tenant 子域名
Matcher m5 = fsPattern.matcher(
"https://pokepangle.feishu.cn/file/VW30bpK74ontiTxvRg1cZcgvnGg");
assertTrue("FS should match different tenant", m5.matches());
assertEquals("VW30bpK74ontiTxvRg1cZcgvnGg", m5.group("KEY"));
// 负例: 非feishu域名不匹配
assertFalse("FS should NOT match non-feishu domain",
fsPattern.matcher("https://evil.com/file/abc123").matches());
// 负例: feishu.cn上的其他路径不匹配
assertFalse("FS should NOT match other feishu paths",
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
}
@Test
public void testFsFromShareUrl() {
// 测试文件链接解析
String fileUrl = "https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink";
ParserCreate parserCreate = ParserCreate.fromShareUrl(fileUrl);
ShareLinkInfo info = parserCreate.getShareLinkInfo();
assertNotNull("ShareLinkInfo should not be null", info);
assertEquals("fs", info.getType());
assertEquals("飞书云盘", info.getPanName());
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
// 测试文件夹链接解析
String folderUrl = "https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg";
ParserCreate parserCreate2 = ParserCreate.fromShareUrl(folderUrl);
ShareLinkInfo info2 = parserCreate2.getShareLinkInfo();
assertNotNull("ShareLinkInfo should not be null", info2);
assertEquals("fs", info2.getType());
assertEquals("飞书云盘", info2.getPanName());
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", info2.getShareKey());
}
@Test @Test
public void verifyDuplicates() { public void verifyDuplicates() {

View File

@@ -11,6 +11,8 @@
content="Netdisk fast download 网盘直链解析工具"> content="Netdisk fast download 网盘直链解析工具">
<!-- Font Awesome 图标库 - 使用国内CDN --> <!-- Font Awesome 图标库 - 使用国内CDN -->
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- 迅雷 JS-SDK -->
<script src="//open.thunderurl.com/thunder-link.js"></script>
<style> <style>
.page-loading-wrap { .page-loading-wrap {
padding: 120px; padding: 120px;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,266 @@
<template>
<el-dialog
title="文件下载"
v-model="dialogVisible"
width="600px"
:close-on-click-modal="false"
@close="$emit('update:visible', false)"
>
<div v-if="info" class="download-info-content">
<div class="download-file-header">
<i class="fas fa-file" style="margin-right: 8px; color: #409eff;"></i>
<strong>{{ info.fileName || '未命名文件' }}</strong>
</div>
<el-alert
title="该文件需要特殊请求头才能下载,无法直接通过浏览器下载。请使用以下方式之一:"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-tabs v-model="activeTab">
<el-tab-pane label="发送到下载器" name="downloader">
<div class="downloader-section">
<template v-if="isThunder">
<p v-if="thunderNeedsCookie" style="color: #f56c6c; margin-bottom: 12px;">
<i class="fas fa-exclamation-circle"></i>
该文件需要 Cookie 认证迅雷不支持自定义 Cookie请切换到 Aria2/Motrix/Gopeed
</p>
<p v-else-if="thunderNeedsUa" style="color: #e6a23c; margin-bottom: 12px;">
<i class="fas fa-exclamation-triangle"></i>
该文件需要特殊 User-Agent 才能下载迅雷客户端可能不支持自定义 UA下载可能失败建议切换到 Aria2/Motrix/Gopeed
</p>
<p v-else style="color: #909399; margin-bottom: 12px;">
<i class="fas fa-bolt"></i>
迅雷将通过浏览器唤起本地客户端下载
</p>
</template>
<template v-else>
<p v-if="!connected" style="color: #e6a23c; margin-bottom: 12px;">
<i class="fas fa-exclamation-triangle"></i>
未检测到下载器连接请先在首页配置下载器Aria2/Motrix/Gopeed/迅雷
</p>
<p v-else style="color: #67c23a; margin-bottom: 12px;">
<i class="fas fa-check-circle"></i>
下载器已连接 ({{ downloaderVersion }})
</p>
</template>
<el-button
type="success"
@click="sendToDownloader"
:disabled="(isThunder && thunderNeedsCookie) || (!isThunder && !connected)"
:loading="sending"
>
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="!isThunder"
size="small"
@click="doTestConnection"
style="margin-left: 8px;"
>
测试连接
</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="Aria2 命令" name="aria2">
<el-input
type="textarea"
:model-value="info.aria2Command"
:rows="4"
readonly
resize="none"
class="download-command-textarea"
/>
<div class="download-actions">
<el-button type="primary" size="small" @click="copyText(info.aria2Command)">
<i class="fas fa-copy"></i> 复制 Aria2 命令
</el-button>
</div>
</el-tab-pane>
<el-tab-pane label="Curl 命令" name="curl">
<el-input
type="textarea"
:model-value="info.curlCommand"
:rows="4"
readonly
resize="none"
class="download-command-textarea"
/>
<div class="download-actions">
<el-button type="primary" size="small" @click="copyText(info.curlCommand)">
<i class="fas fa-copy"></i> 复制 Curl 命令
</el-button>
</div>
</el-tab-pane>
</el-tabs>
<div style="margin-top: 16px; text-align: right;">
<el-button size="small" type="warning" @click="doDirectDownload">
直接打开链接可能失败
</el-button>
</div>
</div>
</el-dialog>
</template>
<script>
import { testConnection, addDownload, getConfig, hasCookieHeader, hasCustomUaHeader } from '@/utils/downloaderService'
export default {
name: 'DownloadDialog',
props: {
/** v-model:visible 控制弹窗显示 */
visible: {
type: Boolean,
default: false
},
/**
* 下载信息对象
* { downloadUrl, fileName, downloadHeaders, aria2Command, curlCommand, aria2JsonRpc, needDownloader }
*/
downloadInfo: {
type: Object,
default: null
}
},
emits: ['update:visible', 'close'],
data() {
return {
activeTab: 'downloader',
connected: false,
downloaderVersion: '',
sending: false
}
},
computed: {
dialogVisible: {
get() { return this.visible },
set(val) { this.$emit('update:visible', val) }
},
info() { return this.downloadInfo },
isThunder() { return getConfig().downloaderType === 'thunder' },
thunderNeedsCookie() { return this.isThunder && this.info && hasCookieHeader(this.info.downloadHeaders) },
thunderNeedsUa() { return this.isThunder && this.info && hasCustomUaHeader(this.info.downloadHeaders) }
},
watch: {
visible(val) {
if (val) {
this.activeTab = 'downloader'
this.checkConnection()
}
}
},
methods: {
/** 检测下载器连接状态 */
async checkConnection() {
const result = await testConnection()
this.connected = result.connected
this.downloaderVersion = result.version
},
/** 手动测试连接 */
async doTestConnection() {
const result = await testConnection()
this.connected = result.connected
this.downloaderVersion = result.version
if (result.connected) {
this.$message.success(`下载器连接正常 (${result.version})`)
} else {
this.$message.error('无法连接到下载器,请检查配置')
}
},
/** 发送到 Aria2/Motrix/Gopeed */
async sendToDownloader() {
if (!this.info) return
this.sending = true
try {
const gid = await addDownload(
this.info.downloadUrl,
this.info.downloadHeaders,
this.info.fileName
)
this.$message.success('已发送到下载器任务ID: ' + gid)
this.dialogVisible = false
} catch (error) {
console.error('发送到下载器失败:', error)
this.$message.error('发送到下载器失败: ' + (error.message || '未知错误'))
} finally {
this.sending = false
}
},
/** 直接打开下载链接(可能因缺请求头而失败) */
doDirectDownload() {
if (this.info && this.info.downloadUrl) {
const a = document.createElement('a')
a.href = this.info.downloadUrl
a.target = '_blank'
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
this.dialogVisible = false
}
},
/** 复制文本到剪贴板 */
async copyText(text) {
if (!text) return
try {
await navigator.clipboard.writeText(text)
this.$message.success('已复制到剪贴板')
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
this.$message.success('已复制到剪贴板')
}
}
}
}
</script>
<style scoped>
.download-info-content {
padding: 0 4px;
}
.download-file-header {
font-size: 16px;
margin-bottom: 12px;
padding: 10px 14px;
background: #f0f7ff;
border-radius: 8px;
display: flex;
align-items: center;
word-break: break-all;
}
:deep(.dark) .download-file-header,
.dark-theme .download-file-header {
background: #1a3350;
}
.download-command-textarea :deep(.el-textarea__inner) {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: #f5f5f5;
color: #333;
}
:deep(.dark) .download-command-textarea :deep(.el-textarea__inner),
.dark-theme .download-command-textarea :deep(.el-textarea__inner) {
background: #1e1e1e;
color: #d4d4d4;
}
.download-actions {
margin-top: 10px;
display: flex;
gap: 8px;
}
.downloader-section {
padding: 16px 0;
}
</style>

View File

@@ -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<Object>} 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<Object>} 响应 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 APIGET /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<string>} 任务 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 APIPOST /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.multicallgopeed 用 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<string>}
*/
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
}

View File

@@ -35,27 +35,36 @@
<i class="fas fa-server feedback-icon"></i> <i class="fas fa-server feedback-icon"></i>
部署 部署
</a> </a>
<a href="javascript:void(0)" class="feedback-link mini donate-link" @click="showDonateDialog = true">
<i class="fas fa-gift feedback-icon" style="color: #e74c3c;"></i>
捐赠账号
</a>
</div> </div>
<el-row :gutter="20" style="margin-left: 0; margin-right: 0;"> <el-row :gutter="20" style="margin-left: 0; margin-right: 0;">
<el-card class="box-card"> <el-card class="box-card">
<div style="text-align: right; display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<!-- 左侧认证配置按钮 --> <!-- 左侧认证配置 + 捐赠账号 按钮 -->
<el-tooltip content="配置临时认证信息" placement="bottom"> <div style="display: flex; gap: 6px; align-items: center;">
<el-button <el-tooltip content="配置临时认证信息" placement="bottom">
:type="hasAuthConfig ? 'primary' : 'default'" <el-button
:class="{ 'auth-config-btn-active': hasAuthConfig }" :type="hasAuthConfig ? 'primary' : 'default'"
circle :class="{ 'auth-config-btn-active': hasAuthConfig }"
size="small" circle
@click="showAuthConfigDialog = true"> size="small"
<el-icon><Key /></el-icon> @click="showAuthConfigDialog = true">
<el-icon><Key /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="捐赠网盘账号" placement="bottom">
<el-button circle size="small" type="warning" @click="showDonateDialog = true">
<el-icon><Present /></el-icon>
</el-button>
</el-tooltip>
</div>
<!-- 右侧下载器 + 暗色模式 -->
<div style="display: flex; gap: 8px; align-items: center;">
<el-button link type="primary" @click="openAria2Dialog" style="position: relative;">
<span :class="['aria2-status-dot', aria2Connected ? 'connected' : 'disconnected']"></span>
{{ aria2Connected ? ('已连接 - ' + downloaderTypeName) : '下载器' }}
</el-button> </el-button>
</el-tooltip> <DarkMode @theme-change="handleThemeChange" />
<!-- 右侧暗色模式切换 --> </div>
<DarkMode @theme-change="handleThemeChange" />
</div> </div>
<div class="demo-basic--circle"> <div class="demo-basic--circle">
<div class="block" style="text-align: center;"> <div class="block" style="text-align: center;">
@@ -107,7 +116,7 @@
<el-button style="margin-left: 20px" @click="generateMarkdown">生成Markdown</el-button> <el-button style="margin-left: 20px" @click="generateMarkdown">生成Markdown</el-button>
<el-button style="margin-left: 20px" @click="generateQRCode">扫码下载</el-button> <el-button style="margin-left: 20px" @click="generateQRCode">扫码下载</el-button>
<el-button style="margin-left: 20px" @click="getStatistics">分享统计</el-button> <el-button style="margin-left: 20px" @click="getStatistics">分享统计</el-button>
<el-button style="margin-left: 20px" @click="goToClientLinks" type="primary">生成命令行链接</el-button>
</p> </p>
</div> </div>
@@ -115,31 +124,92 @@
<div v-if="parseResult.code" style="margin-top: 10px"> <div v-if="parseResult.code" style="margin-top: 10px">
<strong>解析结果: </strong> <strong>解析结果: </strong>
<json-viewer :value="parseResult" :expand-depth="5" copyable boxed sort /> <json-viewer :value="parseResult" :expand-depth="5" copyable boxed sort />
<!-- 文件信息美化展示区 --> <!-- 下载链接卡片 -->
<div v-if="downloadUrl" class="file-meta-info-card"> <div v-if="downloadUrl" style="margin-top: 15px;">
<div class="file-meta-row"> <el-card shadow="hover" class="download-result-card">
<span class="file-meta-label">下载链接</span> <template #header>
<a :href="downloadUrl" target="_blank" class="file-meta-link" rel="noreferrer noopener">点击下载</a> <div style="display: flex; align-items: center; justify-content: space-between;">
</div> <span>下载链接</span>
<div class="file-meta-row" v-if="parseResult.data?.downloadShortUrl"> <div style="display: flex; gap: 8px;">
<span class="file-meta-label">下载短链</span> <el-button @click="openUrl(downloadUrl)" type="primary" size="small">
<a :href="parseResult.data.downloadShortUrl" target="_blank" class="file-meta-link">{{ parseResult.data.downloadShortUrl }}</a> <el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
</div> </el-button>
<div class="file-meta-row"> <el-button @click="openUrl(getPreviewLink())" type="default" size="small">
<span class="file-meta-label">文件预览</span> <el-icon style="margin-right: 4px;"><View /></el-icon> 预览
<a :href="getPreviewLink()" target="_blank" class="file-meta-link">点击预览</a> </el-button>
</div> <el-tooltip :disabled="aria2Connected"
<div class="file-meta-row"> content="下载器未连接,请点击右上角「下载器」配置" placement="top">
<span class="file-meta-label">文件名</span>{{ extractFileNameAndExt(downloadUrl).name }} <el-button
</div> @click="handleAria2Download" :loading="aria2Downloading"
<div class="file-meta-row"> type="success" size="small" :disabled="!aria2Connected">
<span class="file-meta-label">文件类型</span>{{ getFileTypeClass({ fileName: extractFileNameAndExt(downloadUrl).name }) }} <el-icon style="margin-right: 4px;"><Download /></el-icon> 发送到下载器
</div> </el-button>
<div class="file-meta-row" v-if="parseResult.data?.sizeStr"> </el-tooltip>
<span class="file-meta-label">文件大小</span>{{ parseResult.data.sizeStr }} </div>
</div> </div>
</template>
<el-input :value="downloadUrl" readonly>
<template #append>
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
v-clipboard:error="onError" style="padding: 0 14px;">
<el-icon><CopyDocument/></el-icon>
</el-button>
</template>
</el-input>
<!-- 文件元信息 -->
<div style="margin-top: 10px; font-size: 13px; color: var(--el-text-color-secondary);">
<span v-if="parseResult.data?.sizeStr" style="margin-right: 16px;">
大小: <strong>{{ parseResult.data.sizeStr }}</strong>
</span>
<span v-if="parseResult.data?.downloadShortUrl" style="margin-right: 16px;">
短链: <a :href="parseResult.data.downloadShortUrl" target="_blank" class="file-meta-link">{{ parseResult.data.downloadShortUrl }}</a>
</span>
</div>
<!-- 调试命令区默认折叠 -->
<div v-if="aria2Command || aria2JsonRpc || curlCommand" style="margin-top: 12px;">
<el-collapse v-model="activeDebugCommands">
<el-collapse-item name="debug">
<template #title>
<span style="font-size: 13px; color: var(--el-text-color-secondary);">命令行 / 调试参数</span>
</template>
<div v-if="aria2Command" class="debug-cmd-section">
<div class="debug-cmd-label">Aria2 下载命令</div>
<el-input :value="aria2Command" type="textarea" :rows="2" readonly />
<div style="text-align: right; margin-top: 6px;">
<el-button v-clipboard:copy="aria2Command" v-clipboard:success="onCopy"
v-clipboard:error="onError" size="small">
<el-icon><CopyDocument/></el-icon> 复制
</el-button>
</div>
</div>
<div v-if="aria2JsonRpc" class="debug-cmd-section">
<div class="debug-cmd-label">Aria2 JSON-RPC</div>
<el-input :value="aria2JsonRpc" type="textarea" :rows="2" readonly />
<div style="text-align: right; margin-top: 6px;">
<el-button v-clipboard:copy="aria2JsonRpc" v-clipboard:success="onCopy"
v-clipboard:error="onError" size="small">
<el-icon><CopyDocument/></el-icon> 复制
</el-button>
</div>
</div>
<div v-if="curlCommand" class="debug-cmd-section">
<div class="debug-cmd-label">curl 下载命令</div>
<el-input :value="curlCommand" type="textarea" :rows="2" readonly />
<div style="text-align: right; margin-top: 6px;">
<el-button v-clipboard:copy="curlCommand" v-clipboard:success="onCopy"
v-clipboard:error="onError" size="small">
<el-icon><CopyDocument/></el-icon> 复制
</el-button>
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</div> </div>
</div> </div>
<!-- 文件需要下载器弹窗 -->
<DownloadDialog v-model:visible="downloadDialogVisible" :download-info="downloadDialogInfo" />
<!-- Markdown链接 --> <!-- Markdown链接 -->
<div v-if="markdownText" style="text-align: center"> <div v-if="markdownText" style="text-align: center">
@@ -335,27 +405,85 @@
</el-card> </el-card>
</el-row> </el-row>
<!-- 下载器设置 Dialog -->
<el-dialog v-model="aria2DialogVisible" title="下载器设置" width="min(500px, 92vw)" :close-on-click-modal="false">
<div class="aria2-config-section">
<div class="aria2-config-title">
<el-icon><Setting /></el-icon>
<span>下载器类型</span>
</div>
<el-select v-model="aria2ConfigForm.downloaderType" style="width: 100%;" @change="onDownloaderTypeChange">
<el-option label="Motrix (推荐)" value="motrix" />
<el-option label="Gopeed" value="gopeed" />
<el-option label="Aria2" value="aria2" />
<el-option label="迅雷" value="thunder" />
</el-select>
<el-alert
v-if="aria2ConfigForm.downloaderType !== 'thunder'"
title="Motrix: 端口 16800 | Gopeed: 端口 9999 | Aria2: 端口 6800"
type="info" :closable="false" show-icon style="margin-top: 8px;"
/>
<el-alert
v-else
title="迅雷通过 JS-SDK 调用本地客户端,无需配置 RPC"
type="info" :closable="false" show-icon style="margin-top: 8px;"
/>
<div style="margin-top: 10px; font-size: 13px; color: var(--el-text-color-secondary);">
没有下载器
<el-link type="primary" href="https://motrix.app" target="_blank" rel="noopener noreferrer">Motrix</el-link> /
<el-link type="primary" href="https://github.com/GopeedLab/gopeed/releases" target="_blank" rel="noopener noreferrer">Gopeed</el-link> /
<el-link type="primary" href="https://www.xunlei.com" target="_blank" rel="noopener noreferrer">迅雷</el-link>
</div>
</div>
<div v-show="aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section">
<div class="aria2-config-title"><el-icon><Monitor /></el-icon><span>RPC 地址</span></div>
<el-input v-model="aria2ConfigForm.rpcUrl" placeholder="http://localhost:6800/jsonrpc" clearable />
</div>
<div v-show="aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section">
<div class="aria2-config-title"><el-icon><Key /></el-icon><span>RPC 密钥 (可选)</span></div>
<el-input v-model="aria2ConfigForm.rpcSecret" placeholder="如果设置了密钥请输入" show-password clearable autocomplete="new-password" />
</div>
<div class="aria2-config-section">
<el-button link type="primary" @click="aria2ShowAdvanced = !aria2ShowAdvanced">
{{ aria2ShowAdvanced ? '收起选项 ' : '更多选项 ' }}
</el-button>
<el-collapse-transition>
<div v-show="aria2ShowAdvanced" style="margin-top: 10px;">
<div class="aria2-config-title"><el-icon><Folder /></el-icon><span>下载目录</span></div>
<el-input v-model="aria2ConfigForm.downloadDir" placeholder="留空使用默认下载目录" clearable />
</div>
</el-collapse-transition>
</div>
<div v-if="aria2Version && aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section" style="text-align: center;">
<el-tag type="success" size="small">
<el-icon style="vertical-align: middle;"><SuccessFilled /></el-icon>
已连接 - {{ downloaderTypeName }} {{ aria2Version }}
</el-tag>
</div>
<div v-if="aria2ConfigForm.downloaderType === 'thunder'" class="aria2-config-section" style="text-align: center;">
<el-tag type="info" size="small">迅雷通过浏览器唤起本地客户端无需测试连接</el-tag>
</div>
<div v-show="aria2ConfigForm.downloaderType !== 'thunder'" class="aria2-config-section" style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<el-button :loading="aria2Testing" @click="testAria2Connection(false)" type="primary" plain>
<el-icon><Download /></el-icon> 测试连接
</el-button>
<el-button :loading="aria2AutoDetecting" @click="autoDetectDownloader" type="success" plain>
<el-icon><Search /></el-icon> 自动检测
</el-button>
</div>
<div style="text-align: center; margin-top: 12px;">
<el-button type="primary" @click="saveAria2Config" style="min-width: 180px;">
<el-icon><Select /></el-icon> 保存设置
</el-button>
</div>
</el-dialog>
<!-- 版本号显示 --> <!-- 版本号显示 -->
<div class="version-info"> <div class="version-info">
<span class="version-text">内部版本: {{ buildVersion }}</span> <span class="version-text">内部版本: {{ buildVersion }}</span>
<el-link v-if="playgroundEnabled" :href="'/playground'" class="playground-link">脚本演练场</el-link> <el-link v-if="playgroundEnabled" :href="'/playground'" class="playground-link">脚本演练场</el-link>
</div> </div>
<!-- 文件解析结果区下方加分享按钮 -->
<!-- <div v-if="parseResult.code && downloadUrl" style="margin-top: 10px; text-align: right;">-->
<!-- <el-button type="primary" @click="copyShowFileLink">分享文件直链</el-button>-->
<!-- </div>-->
<!-- 目录解析结果区下方加分享按钮 -->
<!-- <div v-if="showDirectoryTree && directoryData.length" style="margin-top: 10px; text-align: right;">-->
<!-- <el-input :value="showListLink" readonly style="width: 350px; margin-right: 10px;">-->
<!-- <template #append>-->
<!-- <el-button v-clipboard:copy="showListLink" v-clipboard:success="onCopy" v-clipboard:error="onError">-->
<!-- <el-icon><CopyDocument /></el-icon>复制分享链接-->
<!-- </el-button>-->
<!-- </template>-->
<!-- </el-input>-->
<!-- </div>-->
<!-- 捐赠账号弹窗 --> <!-- 捐赠账号弹窗 -->
<el-dialog <el-dialog
v-model="showDonateDialog" v-model="showDonateDialog"
@@ -461,16 +589,18 @@ import axios from 'axios'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import DarkMode from '@/components/DarkMode' import DarkMode from '@/components/DarkMode'
import DirectoryTree from '@/components/DirectoryTree' import DirectoryTree from '@/components/DirectoryTree'
import DownloadDialog from '@/components/DownloadDialog'
import parserUrl from '../parserUrl1' import parserUrl from '../parserUrl1'
import fileTypeUtils from '@/utils/fileTypeUtils' import fileTypeUtils from '@/utils/fileTypeUtils'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { playgroundApi } from '@/utils/playgroundApi' import { playgroundApi } from '@/utils/playgroundApi'
import { testConnection, autoDetect, addDownload, getConfig, saveConfig } from '@/utils/downloaderService'
export const previewBaseUrl = 'https://nfd-parser.github.io/nfd-preview/preview.html?src='; export const previewBaseUrl = 'https://nfd-parser.github.io/nfd-preview/preview.html?src=';
export default { export default {
name: 'App', name: 'App',
components: { DarkMode, DirectoryTree }, components: { DarkMode, DirectoryTree, DownloadDialog },
mixins: [fileTypeUtils], mixins: [fileTypeUtils],
data() { data() {
return { return {
@@ -553,7 +683,32 @@ export default {
donateAccountCounts: { donateAccountCounts: {
active: { total: 0 }, active: { total: 0 },
inactive: { total: 0 } inactive: { total: 0 }
} },
// 下载器相关
aria2Connected: false,
aria2Version: '',
aria2DialogVisible: false,
aria2ShowAdvanced: false,
aria2Testing: false,
aria2AutoDetecting: false,
aria2Downloading: false,
aria2ConfigForm: {
downloaderType: 'aria2',
rpcUrl: 'http://localhost:6800/jsonrpc',
rpcSecret: '',
downloadDir: ''
},
// 下载命令
aria2Command: '',
aria2JsonRpc: '',
curlCommand: '',
activeDebugCommands: [],
// 下载器特殊头对话框
downloadDialogVisible: false,
downloadDialogInfo: null,
// 目录解析支持的网盘列表
directoryParseSupportedPans: []
} }
}, },
computed: { computed: {
@@ -566,6 +721,16 @@ export default {
// 获取已配置认证的网盘数量 // 获取已配置认证的网盘数量
authConfigCount() { authConfigCount() {
return Object.keys(this.allAuthConfigs).length return Object.keys(this.allAuthConfigs).length
},
// 下载器类型名称
downloaderTypeName() {
const map = {
motrix: 'Motrix',
gopeed: 'Gopeed',
aria2: 'Aria2',
thunder: '迅雷'
}
return map[this.aria2ConfigForm.downloaderType] || 'Aria2'
} }
}, },
methods: { methods: {
@@ -967,8 +1132,27 @@ export default {
const result = await this.callAPI('/json/parser', params) const result = await this.callAPI('/json/parser', params)
this.parseResult = result this.parseResult = result
this.downloadUrl = result.data?.directLink this.downloadUrl = result.data?.directLink
// 提取命令行参数
const otherParam = result.data?.otherParam || {}
this.aria2Command = otherParam.aria2Command || ''
this.aria2JsonRpc = otherParam.aria2JsonRpc || ''
this.curlCommand = otherParam.curlCommand || ''
this.activeDebugCommands = []
// 更新智能直链(包含认证参数) // 更新智能直链(包含认证参数)
this.updateDirectLink() this.updateDirectLink()
// 如果需要下载器(含特殊头),弹出下载器对话框
if (result.data?.needDownloader) {
this.downloadDialogInfo = {
downloadUrl: result.data.directLink,
fileName: result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || {},
aria2Command: this.aria2Command,
curlCommand: this.curlCommand,
aria2JsonRpc: this.aria2JsonRpc,
needDownloader: true
}
this.downloadDialogVisible = true
}
this.$message.success('文件解析成功!') this.$message.success('文件解析成功!')
} catch (error) { } catch (error) {
console.error('文件解析失败:', error) console.error('文件解析失败:', error)
@@ -982,17 +1166,7 @@ export default {
const params = { url: this.link } const params = { url: this.link }
if (this.password) params.pwd = this.password if (this.password) params.pwd = this.password
const result = await this.callAPI('/v2/linkInfo', params) // 直接调用 getFileList让后端返回错误不做客户端类型检查
const data = result.data
// 检查是否支持目录解析
const supportedPans = ["iz", "lz", "fj", "ye", "le"]
if (!supportedPans.includes(data.shareLinkInfo.type)) {
this.$message.error("当前网盘不支持目录解析")
return
}
// 获取目录数据
const directoryResult = await this.callAPI('/v2/getFileList', params) const directoryResult = await this.callAPI('/v2/getFileList', params)
this.directoryData = directoryResult.data || [] this.directoryData = directoryResult.data || []
this.showDirectoryTree = true this.showDirectoryTree = true
@@ -1363,6 +1537,117 @@ export default {
} catch (e) { } catch (e) {
console.error('加载捐赠账号统计失败:', e) console.error('加载捐赠账号统计失败:', e)
} }
},
// ===== 下载器相关方法 =====
openAria2Dialog() {
this.aria2DialogVisible = true
},
onDownloaderTypeChange() {
const defaults = {
motrix: 'http://localhost:16800/jsonrpc',
gopeed: 'http://localhost:9999/api/v1',
aria2: 'http://localhost:6800/jsonrpc',
thunder: ''
}
if (defaults[this.aria2ConfigForm.downloaderType] !== undefined) {
this.aria2ConfigForm.rpcUrl = defaults[this.aria2ConfigForm.downloaderType]
}
},
async testAria2Connection(silent = false) {
this.aria2Testing = true
try {
if (this.aria2ConfigForm.downloaderType === 'thunder') {
const result = await testConnection()
this.aria2Connected = result.connected
this.aria2Version = result.version || 'JS-SDK'
if (!silent) {
if (result.connected) this.$message.success('迅雷 JS-SDK 已就绪')
else this.$message.error('迅雷客户端未检测到,请确认已安装并启动迅雷')
}
return
}
const result = await testConnection(
this.aria2ConfigForm.rpcUrl,
this.aria2ConfigForm.rpcSecret
)
if (result.connected) {
this.aria2Connected = true
this.aria2Version = result.version || ''
if (!silent) this.$message.success(`连接成功:${this.downloaderTypeName} ${this.aria2Version}`)
} else {
this.aria2Connected = false
this.aria2Version = ''
if (!silent) this.$message.error('连接失败:请检查下载器是否启动')
}
} catch (e) {
this.aria2Connected = false
this.aria2Version = ''
if (!silent) this.$message.error('连接失败:' + e.message)
} finally {
this.aria2Testing = false
}
},
async autoDetectDownloader() {
this.aria2AutoDetecting = true
try {
const result = await autoDetect(this.aria2ConfigForm.rpcSecret)
if (result.found) {
this.aria2ConfigForm.rpcUrl = result.rpcUrl
this.aria2ConfigForm.downloaderType = result.type || 'aria2'
this.aria2Connected = true
this.aria2Version = result.version || ''
this.$message.success(`检测到 ${this.downloaderTypeName} ${this.aria2Version}`)
} else {
this.$message.warning('未检测到本地下载器,请确认 Motrix/Gopeed/Aria2 正在运行')
}
} catch (e) {
this.$message.error('自动检测失败:' + e.message)
} finally {
this.aria2AutoDetecting = false
}
},
saveAria2Config() {
saveConfig(this.aria2ConfigForm)
this.$message.success('下载器配置已保存')
this.aria2DialogVisible = false
// 保存后自动测试连接
this.testAria2Connection(true)
},
getAria2Config() {
const cfg = getConfig()
if (cfg) {
this.aria2ConfigForm = { ...this.aria2ConfigForm, ...cfg }
// 启动后静默测试连接
this.testAria2Connection(true)
}
},
async handleAria2Download() {
if (!this.downloadUrl) return
this.aria2Downloading = true
try {
const headers = this.parseResult.data?.otherParam?.downloadHeaders || {}
const fileName = this.parseResult.data?.fileInfo?.fileName || ''
await addDownload(this.downloadUrl, headers, fileName, this.aria2ConfigForm)
this.$message.success('已发送到下载器')
} catch (e) {
this.$message.error('发送失败:' + e.message)
} finally {
this.aria2Downloading = false
}
},
openUrl(url) {
if (url) window.open(url, '_blank', 'noopener,noreferrer')
},
async loadDirectoryParseSupportedPans() {
try {
const result = await this.callAPI('/v2/supportedParsePans', {})
if (result.data && Array.isArray(result.data)) {
this.directoryParseSupportedPans = result.data.map(p => (typeof p === 'string' ? p.toLowerCase() : p))
}
} catch (e) {
// 静默失败,使用默认列表
}
} }
}, },
@@ -1388,6 +1673,9 @@ export default {
// 检查演练场是否启用 // 检查演练场是否启用
this.checkPlaygroundEnabled() this.checkPlaygroundEnabled()
// 初始化下载器配置
this.getAria2Config()
// 自动读取剪切板 // 自动读取剪切板
if (this.autoReadClipboard) { if (this.autoReadClipboard) {
this.getPaste() this.getPaste()
@@ -1662,6 +1950,45 @@ hr {
color: #eee !important; color: #eee !important;
border-color: #444 !important; border-color: #444 !important;
} }
/* 下载器状态指示点 */
.aria2-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 5px;
}
.aria2-status-dot.connected { background: #67c23a; }
.aria2-status-dot.disconnected { background: #909399; }
/* 下载器配置区块 */
.aria2-config-section {
margin-bottom: 14px;
}
.aria2-config-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
/* 下载结果卡片 */
.download-result-card {
margin-top: 10px;
}
/* 调试命令区 */
.debug-cmd-section {
margin-bottom: 14px;
}
.debug-cmd-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
#app.dark-theme .jv-key { #app.dark-theme .jv-key {
color: #4a9eff !important; color: #4a9eff !important;
} }

View File

@@ -19,19 +19,19 @@ module.exports = {
port: 6444, port: 6444,
proxy: { proxy: {
'/parser': { '/parser': {
target: 'http://127.0.0.1:6400/', // 请求本地 target: 'http://127.0.0.1:6401/', // 请求本地
ws: false ws: false
}, },
'/v2': { '/v2': {
target: 'http://127.0.0.1:6400/', // 请求本地 target: 'http://127.0.0.1:6401/', // 请求本地
ws: false ws: false
}, },
'/json': { '/json': {
target: 'http://127.0.0.1:6400/', // 请求本地 target: 'http://127.0.0.1:6401/', // 请求本地
ws: false ws: false
}, },
'/d': { '/d': {
target: 'http://127.0.0.1:6400/', // 请求本地 target: 'http://127.0.0.1:6401/', // 请求本地
ws: false ws: false
}, },
} }