fix(uc/qk): improve directory download with cookie, transfer fallback and URL-safe param encoding

- Preserve download cookie for UC/Quark needDownloader flows and disable browser/copy when required
- Quark: share-link first, transfer only on size limit (23018), reuse savedFileCache/search_exit
- Propagate auth to subdirectory parser URLs; switch path params to URL-safe Base64 (no double encode)
- Bump version to 0.4.2

Fixes #205

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
q
2026-07-26 16:16:51 +08:00
parent c282fcb109
commit d1fa787bef
19 changed files with 1837 additions and 302 deletions
@@ -414,7 +414,18 @@ public abstract class PanBase implements IPanTool, Closeable {
protected void completeWithMeta(String url, Map<String, String> headers) {
shareLinkInfo.getOtherParam().put("downloadUrl", url);
if (headers != null && !headers.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", headers);
// 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令
Map<String, String> clean = new HashMap<>();
headers.forEach((k, v) -> {
if (k != null && v != null && !v.isBlank()) {
clean.put(k, v);
}
});
if (!clean.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", clean);
// UC/夸克等需带 cookie 的直链,标记前端走下载器
shareLinkInfo.getOtherParam().put("needDownloader", true);
}
}
promise.complete(url);
}
@@ -522,6 +533,34 @@ public abstract class PanBase implements IPanTool, Closeable {
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
}
/**
* 将入口请求中的加密 auth 透传到子目录/下载链接,避免进入子目录后丢失认证。
* otherParam 中的 key 为 {@code _authQuery}(由 web 层写入)。
*/
protected String appendAuthQuery(String url) {
if (StringUtils.isBlank(url) || shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
return url;
}
Object authObj = shareLinkInfo.getOtherParam().get("_authQuery");
if (authObj == null) {
return url;
}
String auth = authObj.toString();
if (StringUtils.isBlank(auth)) {
return url;
}
// 已带 auth 则不再追加
if (url.contains("auth=")) {
return url;
}
try {
String encoded = java.net.URLEncoder.encode(auth, StandardCharsets.UTF_8);
return url + (url.contains("?") ? "&" : "?") + "auth=" + encoded;
} catch (Exception e) {
return url + (url.contains("?") ? "&" : "?") + "auth=" + auth;
}
}
@Override
public ShareLinkInfo getShareLinkInfo() {
return shareLinkInfo;
@@ -378,8 +378,11 @@ public enum PanDomainTemplate {
// =====================私有盘解析==========================
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
// 主域名 ysepan.com / ys168.com;备用 cccpan.com / ysupan.com / uupan.net / ysok.net
YS("永硕E盘",
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\.(?:ysepan|ys168)\\.com/?(?:\\?.*)?"),
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\."
+ "(?:ysepan\\.com|ys168\\.com|cccpan\\.com|ysupan\\.com|uupan\\.net|ysok\\.net)"
+ "/?(?:\\?.*)?"),
"https://{shareKey}.ysepan.com/",
"https://www.ysepan.com/",
YsTool.class),
@@ -1,53 +1,149 @@
package cn.qaiu.parser;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Parser token cache keyed by parser type and account identity.
* 解析器 Token/Cookie 缓存 — 支持多账号隔离。
* <p>
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
* </p>
*/
public final class TokenCache {
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
private TokenCache() {}
private TokenCache() {
/** token 缓存 */
private static final ConcurrentHashMap<String, String> tokenMap = new ConcurrentHashMap<>();
/** 过期时间缓存(毫秒时间戳) */
private static final ConcurrentHashMap<String, Long> expireMap = new ConcurrentHashMap<>();
/** 同一 key 下的额外字符串缓存(如 userId) */
private static final ConcurrentHashMap<String, String> extraMap = new ConcurrentHashMap<>();
/** 布尔标记缓存(如 authFlag */
private static final ConcurrentHashMap<String, Boolean> flagMap = new ConcurrentHashMap<>();
// ============ key 构造 ============
public static String key(String diskType, String accountKey) {
return diskType + "#" + (accountKey == null ? "_default" : accountKey);
}
public static String key(String type, String accountId) {
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
// ============ token ============
public static String getToken(String cacheKey) {
return tokenMap.get(cacheKey);
}
public static void putToken(String key, String token) {
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
return;
public static void putToken(String cacheKey, String token) {
if (token == null) {
tokenMap.remove(cacheKey);
} else {
tokenMap.put(cacheKey, token);
}
TOKENS.put(key, token);
}
public static String getToken(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
if (isExpired(key)) {
TOKENS.remove(key);
EXPIRES.remove(key);
return null;
}
return TOKENS.get(key);
// ============ expire ============
public static long getExpire(String cacheKey) {
return expireMap.getOrDefault(cacheKey, 0L);
}
public static void putExpire(String key, long expireTimeMillis) {
if (StringUtils.isBlank(key)) {
return;
}
EXPIRES.put(key, expireTimeMillis);
public static void putExpire(String cacheKey, long expireMs) {
expireMap.put(cacheKey, expireMs);
}
public static boolean isExpired(String key) {
Long expireTimeMillis = EXPIRES.get(key);
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
public static boolean isExpired(String cacheKey) {
long exp = getExpire(cacheKey);
return exp <= 0 || System.currentTimeMillis() > exp;
}
// ============ extra (userId 等) ============
public static String getExtra(String cacheKey) {
return extraMap.get(cacheKey);
}
public static void putExtra(String cacheKey, String value) {
if (value == null) {
extraMap.remove(cacheKey);
} else {
extraMap.put(cacheKey, value);
}
}
// ============ flag (authFlag 等) ============
public static boolean getFlag(String cacheKey, boolean defaultValue) {
return flagMap.getOrDefault(cacheKey, defaultValue);
}
public static void putFlag(String cacheKey, boolean value) {
flagMap.put(cacheKey, value);
}
// ============ 清除 ============
public static void remove(String cacheKey) {
tokenMap.remove(cacheKey);
expireMap.remove(cacheKey);
extraMap.remove(cacheKey);
flagMap.remove(cacheKey);
}
/**
* 清除指定网盘类型的所有缓存(精准清除,不影响其他网盘类型)
*/
public static void removeByDiskType(String diskType) {
String prefix = diskType + "#";
tokenMap.keySet().removeIf(k -> k.startsWith(prefix));
expireMap.keySet().removeIf(k -> k.startsWith(prefix));
extraMap.keySet().removeIf(k -> k.startsWith(prefix));
flagMap.keySet().removeIf(k -> k.startsWith(prefix));
}
public static void clear() {
tokenMap.clear();
expireMap.clear();
extraMap.clear();
flagMap.clear();
}
// ============ Token 持久化队列 ============
/** 待持久化的 cachedToken 数据 (cacheKey -> [token, expireMs]) */
private static final ConcurrentHashMap<String, String[]> persistQueue = new ConcurrentHashMap<>();
/** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */
private static final ConcurrentHashMap<String, String> credentialUpdateQueue = new ConcurrentHashMap<>();
/**
* 解析器登录成功后,将 token 加入持久化队列(下次 recordConfigUsage 回写 DB
*/
public static void queueCachedTokenPersist(String cacheKey, String token, long expireMs) {
if (cacheKey != null && token != null) {
persistQueue.put(cacheKey, new String[]{token, String.valueOf(expireMs)});
}
}
/**
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
*/
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
if (cacheKey != null && newCredential != null) {
credentialUpdateQueue.put(cacheKey, newCredential);
}
}
/**
* 消费持久化队列:返回 [token, expireMs] 并移除;无数据返回 null
*/
public static String[] pollCachedTokenPersist(String cacheKey) {
return cacheKey == null ? null : persistQueue.remove(cacheKey);
}
/**
* 消费凭据更新队列:返回新凭据并移除;无数据返回 null
*/
public static String pollCredentialUpdate(String cacheKey) {
return cacheKey == null ? null : credentialUpdateQueue.remove(cacheKey);
}
}
File diff suppressed because it is too large Load Diff
@@ -265,13 +265,8 @@ public class UcTool extends PanBase {
return;
}
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
// UC网盘需要配合aria2下载,保存下载请求头
Map<String, String> downloadHeaders = new HashMap<>();
// 将header转换为Map 只需要包含cookie,user-agent,referer
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
completeWithMeta(downloadUrl, downloadHeaders);
// UC 需配合下载器(带 cookie,保存下载请求头
completeWithMeta(downloadUrl, buildDownloadHeaders(null));
} catch (Exception e) {
fail("解析 UC 下载链接失败: " + e.getMessage());
}
@@ -466,29 +461,40 @@ public class UcTool extends PanBase {
if (shareFidToken != null) {
extParams.put("share_fid_token", shareFidToken);
}
extParams.put("needDownloader", true);
Map<String, String> dlHeaders = new HashMap<>();
String listCookie = header.get(HttpHeaders.COOKIE);
if (listCookie != null && !listCookie.isEmpty()) {
dlHeaders.put(HttpHeaders.COOKIE.toString(), listCookie);
}
dlHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
dlHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
extParams.put("downloadHeaders", dlHeaders);
fileInfo.setExtParameters(extParams);
// 设置解析URL(用于下载)
JsonObject paramJson = new JsonObject(extParams);
paramJson.put("fileName", fileName);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param));
// 透传 auth,避免下载/转存时变成 guest
fileInfo.setParserUrl(appendAuthQuery(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param)));
} else {
// 文件夹
fileInfo.setFileType("folder");
fileInfo.setSize(0L);
fileInfo.setSizeStr("0B");
// 设置目录解析URL(用于递归解析子目录)
// 对 URL 参数进行编码,确保特殊字符正确传递
// 递归子目录须透传 auth,否则会丢失认证
try {
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
fileInfo.setParserUrl(appendAuthQuery(String.format(
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken)));
} catch (Exception e) {
// 如果编码失败,使用原始值
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken));
fileInfo.setParserUrl(appendAuthQuery(String.format(
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)));
}
}
@@ -511,6 +517,9 @@ public class UcTool extends PanBase {
return promise.future();
}
// 会话无 cookie 时,回退使用入口参数中已带的 cookie
ensureCookieFromParam(paramJson);
String fid = paramJson.getString("fid");
String pwdId = paramJson.getString("pwd_id");
String stoken = paramJson.getString("stoken");
@@ -554,6 +563,11 @@ public class UcTool extends PanBase {
promise.fail("未找到下载链接");
return;
}
// 存储下载请求头,供目录解析 getFileDownInfo 接口使用
// 优先用当前会话 cookie;缺失时回退入口参数中已带的 cookie
Map<String, String> downloadHeaders = buildDownloadHeaders(paramJson);
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
shareLinkInfo.getOtherParam().put("fileName", paramJson.getString("fileName", ""));
promise.complete(downloadUrl);
} catch (Exception e) {
promise.fail("解析 UC 下载链接失败: " + e.getMessage());
@@ -564,6 +578,53 @@ public class UcTool extends PanBase {
return promise.future();
}
/**
* 会话无 cookie 时,从入口参数 downloadHeaders 回填到请求头。
*/
private void ensureCookieFromParam(JsonObject paramJson) {
String cookie = header.get(HttpHeaders.COOKIE);
if (cookie != null && !cookie.isEmpty()) {
return;
}
String paramCookie = extractCookieFromParam(paramJson);
if (paramCookie != null && !paramCookie.isEmpty()) {
header.set(HttpHeaders.COOKIE, CookieUtils.filterUcQuarkCookie(paramCookie));
}
}
private static String extractCookieFromParam(JsonObject paramJson) {
if (paramJson == null) {
return null;
}
JsonObject paramHeaders = paramJson.getJsonObject("downloadHeaders");
if (paramHeaders == null) {
return null;
}
String cookie = paramHeaders.getString("cookie");
return cookie != null ? cookie : paramHeaders.getString("Cookie");
}
/**
* 构建下载请求头:会话 cookie 优先,缺失时回退入口参数中的 downloadHeaders。
*/
private Map<String, String> buildDownloadHeaders(JsonObject paramJson) {
Map<String, String> downloadHeaders = new HashMap<>();
String cookie = header.get(HttpHeaders.COOKIE);
if (cookie == null || cookie.isEmpty()) {
cookie = extractCookieFromParam(paramJson);
}
if (cookie != null && !cookie.isEmpty()) {
downloadHeaders.put(HttpHeaders.COOKIE.toString(), cookie);
}
String ua = header.get(HttpHeaders.USER_AGENT);
if (ua == null || ua.isEmpty()) {
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
}
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), ua);
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
return downloadHeaders;
}
// public static void main(String[] args) {
// // https://drive.uc.cn/s/12450d1694844?public=1
// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess(
@@ -24,7 +24,7 @@ import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 永硕E盘 (ysepan.com / ys168.com)
* 永硕E盘(主 ysepan.com / ys168.com,备 cccpan.com / ysupan.com / uupan.net / ysok.net
* <p>
* 空间分享形如 https://{space}.ysepan.com/ ,需空间访问密码时通过 sharePassword 传入。
*/
@@ -42,6 +42,8 @@ public class YsTool extends PanBase {
Pattern.compile("jwttk_[^=]+=([^;\\s]+)");
private static final String PARAM_DIR_ID = "dirId";
/** 永硕目录内的子目录名(API 字段 zml),用于二级层级 */
private static final String PARAM_ZML = "zml";
public YsTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
@@ -88,9 +90,10 @@ public class YsTool extends PanBase {
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
int dirId = Integer.parseInt(dirIdObj.toString());
String zmlFilter = currentZmlFilter();
fetchFiles(session, dirId).onSuccess(filesResp -> {
try {
listPromise.complete(mapFiles(session, dirId, filesResp));
listPromise.complete(mapFiles(session, dirId, filesResp, zmlFilter));
} catch (Exception e) {
listPromise.fail(baseMsg() + " - 解析文件列表失败: " + e.getMessage());
}
@@ -132,7 +135,7 @@ public class YsTool extends PanBase {
fail("下载参数不完整: {}", paramJson);
return promise.future();
}
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName, true);
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName);
completeWithMeta(url, downloadHeaders(paramJson.getString("referer", spaceOrigin())));
return promise.future();
}
@@ -375,36 +378,94 @@ public class YsTool extends PanBase {
return result;
}
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp) {
/**
* 将目录内文件按 zml(子目录)分层。
* <ul>
* <li>zmlFilter 为空:返回子目录(folder+ 根级文件</li>
* <li>zmlFilter 非空:仅返回该子目录下的文件/链接</li>
* </ul>
*/
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp, String zmlFilter) {
List<FileInfo> result = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
boolean listingSubdir = StringUtils.isNotBlank(zmlFilter);
// 未进入子目录时,先按出现顺序收集 zml 作为二级文件夹
if (!listingSubdir) {
java.util.LinkedHashSet<String> subdirs = new java.util.LinkedHashSet<>();
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String wjlx = item.getString("wjlx", "");
String zml = StringUtils.defaultString(item.getString("zml")).trim();
if (StringUtils.isNotBlank(zml)) {
subdirs.add(zml);
}
}
for (String zml : subdirs) {
result.add(new FileInfo()
.setFileName(zml)
.setFileId(dirId + ":" + zml)
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(zml)
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&zml=%s&pwd=%s",
getDomainName(),
urlEncode(shareLinkInfo.getShareUrl()),
dirId,
urlEncode(zml),
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword())))));
}
}
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String itemZml = StringUtils.defaultString(item.getString("zml")).trim();
if (listingSubdir) {
if (!zmlFilter.equals(itemZml)) {
continue;
}
} else if (StringUtils.isNotBlank(itemZml)) {
// 根级列表只展示无 zml 的条目,有 zml 的归入子目录
continue;
}
Integer bh = item.getInteger("bh");
if (bh == null) {
continue;
}
String wjlx = item.getString("wjlx", "");
// URL / 公告条目
if ("url".equalsIgnoreCase(wjlx)) {
String title = StringUtils.defaultIfBlank(item.getString("bt"), item.getString("wjm", "链接"));
String link = item.getString("wjm", "");
String link = StringUtils.defaultString(item.getString("wjm")).trim();
String title = StringUtils.defaultString(item.getString("bt")).trim();
// 空占位(标题和链接都空)跳过,与官网展示一致
if (StringUtils.isAllBlank(title, link)) {
continue;
}
if (StringUtils.isBlank(title)) {
title = StringUtils.defaultIfBlank(link, "链接");
}
FileInfo urlInfo = new FileInfo()
.setFileName(title)
.setFileId(bh.toString())
.setFileType("url")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(item.getString("zml", ""))
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setPreviewUrl(link)
.setDescription(link);
// parserUrl 置空,避免前端误走下载;打开走 previewUrl
result.add(urlInfo);
continue;
}
@@ -417,7 +478,7 @@ public class YsTool extends PanBase {
}
long size = item.getLong("dx", 0L);
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName, true);
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName);
JsonObject param = new JsonObject()
.put("space", session.space)
.put("xzpz", xzpz)
@@ -436,7 +497,7 @@ public class YsTool extends PanBase {
.setFileType("file")
.setSize(size)
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
.setFilePath(item.getString("zml", ""))
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
@@ -448,6 +509,18 @@ public class YsTool extends PanBase {
return result;
}
private String currentZmlFilter() {
Object zml = shareLinkInfo.getOtherParam().get(PARAM_ZML);
if (zml == null) {
return "";
}
try {
return java.net.URLDecoder.decode(zml.toString(), StandardCharsets.UTF_8).trim();
} catch (Exception e) {
return zml.toString().trim();
}
}
private List<JsonObject> downloadableFiles(JsonObject filesResp) {
List<JsonObject> files = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
@@ -474,7 +547,7 @@ public class YsTool extends PanBase {
private void completeDownload(Session session, JsonObject filesResp, JsonObject file) {
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz");
String url = buildDownloadUrl(session.space, xzpz, file.getString("pz"),
file.getString("fwq"), file.getString("wjm"), true);
file.getString("fwq"), file.getString("wjm"));
FileInfo fileInfo = new FileInfo()
.setFileName(file.getString("wjm"))
@@ -489,15 +562,16 @@ public class YsTool extends PanBase {
completeWithMeta(url, downloadHeaders(session.origin + "/"));
}
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq,
String fileName, boolean forceDownload) {
String token = forceDownload ? "_" + xzpz : xzpz;
/**
* 拼装直链。注意:不要在 xzpz 前加 "_",官方页面直链无此前缀,加了会 404。
*/
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq, String fileName) {
String host = "X".equalsIgnoreCase(fwq)
? "y.ys168.com:8000"
: "ys-" + fwq.toLowerCase() + ".ysepan.com";
return "https://" + host + "/wap/"
+ encodePathSegment(space) + "/"
+ encodePathSegment(token) + "/"
+ encodePathSegment(xzpz) + "/"
+ encodePathSegment(pz) + "/"
+ encodePathSegment(fileName);
}
@@ -77,32 +77,57 @@ public class CommonUtils {
}
/**
* urlEncode -> deBase64 -> string
* @param encoded 编码后的字符串
* @return 解码后的字符串
* 解码路径参数中的 Base64。
* <p>优先按 URL-Safe Base64 解;兼容历史「标准 Base64 + URLEncode」以及重复 encode。</p>
*/
public static String urlBase64Decode(String encoded) {
try {
String urlDecoded = java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
byte[] base64DecodedBytes = java.util.Base64.getDecoder().decode(urlDecoded);
return new String(base64DecodedBytes, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("URL Base64 解码失败", e);
if (encoded == null || encoded.isEmpty()) {
throw new RuntimeException("URL Base64 解码失败: empty");
}
String s = encoded.trim().replace(' ', '+');
// 兼容历史 URLEncode / 误二次 encode:有 % 则解到不再变化
for (int i = 0; i < 3 && s.contains("%"); i++) {
try {
String next = java.net.URLDecoder.decode(s, StandardCharsets.UTF_8);
if (next.equals(s)) {
break;
}
s = next;
} catch (Exception e) {
break;
}
}
Exception last = null;
for (String candidate : new String[]{s, padBase64(s)}) {
try {
return new String(java.util.Base64.getUrlDecoder().decode(candidate), StandardCharsets.UTF_8);
} catch (Exception e) {
last = e;
}
try {
return new String(java.util.Base64.getDecoder().decode(candidate), StandardCharsets.UTF_8);
} catch (Exception e) {
last = e;
}
}
throw new RuntimeException("URL Base64 解码失败", last);
}
/**
* string -> base64Encode -> urlEncode
* @param str 原始字符串
* @return 编码后的字符串
* 编码为可直接放进 URL path 的 Base64URL-Safe,无 padding)。
* <p>不再做 URLEncoder,避免前端/代理再 encode 时变成 %253D。</p>
*/
public static String urlBase64Encode(String str) {
try {
byte[] base64EncodedBytes = java.util.Base64.getEncoder().encode(str.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String base64Encoded = new String(base64EncodedBytes, java.nio.charset.StandardCharsets.UTF_8);
return java.net.URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("URL Base64 编码失败", e);
}
return java.util.Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(str.getBytes(StandardCharsets.UTF_8));
}
private static String padBase64(String s) {
int mod = s.length() % 4;
if (mod == 0) {
return s;
}
return s + "====".substring(mod);
}
}
@@ -323,6 +323,63 @@ public class PanDomainTemplateTest {
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
}
@Test
public void testYsPatternMatching() {
Pattern ysPattern = PanDomainTemplate.YS.getPattern();
// 主域名
Matcher m1 = ysPattern.matcher("https://qaiu.ysepan.com/");
assertTrue("YS should match ysepan.com", m1.matches());
assertEquals("qaiu", m1.group("KEY"));
Matcher m2 = ysPattern.matcher("http://sohehe4.ys168.com");
assertTrue("YS should match ys168.com", m2.matches());
assertEquals("sohehe4", m2.group("KEY"));
// 备用域名
Matcher m3 = ysPattern.matcher("https://demo.cccpan.com/");
assertTrue("YS should match cccpan.com", m3.matches());
assertEquals("demo", m3.group("KEY"));
Matcher m4 = ysPattern.matcher("https://space.ysupan.com");
assertTrue("YS should match ysupan.com", m4.matches());
assertEquals("space", m4.group("KEY"));
Matcher m5 = ysPattern.matcher("https://user.uupan.net/");
assertTrue("YS should match uupan.net", m5.matches());
assertEquals("user", m5.group("KEY"));
Matcher m6 = ysPattern.matcher("https://ok.ysok.net");
assertTrue("YS should match ysok.net", m6.matches());
assertEquals("ok", m6.group("KEY"));
// 非空间子域 / 非白名单域名
assertFalse("YS should NOT match www.ysepan.com",
ysPattern.matcher("https://www.ysepan.com/").matches());
assertFalse("YS should NOT match api host c6.ysepan.com",
ysPattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
assertFalse("YS should NOT match CDN ys-c.ysepan.com",
ysPattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x").matches());
assertFalse("YS should NOT match unrelated domain",
ysPattern.matcher("https://qaiu.evil.com/").matches());
assertFalse("YS should NOT match ysepan.com without space subdomain",
ysPattern.matcher("https://ysepan.com/").matches());
}
@Test
public void testYsFromShareUrl() {
ParserCreate parserCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
ShareLinkInfo info = parserCreate.getShareLinkInfo();
assertNotNull(info);
assertEquals("ys", info.getType());
assertEquals("永硕E盘", info.getPanName());
assertEquals("qaiu", info.getShareKey());
ParserCreate backup = ParserCreate.fromShareUrl("https://demo.cccpan.com/");
assertEquals("ys", backup.getShareLinkInfo().getType());
assertEquals("demo", backup.getShareLinkInfo().getShareKey());
}
@Test
public void testFsFromShareUrl() {
// 测试文件链接解析
@@ -6,7 +6,9 @@ import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.util.CommonUtils;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -19,20 +21,25 @@ import java.util.regex.Pattern;
import static org.junit.Assert.*;
/**
* 永硕E盘解析测试(含示例空间联调)
* 永硕E盘解析测试(含示例空间联调 + 真实下载校验
*/
public class YsToolTest {
private static Vertx vertx;
private static WebClient webClient;
@BeforeClass
public static void setUpClass() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
webClient = WebClient.create(vertx);
}
@AfterClass
public static void tearDownClass() {
if (webClient != null) {
webClient.close();
}
if (vertx != null) {
vertx.close();
}
@@ -54,10 +61,16 @@ public class YsToolTest {
assertTrue(m3.matches());
assertEquals("demo", m3.group("KEY"));
assertTrue(pattern.matcher("https://a.cccpan.com/").matches());
assertTrue(pattern.matcher("https://a.ysupan.com").matches());
assertTrue(pattern.matcher("https://a.uupan.net/").matches());
assertTrue(pattern.matcher("https://a.ysok.net").matches());
assertFalse(pattern.matcher("https://www.ysepan.com/").matches());
assertFalse(pattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
assertFalse(pattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x/y/z").matches());
assertFalse(pattern.matcher("https://zy.ysepan.com/assets/index.js").matches());
assertFalse(pattern.matcher("https://qaiu.evil.com/").matches());
}
@Test
@@ -71,19 +84,40 @@ public class YsToolTest {
@Test
public void testBuildDownloadUrl() {
String url = YsTool.buildDownloadUrl(
"qaiu",
"UOkGHeA9O9hFJHG",
"rEBaljD.Ba69AMzTBmAb9AC9CPvC2E",
"C",
"Pycharm2023.1激活.zip",
true);
"yssl",
"A95UIe495EkSKE",
"Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC",
"L",
"lu20.jpg");
// 与官方一致:xzpz 前不加 "_"
assertEquals(
"https://ys-c.ysepan.com/wap/qaiu/_UOkGHeA9O9hFJHG/rEBaljD.Ba69AMzTBmAb9AC9CPvC2E/Pycharm2023.1%E6%BF%80%E6%B4%BB.zip",
"https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg",
url);
assertFalse("force-download 前缀会导致 404", url.contains("/_"));
}
@Test
public void testQaiuSpaceFileListAndDownload() throws Exception {
public void testOfficialSampleUrlRealDownload() throws Exception {
String official = "https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
String withForcePrefix = "https://ys-l.ysepan.com/wap/yssl/_A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
Buffer ok = download(official, "https://yssl.ysepan.com/");
assertTrue("官方直链应能下载到 JPEG", ok.length() > 1000);
assertEquals((byte) 0xFF, ok.getByte(0));
assertEquals((byte) 0xD8, ok.getByte(1));
int forceStatus = webClient.getAbs(withForcePrefix)
.putHeader("User-Agent", "Mozilla/5.0")
.putHeader("Referer", "https://yssl.ysepan.com/")
.send()
.toCompletionStage().toCompletableFuture()
.get(30, TimeUnit.SECONDS)
.statusCode();
assertNotEquals("带 _ 前缀的直链应失败(文件不存在)", 200, forceStatus);
}
@Test
public void testQaiuSpaceFileListAndRealDownload() throws Exception {
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
create.getShareLinkInfo().setSharePassword("qaiuys168");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
@@ -118,8 +152,9 @@ public class YsToolTest {
assertNotNull(zip.getParserUrl());
String param = zip.getParserUrl().substring(zip.getParserUrl().lastIndexOf('/') + 1);
String decoded = CommonUtils.urlBase64Decode(param);
JsonObject paramJson = new JsonObject(decoded);
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
assertFalse("downloadUrl 不应含 force 前缀",
paramJson.getString("downloadUrl", "").contains("/_"));
ParserCreate byId = ParserCreate.fromType("ys").shareKey("qaiu");
byId.getShareLinkInfo().setSharePassword("qaiuys168");
@@ -130,9 +165,74 @@ public class YsToolTest {
.get(60, TimeUnit.SECONDS);
assertNotNull(downloadUrl);
assertTrue(downloadUrl.contains("ys-c.ysepan.com") || downloadUrl.contains("ysepan.com"));
assertTrue(downloadUrl.contains("Pycharm") || downloadUrl.contains("%E6%BF%80%E6%B4%BB"));
System.out.println("qaiu downloadUrl=" + downloadUrl);
assertFalse("解析直链不应含 _xzpz 前缀", downloadUrl.matches(".*/_[^/]+/.*"));
assertTrue(downloadUrl.contains("ysepan.com"));
Buffer body = download(downloadUrl, "https://qaiu.ysepan.com/");
assertEquals("真实下载大小应与列表一致", zip.getSize().longValue(), body.length());
// ZIP magic: PK
assertEquals('P', (char) body.getByte(0));
assertEquals('K', (char) body.getByte(1));
System.out.println("qaiu real download ok, url=" + downloadUrl + ", size=" + body.length());
}
@Test
public void testFufu1ZmlHierarchyAndUrlItems() throws Exception {
// https://fufu1.ysepan.com/ 无密码;游戏3 下应按 zml 展示子目录,再进子目录才是夸克/百度链接
ParserCreate create = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> roots = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(roots);
FileInfo game3 = roots.stream()
.filter(f -> "folder".equals(f.getFileType()))
.filter(f -> "游戏3".equals(f.getFileName()))
.findFirst()
.orElse(null);
assertNotNull("应有目录 游戏3", game3);
ParserCreate level2 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
level2.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
level2.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
List<FileInfo> subdirs = level2.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(subdirs);
assertTrue("游戏3 下应是子目录列表", subdirs.size() > 10);
assertTrue("游戏3 下列表应全是 folder(zml 子目录)",
subdirs.stream().allMatch(f -> "folder".equals(f.getFileType())));
FileInfo sample = subdirs.stream()
.filter(f -> f.getFileName() != null && f.getFileName().contains("我是未来"))
.findFirst()
.orElse(subdirs.get(0));
// 从 parserUrl 提取 zml,或直接用 fileName
ParserCreate level3 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
level3.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
level3.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
level3.getShareLinkInfo().getOtherParam().put("zml", sample.getFileName());
List<FileInfo> links = level3.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(links);
assertFalse(links.isEmpty());
assertTrue("子目录内应有 url 类型",
links.stream().anyMatch(f -> "url".equals(f.getFileType())));
assertTrue("应包含夸克/百度链接名",
links.stream().anyMatch(f -> "夸克".equals(f.getFileName()) || "百度".equals(f.getFileName())));
assertFalse("空占位 URL 不应出现",
links.stream().anyMatch(f -> f.getFileName() == null || f.getFileName().isBlank()));
assertTrue("URL 条目应带 previewUrl",
links.stream().filter(f -> "url".equals(f.getFileType()))
.allMatch(f -> f.getPreviewUrl() != null && f.getPreviewUrl().startsWith("http")));
System.out.println("fufu1 hierarchy ok: 游戏3 -> " + sample.getFileName()
+ " -> " + links.stream().map(FileInfo::getFileName).toList());
}
@Test
@@ -166,8 +266,47 @@ public class YsToolTest {
assertNotNull(files);
assertFalse(files.isEmpty());
assertTrue("应包含文件或URL条目",
files.stream().anyMatch(f -> "file".equals(f.getFileType()) || "url".equals(f.getFileType())));
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size());
FileInfo file = files.stream()
.filter(f -> "file".equals(f.getFileType()))
.filter(f -> f.getSize() != null && f.getSize() > 0 && f.getSize() < 5_000_000)
.findFirst()
.orElse(null);
if (file != null) {
String param = file.getParserUrl().substring(file.getParserUrl().lastIndexOf('/') + 1);
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
String downloadUrl = paramJson.getString("downloadUrl");
assertNotNull(downloadUrl);
assertFalse(downloadUrl.contains("/_"));
Buffer body = download(downloadUrl, "https://sohehe4.ysepan.com/");
assertEquals(file.getSize().longValue(), body.length());
System.out.println("sohehe4 real download ok, file=" + file.getFileName()
+ ", size=" + body.length());
} else {
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size()
+ " (no small file for real download sample)");
}
}
private static Buffer download(String url, String referer) throws Exception {
return webClient.getAbs(url)
.putHeader("User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
.putHeader("Referer", referer)
.send()
.toCompletionStage().toCompletableFuture()
.thenApply(res -> {
assertEquals("下载 HTTP 状态码应为 200: " + url, 200, res.statusCode());
Buffer body = res.body();
assertNotNull(body);
assertTrue("下载内容为空: " + url, body.length() > 0);
String ct = res.getHeader("Content-Type");
assertFalse("不应返回 HTML 错误页: " + url,
ct != null && ct.toLowerCase().contains("text/html"));
return body;
})
.get(60, TimeUnit.SECONDS);
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.4.1</revision>
<revision>0.4.2</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
+190 -52
View File
@@ -24,7 +24,7 @@
:class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]"
@click="batchMode ? onBatchClick(file) : handleFileClick(file)"
>
<div v-if="batchMode && file.fileType !== 'folder'" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
<div v-if="batchMode && isDownloadableFile(file)" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
<i :class="isFileSelected(file) ? 'fas fa-check-square' : 'far fa-square'"
:style="{ color: isFileSelected(file) ? '#409eff' : '#c0c4cc' }"></i>
</div>
@@ -52,9 +52,10 @@
<div class="batch-right">
<el-button
type="primary" size="small"
:disabled="selectedFiles.length === 0"
:disabled="selectedFiles.length === 0 || batchBrowserDownloadDisabled"
:loading="batchDownloading"
@click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 浏览器下载
</el-button>
@@ -127,9 +128,10 @@
<el-button
type="primary"
size="small"
:disabled="selectedFiles.length === 0 || batchDownloading"
:disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled"
:loading="batchDownloading"
@click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
>
浏览器下载
</el-button>
@@ -166,25 +168,41 @@
</div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
<p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
链接: {{ selectedNode.previewUrl }}
</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div>
<div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
<el-button
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
type="primary" size="small"
@click="openExternalLink(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开链接
</el-button>
<el-button
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
size="small"
@click="previewFile(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="success" size="small"
@click="handleDownload(selectedNode)"
:loading="downloadLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 下载
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading"
@@ -192,10 +210,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="selectedNode.parserUrl"
v-if="isDownloadableFile(selectedNode)"
size="small"
@click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
<i class="fas fa-link"></i> 复制直链
</el-button>
@@ -243,25 +263,41 @@
</div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
<p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
链接: {{ selectedNode.previewUrl }}
</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div>
<div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
<el-button
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
type="primary" size="small"
@click="openExternalLink(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开链接
</el-button>
<el-button
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
size="small"
@click="previewFile(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="success" size="small"
@click="handleDownload(selectedNode)"
:loading="downloadLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 下载
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading"
@@ -269,10 +305,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="selectedNode.parserUrl"
v-if="isDownloadableFile(selectedNode)"
size="small"
@click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
<i class="fas fa-link"></i> 复制直链
</el-button>
@@ -290,7 +328,7 @@
<div v-if="batchMode" class="mobile-batch-footer">
<span class="tree-sidebar-count">已勾选 {{ selectedFiles.length }} 个文件</span>
<div class="tree-sidebar-actions">
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchBrowserDownload">浏览器下载</el-button>
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled" :loading="batchDownloading" @click="batchBrowserDownload" :title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''">浏览器下载</el-button>
<el-button type="success" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchSendToDownloader">发送到下载器</el-button>
<el-button size="small" @click="toggleBatchMode">取消</el-button>
</div>
@@ -323,19 +361,22 @@
</div>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="previewFile(selectedFile)">打开</el-button>
<!-- 弹窗下载按钮 -->
<el-button type="primary" @click="previewFile(selectedFile)">
{{ selectedFile?.fileType === 'url' ? '打开链接' : '打开' }}
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
type="success"
@click="handleDownload(selectedFile)"
style="margin-left: 8px;"
:loading="downloadLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器下载' : ''"
>
下载
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
type="primary"
@click="sendSingleToDownloader(selectedFile)"
style="margin-left: 8px;"
@@ -344,10 +385,12 @@
发送到下载器
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
@click="copyDirectLink(selectedFile)"
style="margin-left: 8px;"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
复制直链
</el-button>
@@ -394,6 +437,11 @@ export default {
type: String,
default: ''
},
// 加密 auth,用于子目录/下载请求透传(后端未带上时前端补齐)
auth: {
type: String,
default: ''
},
viewMode: {
type: String,
default: 'pane' // 'pane' or 'tree'
@@ -460,6 +508,11 @@ export default {
lines.push(`更新时间: ${updateTime}`)
}
return lines
},
// 所选文件全部需要下载器时,禁用浏览器批量下载
batchBrowserDownloadDisabled() {
return this.selectedFiles.length > 0
&& this.selectedFiles.every(f => this.needsDownloader(f))
}
},
watch: {
@@ -493,6 +546,13 @@ export default {
}
return headers
},
// 子目录/下载链接透传 auth,避免进入子目录变成 guest
// 注意:auth 可能已 encodeURIComponent,直接拼接,避免 searchParams 再编一层
withAuth(url) {
if (!url || !this.auth) return url
if (/[?&]auth=/.test(url)) return url
return url + (url.includes('?') ? '&' : '?') + 'auth=' + this.auth
},
buildApiUrl() {
const baseUrl = `${window.location.origin}/v2/getFileList`
const params = new URLSearchParams({
@@ -501,14 +561,14 @@ export default {
if (this.password) {
params.append('pwd', this.password)
}
return `${baseUrl}?${params.toString()}`
return this.withAuth(`${baseUrl}?${params.toString()}`)
},
// 懒加载子节点
loadNode(node, resolve) {
if (node.level === 0) {
resolve(this.treeData[0].children)
} else if (node.data.fileType === 'folder' && node.data.parserUrl) {
axios.get(node.data.parserUrl, { headers: this.apiKeyHeaders() }).then(res => {
axios.get(this.withAuth(node.data.parserUrl), { headers: this.apiKeyHeaders() }).then(res => {
if (res.data.code === 200) {
const children = (res.data.data || []).map(item => ({
...item,
@@ -535,6 +595,8 @@ export default {
handleFileClick(file) {
if (file.fileType === 'folder') {
this.enterFolder(file)
} else if (file.fileType === 'url') {
this.openExternalLink(file)
} else if (this.viewMode === 'pane') {
this.selectedFile = file
this.fileDialogVisible = true
@@ -548,10 +610,11 @@ export default {
}
try {
this.loading = true
const response = await axios.get(folder.parserUrl, { headers: this.apiKeyHeaders() })
const folderUrl = this.withAuth(folder.parserUrl)
const response = await axios.get(folderUrl, { headers: this.apiKeyHeaders() })
if (response.data.code === 200) {
const newDir = {
url: folder.parserUrl,
url: folderUrl,
name: folder.fileName || '未命名文件夹'
}
this.pathStack.push(newDir)
@@ -585,7 +648,7 @@ export default {
}
try {
this.loading = true
const response = await axios.get(currentDir.url, { headers: this.apiKeyHeaders() })
const response = await axios.get(this.withAuth(currentDir.url), { headers: this.apiKeyHeaders() })
if (response.data.code === 200) {
this.currentFileList = response.data.data || []
} else {
@@ -599,8 +662,32 @@ export default {
this.loading = false
}
},
isDownloadableFile(file) {
return !!(file && file.parserUrl && file.fileType !== 'folder' && file.fileType !== 'url')
},
// 需要下载器(带 cookie 等特殊头)时,浏览器直连/复制直链不可用
// UC/夸克目录文件始终走下载器
needsDownloader(file) {
if (!file) return false
if (file.extParameters && file.extParameters.needDownloader) return true
const pan = (file.panType || '').toLowerCase()
return pan === 'uc' || pan === 'qk'
},
openExternalLink(file) {
const link = file?.previewUrl || file?.description
if (!link) {
this.$message.warning('该条目暂无外链')
return
}
window.open(link, '_blank', 'noopener,noreferrer')
this.closeFileDialog()
},
// 预览文件
previewFile(file) {
if (file?.fileType === 'url') {
this.openExternalLink(file)
return
}
if (file?.previewUrl || file?.parserUrl) {
this.previewUrl = this.appendToken(file.previewUrl || file.parserUrl)
this.isPreviewing = true
@@ -650,25 +737,18 @@ export default {
// 需要下载器,调用 getFileDownInfo 接口获取下载信息
this.downloadLoading = true
try {
// 从 parserUrl 提取 type 和 param
// parserUrl 格式: /v2/redirectUrl/{type}/{param} 或 完整URL
const url = new URL(file.parserUrl, window.location.origin)
const pathParts = url.pathname.split('/')
// 找到 redirectUrl 后面的部分
const redirectIdx = pathParts.indexOf('redirectUrl')
if (redirectIdx === -1 || redirectIdx + 2 >= pathParts.length) {
const tp = this.extractTypeParam(file)
if (!tp) {
this.$message.error('无法解析下载参数')
return
}
const type = pathParts[redirectIdx + 1]
const param = pathParts[redirectIdx + 2]
const headers = {}
const apiKey = localStorage.getItem('nfd_user_api_key')
if (apiKey) {
headers['X-API-Key'] = apiKey
}
const response = await axios.get(`${window.location.origin}/v2/getFileDownInfo/${type}/${param}`, { headers })
const response = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
if (response.data.code === 200 && response.data.data) {
const info = response.data.data
if (info.needDownloader) {
@@ -759,10 +839,7 @@ export default {
const tp = this.extractTypeParam(file)
if (tp && file.extParameters && file.extParameters.needDownloader) {
const headers = this.apiKeyHeaders()
const resp = await axios.get(
`${window.location.origin}/v2/getFileDownInfo/${tp.type}/${tp.param}`,
{ headers }
)
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
const info = resp.data.data || resp.data
if (info && info.downloadUrl) {
await addDownload(info.downloadUrl, info.downloadHeaders || {}, file.fileName)
@@ -870,7 +947,9 @@ export default {
},
fileMetaText(file) {
const parts = []
if (file.fileType !== 'folder') {
if (file.fileType === 'url') {
parts.push('超链接')
} else if (file.fileType !== 'folder') {
parts.push(file.sizeStr || '0B')
}
const timeText = this.formatDate(file.createTime)
@@ -916,7 +995,7 @@ export default {
this.toggleFileSelect(file)
},
selectAll() {
this.selectedFiles = this.currentFileList.filter(f => f.fileType !== 'folder' && f.parserUrl)
this.selectedFiles = this.currentFileList.filter(f => this.isDownloadableFile(f))
},
deselectAll() {
this.selectedFiles = []
@@ -924,25 +1003,41 @@ export default {
onTreeCheckChange() {
if (!this.$refs.fileTree) return
const checked = this.$refs.fileTree.getCheckedNodes()
this.selectedFiles = checked.filter(n => n.fileType !== 'folder' && n.parserUrl)
this.selectedFiles = checked.filter(n => this.isDownloadableFile(n))
},
extractTypeParam(file) {
if (!file.parserUrl) return null
try {
// pathname 已是解码后的 path;后端现用 URL-Safe Base64(无 %),可直接使用
const url = new URL(file.parserUrl, window.location.origin)
const parts = url.pathname.split('/')
const idx = parts.indexOf('redirectUrl')
if (idx === -1 || idx + 2 >= parts.length) return null
return { type: parts[idx + 1], param: parts[idx + 2] }
const m = url.pathname.match(/\/redirectUrl\/([^/]+)\/(.+)$/)
if (!m) return null
let param = m[2]
// 兼容历史「标准 Base64 + URLEncode」旧链接
if (/%[0-9A-Fa-f]{2}/.test(param)) {
try { param = decodeURIComponent(param) } catch { /* ignore */ }
}
return { type: m[1], param }
} catch {
return null
}
},
// URL-Safe Base64 本身可进 pathencodeURIComponent 对 - _ 无影响,只 encode 一次
buildFileDownInfoUrl(type, param) {
return this.withAuth(
`${window.location.origin}/v2/getFileDownInfo/${type}/${encodeURIComponent(param)}`
)
},
async batchBrowserDownload() {
if (this.selectedFiles.length === 0) return
const files = this.selectedFiles.filter(f => !this.needsDownloader(f))
if (files.length === 0) {
this.$message.warning('所选文件需使用「发送到下载器」下载')
return
}
this.batchDownloading = true
this.batchProgress = { current: 0, total: this.selectedFiles.length, failed: 0 }
for (const file of this.selectedFiles) {
this.batchProgress = { current: 0, total: files.length, failed: 0 }
for (const file of files) {
try {
const a = document.createElement('a')
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
@@ -975,13 +1070,32 @@ export default {
const total = this.selectedFiles.length
this.batchProgress = { current: 0, total, failed: 0 }
// 所有文件统一发 parserUrl(302) + downloadHeaders 给下载器
// needDownloader 文件走 getFileDownInfo 拿直链+cookie;其余发 parserUrl + headers
const downloadTasks = []
const apiHeaders = this.apiKeyHeaders()
for (const file of this.selectedFiles) {
try {
if (this.needsDownloader(file)) {
const tp = this.extractTypeParam(file)
if (!tp) throw new Error('无法解析下载参数')
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers: apiHeaders })
const info = resp.data.data || resp.data
if (!info?.downloadUrl) throw new Error('获取下载信息失败')
downloadTasks.push({
url: info.downloadUrl,
headers: info.downloadHeaders || {},
fileName: file.fileName
})
} else {
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
const url = this.appendToken(rawUrl)
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {}
downloadTasks.push({ url, headers, fileName: file.fileName })
}
} catch (e) {
console.error('准备下载任务失败:', file.fileName, e)
this.batchProgress.failed++
}
this.batchProgress.current++
}
@@ -1005,14 +1119,18 @@ export default {
},
renderContent(h, { node, data, store }) {
const isFolder = data.fileType === 'folder'
const isUrl = data.fileType === 'url'
return h('div', {
class: 'custom-tree-node'
}, [
h('i', {
class: [this.getFileIcon(data), { 'folder-icon': isFolder, 'file-icon': !isFolder }]
class: [
this.getFileIcon(data),
{ 'folder-icon': isFolder, 'url-icon': isUrl, 'file-icon': !isFolder && !isUrl }
]
}),
h('span', {
class: ['node-label', { 'folder-text': isFolder, 'file-text': !isFolder }]
class: ['node-label', { 'folder-text': isFolder, 'url-text': isUrl, 'file-text': !isFolder && !isUrl }]
}, node.label)
])
}
@@ -1193,6 +1311,10 @@ html, body, #app, .main-container, .directory-tree, .content-card {
color: #27ae60;
}
.url .file-icon {
color: #1a73e8;
}
.file-name {
font-weight: 500;
font-size: 0.85rem;
@@ -1438,6 +1560,22 @@ html, body, #app, .main-container, .directory-tree, .content-card {
color: #4a9eff !important;
}
.custom-tree-node .url-icon {
color: #1a73e8 !important;
}
.dark-theme .custom-tree-node .url-icon {
color: #8ab4f8 !important;
}
.custom-tree-node .url-text {
color: #1a73e8 !important;
}
.dark-theme .custom-tree-node .url-text {
color: #8ab4f8 !important;
}
.custom-tree-node .folder-text {
color: #409eff !important;
font-weight: 500;
+7
View File
@@ -356,6 +356,13 @@
host: /(fast|drive)\.uc\.cn/,
name: 'UC网盘'
},
// 永硕E盘:主 ysepan.com/ys168.com,备 cccpan.com/ysupan.com/uupan.net/ysok.net
ysepan: {
reg: /https?:\/\/(?!(?:www|zy|ht|api|c\d+|ys-[a-zA-Z0-9]+)\.)[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)\/?/,
host: /[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)/,
name: '永硕E盘',
storage: 'hash'
},
other: {
reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/,
+3
View File
@@ -5,6 +5,7 @@ const fileTypeUtils = {
},
getFileTypeClass(file) {
if (file.fileType === 'folder') return 'folder'
if (file.fileType === 'url') return 'url'
const ext = this.getFileExtension(file.fileName)
const fileTypes = {
'image': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'],
@@ -23,6 +24,8 @@ const fileTypeUtils = {
},
getFileIcon(file) {
if (file.fileType === 'folder') return 'fas fa-folder'
// 永硕等网盘的外链/公告条目
if (file.fileType === 'url') return 'fas fa-link'
const ext = this.getFileExtension(file.fileName)
const iconMap = {
'jpg': 'fas fa-file-image', 'jpeg': 'fas fa-file-image', 'png': 'fas fa-file-image',
+38 -7
View File
@@ -131,10 +131,15 @@
<div style="display: flex; align-items: center; justify-content: space-between;">
<span>下载链接</span>
<div style="display: flex; gap: 8px;">
<el-button @click="openUrl(downloadUrl)" type="primary" size="small">
<el-tooltip :disabled="!needsDownloader"
content="该网盘需使用下载器下载" placement="top">
<el-button @click="openUrl(downloadUrl)" type="primary" size="small"
:disabled="needsDownloader">
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
</el-button>
<el-button @click="openUrl(getPreviewLink())" type="default" size="small">
</el-tooltip>
<el-button @click="openUrl(getPreviewLink())" type="default" size="small"
:disabled="needsDownloader">
<el-icon style="margin-right: 4px;"><View /></el-icon> 预览
</el-button>
<el-tooltip :disabled="aria2Connected"
@@ -150,10 +155,14 @@
</template>
<el-input :value="downloadUrl" readonly>
<template #append>
<el-tooltip :disabled="!needsDownloader"
content="该网盘需使用下载器,无法直接复制直链" placement="top">
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
v-clipboard:error="onError" style="padding: 0 14px;">
v-clipboard:error="onError" style="padding: 0 14px;"
:disabled="needsDownloader">
<el-icon><CopyDocument/></el-icon>
</el-button>
</el-tooltip>
</template>
</el-input>
<!-- 文件元信息 -->
@@ -417,6 +426,7 @@
:file-list="directoryData"
:share-url="link"
:password="password"
:auth="directoryAuth"
:view-mode="directoryViewMode"
@file-click="handleFileClick"
/>
@@ -649,6 +659,7 @@ export default {
// 目录树
showDirectoryTree: false,
directoryData: [],
directoryAuth: '', // 目录解析时的加密 auth,透传给子目录/下载
// 统计信息
node1Info: {},
@@ -760,6 +771,16 @@ export default {
thunder: '迅雷'
}
return map[this.aria2ConfigForm.downloaderType] || 'Aria2'
},
// 需要下载器(带 cookie 等特殊头)时禁用浏览器下载/复制直链;UC/夸克始终需要
needsDownloader() {
const pan = (this.getCurrentPanType() || '').toLowerCase()
if (pan === 'uc' || pan === 'qk') return true
const data = this.parseResult?.data
if (!data) return false
if (data.needDownloader || data.otherParam?.needDownloader) return true
const headers = data.downloadHeaders || data.otherParam?.downloadHeaders
return !!(headers && (headers.cookie || headers.Cookie))
}
},
methods: {
@@ -1168,6 +1189,7 @@ export default {
this.statisticsData = {}
this.showDirectoryTree = false
this.directoryData = []
this.directoryAuth = ''
},
// 统一API调用(自动添加认证参数)
@@ -1176,11 +1198,13 @@ export default {
this.errorBadgeVisible = false
try {
this.isLoading = true
// 添加认证参数(异步获取
// 添加认证参数(已有则不覆盖,便于目录树透传同一份 auth
if (!params.auth) {
const authParam = await this.generateAuthParam()
if (authParam) {
params.auth = authParam
}
}
const response = await axios.get(`${this.baseAPI}${endpoint}`, { params })
if (response.data.code === 200) {
@@ -1267,11 +1291,13 @@ export default {
// 更新智能直链(包含认证参数)
this.updateDirectLink()
// 如果需要下载器(含特殊头),弹出下载器对话框
if (result.data?.needDownloader) {
const needDownloader = !!(result.data?.needDownloader || otherParam.needDownloader
|| otherParam.downloadHeaders?.cookie || otherParam.downloadHeaders?.Cookie)
if (needDownloader) {
this.downloadDialogInfo = {
downloadUrl: result.data.directLink,
fileName: result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || {},
fileName: result.data.fileInfo?.fileName || result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || otherParam.downloadHeaders || {},
aria2Command: this.aria2Command,
curlCommand: this.curlCommand,
aria2JsonRpc: this.aria2JsonRpc,
@@ -1291,6 +1317,11 @@ export default {
this.validateInput()
const params = { url: this.link }
if (this.password) params.pwd = this.password
// 预先生成 auth,既给本次请求用,也透传给 DirectoryTree 子目录/下载
this.directoryAuth = await this.generateAuthParam()
if (this.directoryAuth) {
params.auth = this.directoryAuth
}
// 直接调用 getFileList,让后端返回错误(不做客户端类型检查)
const directoryResult = await this.callAPI('/v2/getFileList', params)
@@ -22,6 +22,8 @@ public class ParserAuthUtil {
public static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
public static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED";
public static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN";
/** 原始加密 auth 查询串,供目录子链透传(避免进入子目录丢失认证) */
public static final String AUTH_QUERY = "_authQuery";
private ParserAuthUtil() {
}
@@ -41,6 +43,8 @@ public class ParserAuthUtil {
}
if (StringUtils.isNotBlank(auth)) {
// 保留原始 auth,供 getFileList 子目录 parserUrl 透传
otherParam.put(AUTH_QUERY, auth);
AuthParam authParam = AuthParamCodec.decode(auth);
if (authParam != null && authParam.hasValidAuth()) {
otherParam.put("authType", authParam.getAuthType());
@@ -68,9 +68,10 @@ public class URLParamUtil {
boolean firstParam = !decodedUrl.contains("?");
for (String paramName : params.names()) {
// 忽略 "url", "pwd", "dirId", "uuid", "auth", "stoken" 参数(这些参数单独处理,不应拼接到分享URL中
// 忽略单独处理的参数,不应拼接到分享URL中
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth") && !paramName.equals("stoken")) {
&& !paramName.equals("uuid") && !paramName.equals("auth")
&& !paramName.equals("stoken") && !paramName.equals("zml")) {
if (firstParam) {
urlBuilder.append("?");
firstParam = false;
@@ -16,7 +16,9 @@ import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.RouteMethod;
@@ -161,7 +163,7 @@ public class ParserApi {
@RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
String stoken, String auth) {
String stoken, String zml, String auth) {
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate;
try {
@@ -182,6 +184,9 @@ public class ParserApi {
if (StringUtils.isNotBlank(uuid)) {
parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid);
}
if (StringUtils.isNotBlank(zml)) {
parserCreate.getShareLinkInfo().getOtherParam().put("zml", zml);
}
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
@@ -207,7 +212,14 @@ public class ParserApi {
return promise.future();
}
String paramStr = new String(Base64.getDecoder().decode(param));
final String paramStr;
try {
paramStr = CommonUtils.urlBase64Decode(param);
} catch (Exception e) {
Promise<String> promise = Promise.promise();
promise.fail("下载参数解码失败: " + e.getMessage());
return promise.future();
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", new JsonObject(paramStr));
@@ -242,6 +254,131 @@ public class ParserApi {
return promise.future();
}
/**
* 目录文件下载信息(供前端下载器使用):返回直链、请求头及命令行
*/
@RouteMapping("/getFileDownInfo/:type/:param")
public Future<JsonObject> getFileDownInfo(HttpServerRequest request, String type, String param, String auth) {
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromType(type).shareKey("-").setShareLinkInfoPwd("-");
} catch (Exception e) {
return Future.failedFuture(e);
}
if (param == null || param.isEmpty()) {
return Future.failedFuture("下载参数为空");
}
final JsonObject paramJson;
try {
paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
} catch (Exception e) {
return Future.failedFuture("下载参数解码失败: " + e.getMessage());
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", paramJson);
String linkPrefix = getLinkPrefix(request);
JsonObject otherParam = ParserAuthUtil.buildOtherParam(request, auth, linkPrefix);
shareLinkInfo.getOtherParam().put("domainName", linkPrefix);
shareLinkInfo.getOtherParam().put("_requestOrigin", linkPrefix);
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseById)
.onFailure(t -> {
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
parserCreate.getShareLinkInfo(), t);
})
.map(downloadUrl -> buildFileDownInfo(shareLinkInfo, paramJson, downloadUrl));
});
}
@SuppressWarnings("unchecked")
private static JsonObject buildFileDownInfo(ShareLinkInfo shareLinkInfo, JsonObject paramJson, String downloadUrl) {
Map<String, String> downloadHeaders = new HashMap<>();
// 入口参数里可能已带 cookie(目录解析时写入),先作为底稿
mergeDownloadHeaders(downloadHeaders, paramJson.getJsonObject("downloadHeaders"));
// 解析器运行时生成的请求头优先覆盖(如刷新后的 cookie),但跳过 null
Object headersObj = shareLinkInfo.getOtherParam().get("downloadHeaders");
if (headersObj instanceof Map) {
mergeDownloadHeaders(downloadHeaders, (Map<?, ?>) headersObj);
}
String fileName = paramJson.getString("fileName", "");
if (StringUtils.isBlank(fileName)) {
Object fn = shareLinkInfo.getOtherParam().get("fileName");
if (fn != null) {
fileName = fn.toString();
}
}
boolean needDownloader = Boolean.TRUE.equals(paramJson.getBoolean("needDownloader"))
|| !downloadHeaders.isEmpty();
shareLinkInfo.getOtherParam().put("downloadUrl", downloadUrl);
if (!downloadHeaders.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
}
JsonObject result = new JsonObject()
.put("downloadUrl", downloadUrl)
.put("fileName", fileName)
.put("needDownloader", needDownloader)
.put("downloadHeaders", downloadHeaders);
try {
Map<ClientLinkType, String> clientLinks = ClientLinkGeneratorFactory.generateAll(shareLinkInfo);
if (clientLinks.containsKey(ClientLinkType.CURL)) {
result.put("curlCommand", clientLinks.get(ClientLinkType.CURL));
}
if (clientLinks.containsKey(ClientLinkType.ARIA2)) {
result.put("aria2Command", clientLinks.get(ClientLinkType.ARIA2));
}
if (clientLinks.containsKey(ClientLinkType.THUNDER)) {
result.put("thunderLink", clientLinks.get(ClientLinkType.THUNDER));
}
} catch (Exception e) {
log.warn("生成下载命令失败: {}", e.getMessage());
}
return result;
}
/**
* 合并下载请求头,忽略 null/空值,避免运行时 null 覆盖入口参数中的 cookie。
*/
private static void mergeDownloadHeaders(Map<String, String> target, JsonObject source) {
if (source == null || source.isEmpty()) {
return;
}
for (String key : source.fieldNames()) {
Object val = source.getValue(key);
if (val != null && StringUtils.isNotBlank(val.toString())) {
target.put(key, val.toString());
}
}
}
private static void mergeDownloadHeaders(Map<String, String> target, Map<?, ?> source) {
if (source == null || source.isEmpty()) {
return;
}
for (Map.Entry<?, ?> e : source.entrySet()) {
if (e.getKey() == null || e.getValue() == null) {
continue;
}
String val = e.getValue().toString();
if (StringUtils.isNotBlank(val)) {
target.put(e.getKey().toString(), val);
}
}
}
/**
* 预览媒体文件
@@ -186,6 +186,11 @@ public class CacheServiceImpl implements CacheService {
// 传递 downloadHeaders 到两个对象
cacheLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
result.getOtherParam().put("downloadHeaders", downloadHeaders);
// 有特殊下载头时标记需要下载器(浏览器无法带 cookie 直连)
if (!downloadHeaders.isEmpty()) {
cacheLinkInfo.getOtherParam().put("needDownloader", true);
result.getOtherParam().put("needDownloader", true);
}
// 使用已有的工具类生成下载命令
generateCommandsFromShareLinkInfo(shareLinkInfo, cacheLinkInfo, result);