diff --git a/parser/src/main/java/cn/qaiu/parser/PanBase.java b/parser/src/main/java/cn/qaiu/parser/PanBase.java index 8bba7a0..8a9702f 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanBase.java +++ b/parser/src/main/java/cn/qaiu/parser/PanBase.java @@ -414,7 +414,18 @@ public abstract class PanBase implements IPanTool, Closeable { protected void completeWithMeta(String url, Map headers) { shareLinkInfo.getOtherParam().put("downloadUrl", url); if (headers != null && !headers.isEmpty()) { - shareLinkInfo.getOtherParam().put("downloadHeaders", headers); + // 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令 + Map 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; diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index 9ecbe5e..0fed256 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -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]+)\\.)(?[a-zA-Z\\d-]+)\\.(?:ysepan|ys168)\\.com/?(?:\\?.*)?"), + compile("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)" + + "/?(?:\\?.*)?"), "https://{shareKey}.ysepan.com/", "https://www.ysepan.com/", YsTool.class), diff --git a/parser/src/main/java/cn/qaiu/parser/TokenCache.java b/parser/src/main/java/cn/qaiu/parser/TokenCache.java index a3e4029..b3e489b 100644 --- a/parser/src/main/java/cn/qaiu/parser/TokenCache.java +++ b/parser/src/main/java/cn/qaiu/parser/TokenCache.java @@ -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 缓存 — 支持多账号隔离。 + *

+ * 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。 + * accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。 + *

*/ public final class TokenCache { - private static final Map TOKENS = new ConcurrentHashMap<>(); - private static final Map EXPIRES = new ConcurrentHashMap<>(); + private TokenCache() {} - private TokenCache() { + /** token 缓存 */ + private static final ConcurrentHashMap tokenMap = new ConcurrentHashMap<>(); + /** 过期时间缓存(毫秒时间戳) */ + private static final ConcurrentHashMap expireMap = new ConcurrentHashMap<>(); + /** 同一 key 下的额外字符串缓存(如 userId) */ + private static final ConcurrentHashMap extraMap = new ConcurrentHashMap<>(); + /** 布尔标记缓存(如 authFlag) */ + private static final ConcurrentHashMap 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 persistQueue = new ConcurrentHashMap<>(); + /** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */ + private static final ConcurrentHashMap 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); } } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java index b0aa945..4d5c24f 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java @@ -3,6 +3,7 @@ package cn.qaiu.parser.impl; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.parser.TokenCache; import cn.qaiu.util.CommonUtils; import cn.qaiu.util.CookieUtils; import cn.qaiu.util.FileSizeConverter; @@ -16,248 +17,962 @@ import io.vertx.core.json.JsonObject; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; /** - * 夸克网盘解析 - 修复版 - * 重点修复了 Cookie 换行符处理和请求头一致性问题 + * 夸克网盘解析 */ public class QkTool extends PanBase { - + public static final String SHARE_URL_PREFIX = "https://pan.quark.cn/s/"; - + private static final String TOKEN_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/token"; private static final String DETAIL_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/detail"; private static final String DOWNLOAD_URL = "https://drive-pc.quark.cn/1/clouddrive/file/download"; + + // Cookie 刷新 API private static final String FLUSH_URL = "https://drive-pc.quark.cn/1/clouddrive/auth/pc/flush"; - - private static final int BATCH_SIZE = 15; - - // 缓存变量 - private static volatile String cachedPuus = null; - private static volatile long puusExpireTime = 0; + + // 转存相关 API + private static final String SAVE_DIR_URL = "https://drive-pc.quark.cn/1/clouddrive/share/sharepage/dir"; + private static final String SAVE_URL = "https://drive-h.quark.cn/1/clouddrive/share/sharepage/save"; + private static final String TASK_URL = "https://drive-pc.quark.cn/1/clouddrive/task"; + + // 多账号隔离:用 TokenCache 代替 static cookie 缓存 + // __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新) private static final long PUUS_TTL_MS = 55 * 60 * 1000L; - - // 严格模拟夸克 PC 客户端的请求头 - private final MultiMap commonHeaders = HeaderUtils.parseHeaders(""" + + // 静态变量:缓存已转存的文件 (分享key -> 转存文件ID) + private static final Map savedFileCache = new java.util.concurrent.ConcurrentHashMap<>(); + + private final MultiMap header = HeaderUtils.parseHeaders(""" User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/2.5.20 Chrome/100.0.4896.160 Electron/18.3.5.4-b478491100 Safari/537.36 Channel/pckk_other_ch - Accept: application/json, text/plain, */* + Content-Type: application/json;charset=UTF-8 Referer: https://pan.quark.cn/ Origin: https://pan.quark.cn - Accept-Language: zh-CN,zh;q=0.9 - Content-Type: application/json + Accept: application/json, text/plain, */* """); + // 保存 auths 引用,用于更新 cookie private MultiMap auths; + private final String cacheKey; public QkTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); + String accountId = "_default"; + // 参考 UcTool 实现,从认证配置中取 cookie 放到请求头 if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) { auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); - String rawCookie = auths.get("cookie"); - - if (rawCookie != null && !rawCookie.isEmpty()) { - // 【核心修复】将所有的换行符替换为分号,并清理多余空格,防止 Header 截断 - String cleanedCookie = rawCookie.replace("\r\n", "; ").replace("\n", "; ") - .replaceAll(";\\s*;", ";") - .trim(); + if (auths.contains("_configId")) { + accountId = auths.get("_configId"); + } else if (auths.contains("cookie")) { + accountId = auths.get("cookie").substring(0, Math.min(16, auths.get("cookie").length())); + } + String cookie = auths.get("cookie"); + if (cookie != null && !cookie.isEmpty()) { + // 过滤出夸克网盘所需的 cookie 字段 + cookie = CookieUtils.filterUcQuarkCookie(cookie); - // 此时 cleanedCookie 已经是单行规范格式 - cleanedCookie = CookieUtils.filterUcQuarkCookie(cleanedCookie); - - if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) { - cleanedCookie = CookieUtils.updateCookieValue(cleanedCookie, "__puus", cachedPuus); - log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000); + // 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie + String cachedPuus = TokenCache.getToken(TokenCache.key("qk", accountId)); + long puusExpire = TokenCache.getExpire(TokenCache.key("qk", accountId)); + if (cachedPuus != null && System.currentTimeMillis() < puusExpire) { + cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus); + log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpire - System.currentTimeMillis()) / 1000); } - - commonHeaders.set(HttpHeaders.COOKIE, cleanedCookie); - auths.set("cookie", cleanedCookie); + header.set(HttpHeaders.COOKIE, cookie); + // 同步更新 auths + auths.set("cookie", cookie); } } + this.cacheKey = TokenCache.key("qk", accountId); this.client = clientDisableUA; - + + // 如果 __puus 已过期或不存在,触发异步刷新 if (needRefreshPuus()) { + log.debug("夸克: __puus 需要刷新,触发异步刷新"); refreshPuusCookie(); } } - + + /** + * 判断是否需要刷新 __puus + * @return true 表示需要刷新 + */ private boolean needRefreshPuus() { - String currentCookie = commonHeaders.get(HttpHeaders.COOKIE); - if (currentCookie == null || !currentCookie.contains("__pus=")) return false; - return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime; + String currentCookie = header.get(HttpHeaders.COOKIE); + if (currentCookie == null || currentCookie.isEmpty()) { + return false; + } + // 必须包含 __pus 才能刷新 + if (!currentCookie.contains("__pus=")) { + return false; + } + // 缓存过期或不存在时需要刷新 + return TokenCache.getToken(cacheKey) == null || System.currentTimeMillis() >= TokenCache.getExpire(cacheKey); } + /** + * 刷新 __puus Cookie + * 通过调用 auth/pc/flush API,服务器会返回 set-cookie 来更新 __puus + * @return Future 包含是否刷新成功 + */ public Future refreshPuusCookie() { Promise refreshPromise = Promise.promise(); - String currentCookie = commonHeaders.get(HttpHeaders.COOKIE); - if (currentCookie == null || !currentCookie.contains("__pus=")) { + + String currentCookie = header.get(HttpHeaders.COOKIE); + if (currentCookie == null || currentCookie.isEmpty()) { + log.debug("夸克: 无 cookie,跳过刷新"); refreshPromise.complete(false); return refreshPromise.future(); } - + + // 检查是否包含 __pus(用于获取 __puus) + if (!currentCookie.contains("__pus=")) { + log.debug("夸克: cookie 中不包含 __pus,跳过刷新"); + refreshPromise.complete(false); + return refreshPromise.future(); + } + + log.debug("夸克: 开始刷新 __puus cookie"); + client.getAbs(FLUSH_URL) .addQueryParam("pr", "ucpro") .addQueryParam("fr", "pc") - .putHeaders(commonHeaders) + .addQueryParam("uc_param_str", "") + .putHeaders(header) .send() .onSuccess(res -> { + // 从响应头获取 set-cookie List setCookies = res.cookies(); String newPuus = null; + for (String cookie : setCookies) { if (cookie.startsWith("__puus=")) { + // 提取 __puus 值(只取到分号前的部分) int endIndex = cookie.indexOf(';'); newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie; break; } } + if (newPuus != null) { + // 更新 cookie:替换或添加 __puus String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus); - commonHeaders.set(HttpHeaders.COOKIE, updatedCookie); - if (auths != null) auths.set("cookie", updatedCookie); - cachedPuus = newPuus; - puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS; + header.set(HttpHeaders.COOKIE, updatedCookie); + + // 同步更新 auths 中的 cookie + if (auths != null) { + auths.set("cookie", updatedCookie); + } + + // 更新静态缓存 + TokenCache.putToken(cacheKey, newPuus); + TokenCache.putExpire(cacheKey, System.currentTimeMillis() + PUUS_TTL_MS); + + log.info("夸克: __puus cookie 刷新成功,有效期至: {}ms", TokenCache.getExpire(cacheKey)); refreshPromise.complete(true); } else { + log.debug("夸克: 响应中未包含 __puus,可能 cookie 仍然有效"); refreshPromise.complete(false); } }) - .onFailure(t -> refreshPromise.complete(false)); + .onFailure(t -> { + log.warn("夸克: 刷新 __puus cookie 失败: {}", t.getMessage()); + refreshPromise.complete(false); + }); + return refreshPromise.future(); } @Override public Future parse() { String pwdId = shareLinkInfo.getShareKey(); - String passcode = shareLinkInfo.getSharePassword() == null ? "" : shareLinkInfo.getSharePassword(); - - log.debug("开始解析夸克分享: {}", pwdId); - - // 1. 获取 Token - JsonObject tokenBody = new JsonObject().put("pwd_id", pwdId).put("passcode", passcode); + String passcode = shareLinkInfo.getSharePassword(); + if (passcode == null) { + passcode = ""; + } + + log.debug("开始解析夸克网盘分享,pwd_id: {}, passcode: {}", pwdId, passcode.isEmpty() ? "无" : "有"); + + // 第一步:获取分享 token + JsonObject tokenRequest = new JsonObject() + .put("pwd_id", pwdId) + .put("passcode", passcode); + client.postAbs(TOKEN_URL) .addQueryParam("pr", "ucpro") .addQueryParam("fr", "pc") - .putHeaders(commonHeaders) - .sendJsonObject(tokenBody) + .putHeaders(header) + .sendJsonObject(tokenRequest) .onSuccess(res -> { + log.debug("第一阶段响应: {}", res.bodyAsString()); JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 0) { - fail("Token 获取失败: " + resJson.getString("message")); + fail(TOKEN_URL + " 返回异常: " + resJson); return; } - + String stoken = resJson.getJsonObject("data").getString("stoken"); - log.debug("成功获取 stoken"); - - // 2. 获取详情 + if (stoken == null || stoken.isEmpty()) { + fail("无法获取分享 token,可能的原因:1. Cookie 已过期 2. 分享链接已失效 3. 需要提取码但未提供"); + return; + } + + log.debug("成功获取 stoken: {}", stoken); + + // 第二步:获取文件列表 client.getAbs(DETAIL_URL) .addQueryParam("pr", "ucpro") .addQueryParam("fr", "pc") .addQueryParam("pwd_id", pwdId) .addQueryParam("stoken", stoken) .addQueryParam("pdir_fid", "0") + .addQueryParam("force", "0") + .addQueryParam("_page", "1") .addQueryParam("_size", "50") - .putHeaders(commonHeaders) + .addQueryParam("_fetch_banner", "1") + .addQueryParam("_fetch_share", "1") + .addQueryParam("_fetch_total", "1") + .addQueryParam("_sort", "file_type:asc,updated_at:desc") + .putHeaders(header) .send() .onSuccess(res2 -> { + log.debug("第二阶段响应: {}", res2.bodyAsString()); JsonObject resJson2 = asJson(res2); + + if (resJson2.getInteger("code") != 0) { + fail(DETAIL_URL + " 返回异常: " + resJson2); + return; + } + JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list"); if (fileList == null || fileList.isEmpty()) { - fail("未找到文件列表"); + fail("未找到文件"); return; } - - List fileIds = new ArrayList<>(); - Map fileMap = new HashMap<>(); + + // 过滤出文件(排除文件夹) + List files = new ArrayList<>(); for (int i = 0; i < fileList.size(); i++) { JsonObject item = fileList.getJsonObject(i); - if (item.getBoolean("file", false) || item.getString("obj_category") != null) { - String fid = item.getString("fid"); - fileIds.add(fid); - fileMap.put(fid, item); + // 判断是否为文件:file=true 或 obj_category 不为空 + if (item.getBoolean("file", false) || + (item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) { + files.add(item); } } - - if (fileIds.isEmpty()) { - fail("无有效文件"); + + if (files.isEmpty()) { + fail("没有可下载的文件(可能都是文件夹)"); return; } - - // 3. 获取下载地址 - getDownloadLinks(fileIds).onSuccess(downloadData -> { - if (downloadData.isEmpty()) { - fail("下载链接获取为空(31001)"); - return; + + log.debug("找到 {} 个文件", files.size()); + + // 提取第一个文件的信息并保存到 otherParam + try { + JsonObject firstFile = files.get(0); + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileId(firstFile.getString("fid")) + .setFileName(firstFile.getString("file_name")) + .setSize(firstFile.getLong("size", 0L)) + .setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L))) + .setFileType(firstFile.getBoolean("file", true) ? "file" : "folder") + .setCreateTime(formatEpochMs(firstFile.getLong("updated_at", 0L))) + .setUpdateTime(formatEpochMs(firstFile.getLong("updated_at", 0L))) + .setPanType(shareLinkInfo.getType()); + + // 保存到 otherParam,供 CacheServiceImpl 使用 + shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); + log.debug("夸克提取文件信息: {}", fileInfo.getFileName()); + } catch (Exception e) { + log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage()); + } + + // 提取文件ID列表 + List fileIds = new ArrayList<>(); + for (JsonObject file : files) { + String fid = file.getString("fid"); + if (fid != null && !fid.isEmpty()) { + fileIds.add(fid); } + } + + if (fileIds.isEmpty()) { + fail("无法提取文件ID"); + return; + } + + // 第三步:解析下载链接(先直链,必要时才转存) + JsonObject firstFileForDownload = files.get(0); + String firstFid = firstFileForDownload.getString("fid"); + String firstShareFidToken = firstFileForDownload.getString("share_fid_token"); - JsonObject firstItem = downloadData.get(0); - String downloadUrl = firstItem.getString("download_url"); - String fid = firstItem.getString("fid"); - JsonObject matchedFile = fileMap.get(fid); - - // 设置文件元数据 - if (matchedFile != null) { - FileInfo fileInfo = new FileInfo(); - fileInfo.setFileName(matchedFile.getString("file_name")) - .setSize(matchedFile.getLong("size", 0L)) - .setPanType(shareLinkInfo.getType()); - shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); - } - - // 【关键】必须透传与 API 请求一致的 Header - Map finalHeaders = new HashMap<>(); - finalHeaders.put("User-Agent", commonHeaders.get("User-Agent")); - finalHeaders.put("Cookie", commonHeaders.get(HttpHeaders.COOKIE)); - finalHeaders.put("Referer", "https://pan.quark.cn/"); - - completeWithMeta(downloadUrl, finalHeaders); - }).onFailure(t -> fail("下载直链请求失败: " + t.getMessage())); - }).onFailure(t -> fail("详情请求失败")); - }).onFailure(t -> fail("Token 请求失败")); - + resolveDownloadUrl(firstFid, pwdId, stoken, firstShareFidToken) + .onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null))) + .onFailure(handleFail("获取下载链接")); + + }).onFailure(handleFail(DETAIL_URL)); + }) + .onFailure(handleFail(TOKEN_URL)); + return promise.future(); } - private Future> getDownloadLinks(List fileIds) { - Promise> batchPromise = Promise.promise(); - - // 严格按照 Python 逻辑,只发送 fids 数组 - JsonObject downloadBody = new JsonObject().put("fids", new JsonArray(fileIds.subList(0, Math.min(fileIds.size(), BATCH_SIZE)))); + /** + * 统一下载入口(parse / parseById 共用): + *
    + *
  1. 先尝试分享直链——成功则无需转存
  2. + *
  3. 仅当接口明确要求转存(如 23018 大文件限制)才进入转存
  4. + *
  5. 转存前查本地 {@link #savedFileCache},命中则直接下,避免重复转存
  6. + *
  7. 转存时服务端 search_exit=true 会复用已有文件,不会建副本
  8. + *
+ */ + private Future resolveDownloadUrl(String fid, String pwdId, String stoken, String shareFidToken) { + return getShareDownloadLink(fid, pwdId, stoken, shareFidToken) + .recover(t -> { + if (!needsTransferFallback(t)) { + // 登录失效等错误直接失败,不盲目转存 + return Future.failedFuture(t); + } + log.warn("夸克分享直链不可用,按需转存: {}", t.getMessage()); + return saveAndDownload(pwdId, stoken, fid, shareFidToken); + }); + } + + /** + * 是否属于「必须转存」类错误。 + * 典型:code 23018 download file size limit(分享直链有大小限制)。 + */ + private static boolean needsTransferFallback(Throwable t) { + String msg = t == null || t.getMessage() == null ? "" : t.getMessage(); + return msg.contains("23018") + || msg.contains("size limit") + || msg.contains("download file size limit"); + } + + /** + * 直接从分享获取下载地址(无需转存的优先路径) + */ + private Future getShareDownloadLink(String fid, String pwdId, String stoken, String shareFidToken) { + Promise promise = Promise.promise(); + + JsonObject bodyJson = JsonObject.of() + .put("fids", JsonArray.of(fid)) + .put("pwd_id", pwdId) + .put("stoken", stoken); + + if (shareFidToken != null && !shareFidToken.isEmpty()) { + bodyJson.put("fids_token", JsonArray.of(shareFidToken)); + } client.postAbs(DOWNLOAD_URL) .addQueryParam("pr", "ucpro") .addQueryParam("fr", "pc") - .putHeaders(commonHeaders) - .sendJsonObject(downloadBody) + .putHeaders(header) + .sendJsonObject(bodyJson) .onSuccess(res -> { JsonObject resJson = asJson(res); - if (resJson.getInteger("code") == 0) { - List list = new ArrayList<>(); - JsonArray data = resJson.getJsonArray("data"); - for (int i = 0; i < data.size(); i++) list.add(data.getJsonObject(i)); - batchPromise.complete(list); - } else { - log.error("下载链接接口返回码: {}, 消息: {}", resJson.getInteger("code"), resJson.getString("message")); - batchPromise.fail("错误码: " + resJson.getInteger("code")); + Integer code = resJson.getInteger("code"); + + if (code != null && code == 31001) { + promise.fail("未登录或 Cookie 已失效"); + return; } + + if (code == null || code != 0) { + // 保留 code/message,供 needsTransferFallback 识别 23018 + promise.fail("分享直链获取失败(code=" + code + "): " + + resJson.getString("message") + " -> " + resJson); + return; + } + + JsonArray dataList = resJson.getJsonArray("data"); + if (dataList == null || dataList.isEmpty()) { + promise.fail("分享直链返回数据为空"); + return; + } + + String downloadUrl = dataList.getJsonObject(0).getString("download_url"); + if (downloadUrl == null || downloadUrl.isEmpty()) { + promise.fail("分享直链为空"); + return; + } + + log.debug("夸克分享直链成功(无需转存)"); + promise.complete(downloadUrl); }) - .onFailure(t -> batchPromise.fail(t.getMessage())); - - return batchPromise.future(); + .onFailure(t -> promise.fail("分享直链请求失败: " + t.getMessage())); + + return promise.future(); } + // 目录解析 @Override public Future> parseFileList() { - // 此处可复用 parse() 逻辑获取 stoken 并调用 detail 接口,代码略(保持原逻辑即可) - return Future.succeededFuture(new ArrayList<>()); + Promise> promise = Promise.promise(); + + String pwdId = shareLinkInfo.getShareKey(); + String passcode = shareLinkInfo.getSharePassword(); + final String finalPasscode = (passcode == null) ? "" : passcode; + + // 如果参数里的目录ID不为空,则直接解析目录 + String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); + if (dirId != null && !dirId.isEmpty()) { + String stoken = (String) shareLinkInfo.getOtherParam().get("stoken"); + if (stoken != null) { + parseDir(dirId, pwdId, finalPasscode, stoken, promise); + return promise.future(); + } + } + + // 第一步:获取 stoken + JsonObject tokenRequest = new JsonObject() + .put("pwd_id", pwdId) + .put("passcode", finalPasscode); + + client.postAbs(TOKEN_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .putHeaders(header) + .sendJsonObject(tokenRequest) + .onSuccess(res -> { + JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 0) { + promise.fail(TOKEN_URL + " 返回异常: " + resJson); + return; + } + JsonObject data = resJson.getJsonObject("data"); + if (data != null) { + String title = data.getString("title"); + if (title != null && !title.isBlank()) { + shareLinkInfo.getOtherParam().put("title", title); + } + } + String stoken = data == null ? null : data.getString("stoken"); + if (stoken == null || stoken.isEmpty()) { + promise.fail("无法获取分享 token"); + return; + } + // 解析根目录(dirId = "0") + String rootDirId = dirId != null ? dirId : "0"; + parseDir(rootDirId, pwdId, finalPasscode, stoken, promise); + }) + .onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage())); + + return promise.future(); + } + + private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise> promise) { + // 第二步:获取文件列表(支持指定目录) + // 夸克 API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0" + log.info("夸克 parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken); + + client.getAbs(DETAIL_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("pwd_id", pwdId) + .addQueryParam("stoken", stoken) + .addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID + .addQueryParam("force", "0") + .addQueryParam("_page", "1") + .addQueryParam("_size", "50") + .addQueryParam("_fetch_banner", "1") + .addQueryParam("_fetch_share", "1") + .addQueryParam("_fetch_total", "1") + .addQueryParam("_sort", "file_type:asc,file_name:asc") + .putHeaders(header) + .send() + .onSuccess(res -> { + JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 0) { + promise.fail(DETAIL_URL + " 返回异常: " + resJson); + return; + } + + JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list"); + if (fileList == null || fileList.isEmpty()) { + log.warn("夸克 API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily()); + promise.complete(new ArrayList<>()); + return; + } + + log.info("夸克 API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId); + List result = new ArrayList<>(); + for (int i = 0; i < fileList.size(); i++) { + JsonObject item = fileList.getJsonObject(i); + FileInfo fileInfo = new FileInfo(); + + // 调试:打印前3个 item 的完整结构 + if (i < 3) { + log.info("夸克 API 返回的 item[{}] 结构: {}", i, item.encodePrettily()); + log.info("夸克 API item[{}] 所有字段名: {}", i, item.fieldNames()); + } + + String fid = item.getString("fid"); + String fileName = item.getString("file_name"); + Boolean isFile = item.getBoolean("file", true); + Long fileSize = item.getLong("size", 0L); + String updatedAt = formatEpochMs(item.getLong("updated_at", 0L)); + String objCategory = item.getString("obj_category"); + String shareFidToken = item.getString("share_fid_token"); + String parentId = item.getString("parent_id"); + + log.info("处理夸克 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}, objCategory={}", + i, fid, fileName, parentId, dirId, isFile, objCategory); + + fileInfo.setFileId(fid) + .setFileName(fileName) + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setCreateTime(updatedAt) + .setUpdateTime(updatedAt) + .setPanType(shareLinkInfo.getType()); + + // 判断是否为文件:file=true 或 obj_category 不为空 + if (isFile || (objCategory != null && !objCategory.isEmpty())) { + // 文件 + fileInfo.setFileType("file"); + // 保存必要的参数用于后续下载 + Map extParams = new HashMap<>(); + extParams.put("fid", fid); + extParams.put("pwd_id", pwdId); + extParams.put("stoken", stoken); + if (shareFidToken != null) { + extParams.put("share_fid_token", shareFidToken); + } + extParams.put("needDownloader", true); + Map 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://pan.quark.cn/"); + extParams.put("downloadHeaders", dlHeaders); + fileInfo.setExtParameters(extParams); + // 设置解析URL(用于下载);透传 auth,避免大文件转存时 guest + JsonObject paramJson = new JsonObject(extParams); + paramJson.put("fileName", fileName); + String param = CommonUtils.urlBase64Encode(paramJson.encode()); + 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(递归子目录须透传 auth,否则会变成 guest) + 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(appendAuthQuery(String.format( + "%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", + getDomainName(), encodedUrl, encodedDirId, encodedStoken))); + } catch (Exception e) { + fileInfo.setParserUrl(appendAuthQuery(String.format( + "%s/v2/getFileList?url=%s&dirId=%s&stoken=%s", + getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken))); + } + } + + result.add(fileInfo); + } + + promise.complete(result); + }) + .onFailure(t -> promise.fail("解析目录失败: " + t.getMessage())); } @Override public Future parseById() { - // 与 parse() 中的下载逻辑一致 - return Future.succeededFuture(""); + Promise promise = Promise.promise(); + + // 从 paramJson 中提取参数 + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + if (paramJson == null) { + promise.fail("缺少必要的参数"); + return promise.future(); + } + + // 会话无 cookie 时,回退使用入口参数中已带的 cookie + ensureCookieFromParam(paramJson); + + String fid = paramJson.getString("fid"); + String pwdId = paramJson.getString("pwd_id"); + String stoken = paramJson.getString("stoken"); + String shareFidToken = paramJson.getString("share_fid_token"); + + if (fid == null || pwdId == null || stoken == null) { + promise.fail("缺少必要的参数: fid, pwd_id 或 stoken"); + return promise.future(); + } + + log.debug("夸克 parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken); + + resolveDownloadUrl(fid, pwdId, stoken, shareFidToken) + .onSuccess(downloadUrl -> { + Map downloadHeaders = buildDownloadHeaders(paramJson); + shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders); + shareLinkInfo.getOtherParam().put("fileName", paramJson.getString("fileName", "")); + shareLinkInfo.getOtherParam().put("needDownloader", true); + promise.complete(downloadUrl); + }) + .onFailure(promise::fail); + + 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 buildDownloadHeaders(JsonObject paramJson) { + Map 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://pan.quark.cn/"); + return downloadHeaders; + } + + /** + * 转存并下载(仅在 {@link #resolveDownloadUrl} 判定需要转存时调用)。 + *

防重复两层:

+ *
    + *
  • 本地 {@link #savedFileCache}:同一分享文件命中则跳过转存 API
  • + *
  • 服务端 search_exit:已存在则复用网盘内文件,不创建副本
  • + *
+ */ + private Future saveAndDownload(String pwdId, String stoken, String shareFid, String shareFidToken) { + Promise promise = Promise.promise(); + String fileCacheKey = pwdId + "_" + (shareFid == null ? "" : shareFid); + + // 本地缓存命中 → 跳过转存,直接用已转存 fid 取链 + String cachedFid = savedFileCache.get(fileCacheKey); + if (cachedFid != null && !cachedFid.isEmpty()) { + log.info("夸克:本地已有转存记录,跳过转存,直接取链。savedFid={}", cachedFid); + getDownloadLinkForSavedFile(cachedFid) + .onSuccess(promise::complete) + .onFailure(t -> { + log.warn("夸克:缓存的转存文件已失效,清除后重新转存: {}", t.getMessage()); + savedFileCache.remove(fileCacheKey); + doSaveAndDownload(pwdId, stoken, shareFid, shareFidToken, fileCacheKey, promise, false); + }); + return promise.future(); + } + + log.info("夸克:无本地转存缓存,执行转存(服务端仍会 search_exit 去重)..."); + doSaveAndDownload(pwdId, stoken, shareFid, shareFidToken, fileCacheKey, promise, false); + return promise.future(); + } + + /** + * 执行实际的转存和下载流程 + * @param isRetry 是否为重试(防止无限循环) + */ + private void doSaveAndDownload(String pwdId, String stoken, String shareFid, String shareFidToken, + String fileCacheKey, Promise promise, boolean isRetry) { + log.info("夸克转存下载:开始转存 shareFid={},isRetry={}", shareFid, isRetry); + + getSaveDirFid() + .compose(toPdirFid -> { + log.info("夸克转存目录: {}", toPdirFid); + return saveToCloud(pwdId, stoken, toPdirFid, shareFid, shareFidToken); + }) + .onSuccess(taskId -> { + log.info("夸克转存任务已创建: {}", taskId); + checkTaskStatus(taskId, 0) + .onSuccess(saveResult -> { + JsonObject saveAs = saveResult.getJsonObject("save_as"); + if (saveAs == null) { + promise.fail("转存成功但未返回 save_as"); + return; + } + JsonArray savedFids = saveAs.getJsonArray("save_as_top_fids"); + if (savedFids == null || savedFids.isEmpty()) { + promise.fail("转存成功但未找到文件ID"); + return; + } + + String savedFid = savedFids.getString(0); + // search_exit=true:服务端检测到已存在,复用已有文件(未创建新副本) + boolean searchExit = Boolean.TRUE.equals(saveAs.getBoolean("search_exit", false)); + if (searchExit) { + log.info("夸克转存:search_exit=true,复用已有文件 FID: {}", savedFid); + } else { + log.info("夸克转存成功(新文件),文件ID: {}", savedFid); + } + + savedFileCache.put(fileCacheKey, savedFid); + log.debug("夸克:已缓存转存映射 {} -> {}", fileCacheKey, savedFid); + + getDownloadLinkForSavedFile(savedFid) + .onSuccess(promise::complete) + .onFailure(t -> { + savedFileCache.remove(fileCacheKey); + if (searchExit) { + // dedup 指向的文件可能已进回收站,再转存仍会返回同一失效 fid + log.warn("夸克:search_exit 复用文件不可用,FID: {},错误: {}", savedFid, t.getMessage()); + promise.fail("转存文件不存在,可能已被移入回收站,请清空夸克网盘回收站后重试。原始错误: " + t.getMessage()); + } else if (!isRetry) { + log.warn("夸克:转存后取链失败: {},FID: {},重试一次...", t.getMessage(), savedFid); + doSaveAndDownload(pwdId, stoken, shareFid, shareFidToken, fileCacheKey, promise, true); + } else { + log.error("夸克:转存后取链仍失败(已重试): {},FID: {}", t.getMessage(), savedFid); + promise.fail("转存文件下载链接获取失败(已重试): " + t.getMessage()); + } + }); + }) + .onFailure(promise::fail); + }) + .onFailure(promise::fail); + } + + /** + * 获取夸克分享转存默认目录FID(通常为“来自:分享”) + */ + private Future getSaveDirFid() { + Promise promise = Promise.promise(); + + client.getAbs(SAVE_DIR_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("uc_param_str", "") + .addQueryParam("aver", "1") + .putHeaders(header) + .send() + .onSuccess(res -> { + JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 0) { + promise.fail("获取转存目录失败: " + resJson.getString("message")); + return; + } + + String pdirFid = resJson.getJsonObject("data").getString("pdir_fid"); + if (pdirFid == null || pdirFid.isEmpty()) { + promise.fail("获取转存目录失败: pdir_fid 为空"); + return; + } + promise.complete(pdirFid); + }) + .onFailure(t -> promise.fail("获取转存目录请求失败: " + t.getMessage())); + + return promise.future(); + } + + /** + * 转存文件到网盘。 + * 有 shareFid 时按指定文件转存(目录多文件场景);否则整包保存。 + * 服务端会通过 search_exit 自动去重,避免重复占空间。 + */ + private Future saveToCloud(String pwdId, String stoken, String toPdirFid, + String shareFid, String shareFidToken) { + Promise promise = Promise.promise(); + + JsonObject saveRequest = new JsonObject() + .put("pwd_id", pwdId) + .put("stoken", stoken) + .put("pdir_fid", "0") + .put("to_pdir_fid", toPdirFid) + .put("scene", "link"); + + if (shareFid != null && !shareFid.isEmpty()) { + saveRequest.put("fid_list", JsonArray.of(shareFid)); + if (shareFidToken != null && !shareFidToken.isEmpty()) { + saveRequest.put("fid_token_list", JsonArray.of(shareFidToken)); + } + saveRequest.put("pdir_save_all", false); + } else { + // 兼容:无指定文件时整包转存(服务端 search_exit 仍会去重) + saveRequest.put("pdir_save_all", true); + } + + log.debug("夸克转存请求: {}", saveRequest.encodePrettily()); + + client.postAbs(SAVE_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("uc_param_str", "") + .putHeaders(header) + .sendJsonObject(saveRequest) + .onSuccess(res -> { + log.debug("夸克转存响应: {}", res.bodyAsString()); + JsonObject resJson = asJson(res); + + if (resJson.getInteger("code") != 0) { + promise.fail("转存失败: " + resJson.getString("message")); + return; + } + + String taskId = resJson.getJsonObject("data").getString("task_id"); + if (taskId == null || taskId.isEmpty()) { + promise.fail("转存响应中未找到任务ID"); + return; + } + + promise.complete(taskId); + }) + .onFailure(t -> promise.fail("转存请求失败: " + t.getMessage())); + + return promise.future(); + } + + /** + * 查询任务状态(带重试) + * @param taskId 任务ID + * @param retryIndex 重试次数 + * @return Future 任务结果 + */ + private Future checkTaskStatus(String taskId, int retryIndex) { + Promise promise = Promise.promise(); + + // 最多重试20次,每次间隔1秒 + if (retryIndex > 20) { + promise.fail("任务查询超时,重试次数已达上限"); + return promise.future(); + } + + client.getAbs(TASK_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("uc_param_str", "") + .addQueryParam("task_id", taskId) + .addQueryParam("retry_index", String.valueOf(retryIndex)) + .putHeaders(header) + .send() + .onSuccess(res -> { + log.debug("夸克任务状态响应 (重试{}): {}", retryIndex, res.bodyAsString()); + JsonObject resJson = asJson(res); + + if (resJson.getInteger("code") != 0) { + promise.fail("查询任务状态失败: " + resJson.getString("message")); + return; + } + + JsonObject taskData = resJson.getJsonObject("data"); + Integer status = taskData.getInteger("status"); + + if (status == null) { + promise.fail("任务状态响应异常"); + return; + } + + // status: 0=待处理, 1=处理中, 2=已完成, 3=失败 + if (status == 2) { + log.info("夸克转存任务完成: taskId={}", taskId); + promise.complete(taskData); + } else if (status == 3) { + promise.fail("转存任务失败"); + } else { + // 任务未完成,1秒后重试 + log.debug("夸克转存任务进行中,状态: {}, 1秒后重试", status); + cn.qaiu.WebClientVertxInit.get().setTimer(1000, id -> { + checkTaskStatus(taskId, retryIndex + 1) + .onSuccess(promise::complete) + .onFailure(promise::fail); + }); + } + }) + .onFailure(t -> promise.fail("查询任务状态请求失败: " + t.getMessage())); + + return promise.future(); + } + + /** + * 获取转存文件的下载链接 + * @param fid 文件ID + * @return Future 下载链接 + */ + private Future getDownloadLinkForSavedFile(String fid) { + Promise promise = Promise.promise(); + + JsonObject downloadRequest = new JsonObject() + .put("fids", new JsonArray().add(fid)); + + client.postAbs(DOWNLOAD_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .putHeaders(header) + .sendJsonObject(downloadRequest) + .onSuccess(res -> { + log.debug("夸克转存文件下载链接响应: {}", res.bodyAsString()); + JsonObject resJson = asJson(res); + + if (resJson.getInteger("code") != 0) { + promise.fail("获取转存文件下载链接失败: " + resJson.getString("message")); + return; + } + + JsonArray dataList = resJson.getJsonArray("data"); + if (dataList == null || dataList.isEmpty()) { + promise.fail("转存文件下载链接列表为空"); + return; + } + + String downloadUrl = dataList.getJsonObject(0).getString("download_url"); + if (downloadUrl == null || downloadUrl.isEmpty()) { + promise.fail("转存文件下载链接为空"); + return; + } + + promise.complete(downloadUrl); + }) + .onFailure(t -> promise.fail("获取转存文件下载链接请求失败: " + t.getMessage())); + + return promise.future(); + } + + /** + * 将夸克 API 返回的 epoch 毫秒时间戳转换为 "yyyy-MM-dd HH:mm:ss" 格式字符串 + */ + private static String formatEpochMs(long epochMs) { + if (epochMs <= 0) return ""; + return LocalDateTime.ofInstant(Instant.ofEpochMilli(epochMs), ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } } \ No newline at end of file diff --git a/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java b/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java index 15f8953..c94bfcc 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/UcTool.java @@ -265,13 +265,8 @@ public class UcTool extends PanBase { return; } String downloadUrl = dataList.getJsonObject(0).getString("download_url"); - // UC网盘需要配合aria2下载,保存下载请求头 - Map 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 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))); } } @@ -510,6 +516,9 @@ public class UcTool extends PanBase { promise.fail("缺少必要的参数"); return promise.future(); } + + // 会话无 cookie 时,回退使用入口参数中已带的 cookie + ensureCookieFromParam(paramJson); String fid = paramJson.getString("fid"); String pwdId = paramJson.getString("pwd_id"); @@ -554,6 +563,11 @@ public class UcTool extends PanBase { promise.fail("未找到下载链接"); return; } + // 存储下载请求头,供目录解析 getFileDownInfo 接口使用 + // 优先用当前会话 cookie;缺失时回退入口参数中已带的 cookie + Map 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 buildDownloadHeaders(JsonObject paramJson) { + Map 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( diff --git a/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java b/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java index 27ef2d9..5fdb325 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java @@ -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) *

* 空间分享形如 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 mapFiles(Session session, int dirId, JsonObject filesResp) { + /** + * 将目录内文件按 zml(子目录)分层。 + *

    + *
  • zmlFilter 为空:返回子目录(folder)+ 根级文件
  • + *
  • zmlFilter 非空:仅返回该子目录下的文件/链接
  • + *
+ */ + private List mapFiles(Session session, int dirId, JsonObject filesResp, String zmlFilter) { List 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 subdirs = new java.util.LinkedHashSet<>(); + for (int i = 0; i < lb.size(); i++) { + JsonObject item = lb.getJsonObject(i); + if (item == null) { + continue; + } + 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 wjlx = item.getString("wjlx", ""); + 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 downloadableFiles(JsonObject filesResp) { List 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); } diff --git a/parser/src/main/java/cn/qaiu/util/CommonUtils.java b/parser/src/main/java/cn/qaiu/util/CommonUtils.java index d5391dd..afc4ec9 100644 --- a/parser/src/main/java/cn/qaiu/util/CommonUtils.java +++ b/parser/src/main/java/cn/qaiu/util/CommonUtils.java @@ -77,32 +77,57 @@ public class CommonUtils { } /** - * urlEncode -> deBase64 -> string - * @param encoded 编码后的字符串 - * @return 解码后的字符串 + * 解码路径参数中的 Base64。 + *

优先按 URL-Safe Base64 解;兼容历史「标准 Base64 + URLEncode」以及重复 encode。

*/ 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 的 Base64(URL-Safe,无 padding)。 + *

不再做 URLEncoder,避免前端/代理再 encode 时变成 %253D。

*/ 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); } } diff --git a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java index 768b071..0ed4ec6 100644 --- a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java +++ b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java @@ -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() { // 测试文件链接解析 diff --git a/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java b/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java index 60a42f7..2d87fde 100644 --- a/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java +++ b/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java @@ -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 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 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 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); } } diff --git a/pom.xml b/pom.xml index eb5abc4..a8834dd 100644 --- a/pom.xml +++ b/pom.xml @@ -17,7 +17,7 @@ - 0.4.1 + 0.4.2 17 17 17 diff --git a/web-front/package.json b/web-front/package.json index 6ce7199..e0de842 100644 --- a/web-front/package.json +++ b/web-front/package.json @@ -1,6 +1,6 @@ { "name": "nfd-web", - "version": "0.4.1", + "version": "0.4.2", "private": true, "scripts": { "serve": "vue-cli-service serve", diff --git a/web-front/src/components/DirectoryTree.vue b/web-front/src/components/DirectoryTree.vue index c907f4d..6e886b2 100644 --- a/web-front/src/components/DirectoryTree.vue +++ b/web-front/src/components/DirectoryTree.vue @@ -24,7 +24,7 @@ :class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]" @click="batchMode ? onBatchClick(file) : handleFileClick(file)" > -
+
@@ -52,9 +52,10 @@
浏览器下载 @@ -127,9 +128,10 @@ 浏览器下载 @@ -166,25 +168,41 @@

{{ selectedNode.fileName }}

-

类型: {{ getFileTypeClass(selectedNode) }}

-

大小: {{ selectedNode.sizeStr || '0B' }}

+

类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}

+

大小: {{ selectedNode.sizeStr || '0B' }}

+

创建时间: {{ formatDate(selectedNode.createTime) }}

更新时间: {{ formatDate(selectedNode.updateTime) }}

- + + 打开链接 + + 打开 下载 发送到下载器 复制直链 @@ -243,25 +263,41 @@

{{ selectedNode.fileName }}

-

类型: {{ getFileTypeClass(selectedNode) }}

-

大小: {{ selectedNode.sizeStr || '0B' }}

+

类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}

+

大小: {{ selectedNode.sizeStr || '0B' }}

+

创建时间: {{ formatDate(selectedNode.createTime) }}

更新时间: {{ formatDate(selectedNode.updateTime) }}

- + + 打开链接 + + 打开 下载 发送到下载器 复制直链 @@ -290,7 +328,7 @@ - 打开 - + + {{ selectedFile?.fileType === 'url' ? '打开链接' : '打开' }} + 下载 复制直链 @@ -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 本身可进 path;encodeURIComponent 对 - _ 无影响,只 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) { - 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 }) + 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; diff --git a/web-front/src/parserUrl1.js b/web-front/src/parserUrl1.js index 7c58aa2..c3e38a9 100644 --- a/web-front/src/parserUrl1.js +++ b/web-front/src/parserUrl1.js @@ -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\/.+/, diff --git a/web-front/src/utils/fileTypeUtils.js b/web-front/src/utils/fileTypeUtils.js index 5a26fa5..b38c75a 100644 --- a/web-front/src/utils/fileTypeUtils.js +++ b/web-front/src/utils/fileTypeUtils.js @@ -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', diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index 55fce9f..aca2358 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -131,10 +131,15 @@
下载链接
- - 下载 - - + + + 下载 + + + 预览 @@ -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,10 +1198,12 @@ export default { this.errorBadgeVisible = false try { this.isLoading = true - // 添加认证参数(异步获取) - const authParam = await this.generateAuthParam() - if (authParam) { - params.auth = authParam + // 添加认证参数(已有则不覆盖,便于目录树透传同一份 auth) + if (!params.auth) { + const authParam = await this.generateAuthParam() + if (authParam) { + params.auth = authParam + } } const response = await axios.get(`${this.baseAPI}${endpoint}`, { params }) @@ -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) diff --git a/web-service/src/main/java/cn/qaiu/lz/common/util/ParserAuthUtil.java b/web-service/src/main/java/cn/qaiu/lz/common/util/ParserAuthUtil.java index cd14629..2253d34 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/util/ParserAuthUtil.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/util/ParserAuthUtil.java @@ -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()); diff --git a/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java b/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java index 80b1d94..d0efac2 100644 --- a/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java +++ b/web-service/src/main/java/cn/qaiu/lz/common/util/URLParamUtil.java @@ -68,9 +68,10 @@ public class URLParamUtil { boolean firstParam = !decodedUrl.contains("?"); for (String paramName : params.names()) { - // 忽略 "url", "pwd", "dirId", "uuid", "auth", "stoken" 参数(这些参数单独处理,不应拼接到分享URL中) - if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId") - && !paramName.equals("uuid") && !paramName.equals("auth") && !paramName.equals("stoken")) { + // 忽略单独处理的参数,不应拼接到分享URL中 + if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId") + && !paramName.equals("uuid") && !paramName.equals("auth") + && !paramName.equals("stoken") && !paramName.equals("zml")) { if (firstParam) { urlBuilder.append("?"); firstParam = false; diff --git a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java index 9e71e41..45ffb7c 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/controller/ParserApi.java @@ -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> 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 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 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 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 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 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 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); + } + } + } + /** * 预览媒体文件 diff --git a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java index a6b5513..be4f0f4 100644 --- a/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java +++ b/web-service/src/main/java/cn/qaiu/lz/web/service/impl/CacheServiceImpl.java @@ -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);