From fdf067c25e9dd15f35b8e0e905509fb2eb7f4e74 Mon Sep 17 00:00:00 2001 From: q Date: Sun, 22 Feb 2026 19:15:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20=E5=A4=B8=E5=85=8B?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E3=80=81=E5=B0=8F=E9=A3=9E=E6=9C=BA=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=8C=E5=89=8D=E7=AB=AF=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - .../cn/qaiu/parser/impl/IzSelectorTool.java | 55 ++ .../main/java/cn/qaiu/parser/impl/IzTool.java | 620 ++++++++++++----- .../cn/qaiu/parser/impl/IzToolWithAuth.java | 658 ++++++++++++++++++ .../main/java/cn/qaiu/parser/impl/QkTool.java | 211 +++--- web-front/src/views/Home.vue | 5 +- web-service/src/main/resources/secret.yml | 4 + 7 files changed, 1297 insertions(+), 257 deletions(-) create mode 100644 parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java create mode 100644 parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java create mode 100644 web-service/src/main/resources/secret.yml diff --git a/.gitignore b/.gitignore index e0e6875..264979c 100644 --- a/.gitignore +++ b/.gitignore @@ -56,7 +56,6 @@ test-filelist.java *.temp *.log *.bak -**/secret.yml *.swp *.swo *~ diff --git a/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java b/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java new file mode 100644 index 0000000..7e3b731 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/impl/IzSelectorTool.java @@ -0,0 +1,55 @@ +package cn.qaiu.parser.impl; + +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.IPanTool; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; + +/** + * 蓝奏云优享解析器选择器 + * 根据配置的鉴权方式选择不同的解析器: + * - 如果配置了 username 和 password,则使用 IzToolWithAuth (支持大文件) + * - 否则使用 IzTool (免登录,仅支持小文件) + */ +public class IzSelectorTool implements IPanTool { + private final IPanTool selectedTool; + + public IzSelectorTool(ShareLinkInfo shareLinkInfo) { + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + + // 检查是否配置了账号密码 + if (auths.contains("username") && auths.contains("password")) { + String username = auths.get("username"); + String password = auths.get("password"); + if (StringUtils.isNotEmpty(username) && StringUtils.isNotEmpty(password)) { + // 使用 IzToolWithAuth (账密登录,支持大文件) + this.selectedTool = new IzToolWithAuth(shareLinkInfo); + return; + } + } + } + + // 无认证信息或认证信息无效,使用免登录版本(仅支持小文件) + this.selectedTool = new IzTool(shareLinkInfo); + } + + @Override + public Future parse() { + return selectedTool.parse(); + } + + @Override + public Future> parseFileList() { + return selectedTool.parseFileList(); + } + + @Override + public Future parseById() { + return selectedTool.parseById(); + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java b/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java index 5180a0e..d8c9ac8 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/IzTool.java @@ -5,35 +5,50 @@ import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; import cn.qaiu.util.AESUtils; import cn.qaiu.util.AcwScV2Generator; +import cn.qaiu.util.CommonUtils; import cn.qaiu.util.FileSizeConverter; import io.netty.handler.codec.http.cookie.DefaultCookie; import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpRequest; +import io.vertx.ext.web.client.HttpResponse; import io.vertx.ext.web.client.WebClientSession; import io.vertx.uritemplate.UriTemplate; import org.apache.commons.lang3.StringUtils; -import java.util.*; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; /** * 蓝奏云优享 - * v019b22 + * */ public class IzTool extends PanBase { - WebClientSession webClientSession = WebClientSession.create(clientNoRedirects); - + private static final String API_URL0 = "https://api.ilanzou.com/"; private static final String API_URL_PREFIX = "https://api.ilanzou.com/unproved/"; private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" + "&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60"; + private static final String LOGIN_URL = API_URL_PREFIX + + "login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken=&extra=2"; + + // https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2×tamp=EC2C6E7F45EB21338A17A7621E0BB437 + private static final String TOKEN_VERIFY_URL = API_URL0 + + "proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}"; + private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" + "&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}"; - // downloadId=x&enable=1&devType=6&uuid=x×tamp=x&auth=x&shareId=lGFndCM + + private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" + + "&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken={appToken}&enable=1&downloadId={fidEncode}&auth={auth}"; + private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" + "={uuid}&extra=2×tamp={ts}"; @@ -42,16 +57,15 @@ public class IzTool extends PanBase { "={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" + "={folderId}&offset=1&limit=60"; - long nowTs = System.currentTimeMillis(); - String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs)); - String uuid = UUID.randomUUID().toString(); + + WebClientSession webClientSession = WebClientSession.create(clientNoRedirects); private static final MultiMap header; static { header = MultiMap.caseInsensitiveMultiMap(); header.set("Accept", "application/json, text/plain, */*"); - header.set("Accept-Encoding", "gzip, deflate, br, zstd"); + header.set("Accept-Encoding", "gzip, deflate"); header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); header.set("Cache-Control", "no-cache"); header.set("Connection", "keep-alive"); @@ -69,38 +83,59 @@ public class IzTool extends PanBase { header.set("sec-ch-ua-mobile", "?0"); header.set("sec-ch-ua-platform", "\"Windows\""); } - public IzTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); } - private void setCookie(String html) { - int beginIndex = html.indexOf("arg1='") + 6; - String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex)); - String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1); - // 创建一个 Cookie 并放入 CookieStore - DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2); - nettyCookie.setDomain(".ilanzou.com"); // 设置域名 - nettyCookie.setPath("/"); // 设置路径 - nettyCookie.setSecure(false); - nettyCookie.setHttpOnly(false); - webClientSession.cookieStore().put(nettyCookie); - } + String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString() + + public static String token = null; + public static boolean authFlag = true; public Future parse() { - String shareId = shareLinkInfo.getShareKey(); - // 24.5.12 ilanzou改规则无需计算shareId - // String shareId = String.valueOf(AESUtils.idEncryptIz(dataKey)); + String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey)); + long nowTs = System.currentTimeMillis(); + String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs)); - // 第一次请求 获取文件信息 - // POST https://api.ilanzou.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60 - String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL - : (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword()); + // 检查并输出认证状态 + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}", + isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(10, token.length())) + "...)" : "未登录"); + + // 如果需要认证但还没有token,先执行登录 + if ((isTempAuth || authFlag) && token == null) { + log.info("文件解析需要登录,开始执行登录流程..."); + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + return login(tsEncode, auths) + .compose(v -> { + log.info("文件解析预登录成功,继续解析流程"); + return parseWithAuth(shareId, tsEncode); + }) + .onFailure(err -> { + log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage()); + // 登录失败,继续使用免登录模式 + }); + } else if (token != null) { + log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(10, token.length()))); + } + } else { + log.debug("文件解析无认证信息,使用免登录模式"); + } + + return parseWithAuth(shareId, tsEncode); + } + + private Future parseWithAuth(String shareId, String tsEncode) { + // 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口 webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL)) .setTemplateParam("uuid", uuid) .setTemplateParam("ts", tsEncode) .send().onSuccess(r0 -> { // 忽略res + + String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL + : (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword()); // 第一次请求 获取文件信息 // POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60 webClientSession.postAbs(UriTemplate.of(url)) @@ -121,36 +156,43 @@ public class IzTool extends PanBase { .setTemplateParam("uuid", uuid) .setTemplateParam("ts", tsEncode) .send().onSuccess(res2 -> { - handleParseResponse(asText(res2), shareId); - }).onFailure(handleFail(FIRST_REQUEST_URL)); + processFirstResponse(res2); + }).onFailure(handleFail("请求1-重试")); return; } - handleParseResponse(resBody, shareId); - }).onFailure(handleFail(FIRST_REQUEST_URL)); - }); + processFirstResponse(res); + }).onFailure(handleFail("请求1")); + + }).onFailure(handleFail("请求1")); + return promise.future(); } - private void handleParseResponse(String resBody, String shareId) { - JsonObject resJson; - try { - resJson = new JsonObject(resBody); - } catch (Exception e) { - fail(FIRST_REQUEST_URL + " 解析JSON失败: " + resBody); - return; - } - if (resJson.isEmpty()) { - fail(FIRST_REQUEST_URL + " 返回内容为空"); - return; - } + /** + * 设置 cookie + */ + private void setCookie(String html) { + int beginIndex = html.indexOf("arg1='") + 6; + String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex)); + String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1); + // 创建一个 Cookie 并放入 CookieStore + DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2); + nettyCookie.setDomain(".ilanzou.com"); // 设置域名 + nettyCookie.setPath("/"); // 设置路径 + nettyCookie.setSecure(false); + nettyCookie.setHttpOnly(false); + webClientSession.cookieStore().put(nettyCookie); + } + + /** + * 处理第一次请求的响应 + */ + private void processFirstResponse(HttpResponse res) { + JsonObject resJson = asJson(res); if (resJson.getInteger("code") != 200) { fail(FIRST_REQUEST_URL + " 返回异常: " + resJson); return; } - if (resJson.getJsonArray("list").isEmpty()) { - fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson); - return; - } if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) { fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson); return; @@ -167,30 +209,251 @@ public class IzTool extends PanBase { promise.complete(fileList.getInteger("folderId").toString()); return; } + // 提取文件信息 + extractFileInfo(fileList, fileInfo); + getDownURL(resJson); + } + private void getDownURL(JsonObject resJson) { + String dataKey = shareLinkInfo.getShareKey(); + // 文件Id + JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0); String fileId = fileInfo.getString("fileIds"); String userId = fileInfo.getString("userId"); // 其他参数 - // String fidEncode = AESUtils.encrypt2HexIz(fileId + "|"); + long nowTs2 = System.currentTimeMillis(); + String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2)); String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId); - String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs); - // 第二次请求 - webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) - .setTemplateParam("fidEncode", fidEncode) - .setTemplateParam("uuid", uuid) - .setTemplateParam("ts", tsEncode) - .setTemplateParam("auth", auth) - .setTemplateParam("shareId", shareId) - .putHeaders(header).send().onSuccess(res2 -> { - MultiMap headers = res2.headers(); - if (!headers.contains("Location")) { - fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + headers); - return; - } - promise.complete(headers.get("Location")); - }).onFailure(handleFail(SECOND_REQUEST_URL)); + String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2); + + // 检查是否有认证信息 + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + // 检查是否为临时认证(临时认证每次都尝试登录) + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + // 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证 + if (isTempAuth || authFlag) { + log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag); + HttpRequest httpRequest = + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP)) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey); + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + if (token == null) { + // 执行登录 + login(tsEncode2, auths).onFailure(failRes-> { + log.warn("登录失败: {}", failRes.getMessage()); + fail(failRes.getMessage()); + }).onSuccess(r-> { + httpRequest.setTemplateParam("appToken", header.get("appToken")) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + }); + } else { + // 验证token + webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL)) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header).send().onSuccess(res -> { + // log.info("res: {}",asJson(res)); + if (asJson(res).getInteger("code") != 200) { + login(tsEncode2, auths).onFailure(failRes -> { + log.warn("重新登录失败: {}", failRes.getMessage()); + fail(failRes.getMessage()); + }).onSuccess(r-> { + httpRequest.setTemplateParam("appToken", header.get("appToken")) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + }); + } else { + httpRequest.setTemplateParam("appToken", header.get("appToken")) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + } + }).onFailure(handleFail("Token验证")); + } + } else { + // authFlag 为 false,使用免登录解析 + log.debug("authFlag=false,使用免登录解析"); + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey).send() + .onSuccess(this::down).onFailure(handleFail("请求2")); + } + } else { + // 没有认证信息,使用免登录解析 + log.debug("无认证信息,使用免登录解析"); + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey).send() + .onSuccess(this::down).onFailure(handleFail("请求2")); + } } + private Future login(String tsEncode2, MultiMap auths) { + Promise promise1 = Promise.promise(); + webClientSession.postAbs(UriTemplate.of(LOGIN_URL)) + .setTemplateParam("uuid",uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header) + .sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password"))) + .onSuccess(res2->{ + JsonObject json = asJson(res2); + if (json.getInteger("code") == 200) { + token = json.getJsonObject("data").getString("appToken"); + header.set("appToken", token); + log.info("登录成功 token: {}", token); + promise1.complete(); + } else { + // 检查是否为临时认证 + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + if (isTempAuth) { + // 临时认证失败,直接返回错误,不影响后台配置的认证 + log.warn("临时认证失败: {}", json.getString("msg")); + promise1.fail("临时认证失败: " + json.getString("msg")); + } else { + // 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析 + log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg")); + authFlag = false; + promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式"); + } + } + }).onFailure(err -> { + log.error("登录请求异常: {}", err.getMessage()); + promise1.fail("登录请求异常: " + err.getMessage()); + }); + return promise1.future(); + } + + /** + * 从接口返回数据中提取文件信息 + */ + private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) { + try { + // 文件名 + String fileName = fileList.getString("fileName"); + shareLinkInfo.getOtherParam().put("fileName", fileName); + + // 文件大小 (KB -> Bytes) + Long fileSize = fileList.getLong("fileSize", 0L) * 1024; + shareLinkInfo.getOtherParam().put("fileSize", fileSize); + shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize)); + + // 文件图标 + String fileIcon = fileList.getString("fileIcon"); + if (StringUtils.isNotBlank(fileIcon)) { + shareLinkInfo.getOtherParam().put("fileIcon", fileIcon); + } + + // 文件ID + Long fileId = fileList.getLong("fileId"); + if (fileId != null) { + shareLinkInfo.getOtherParam().put("fileId", fileId.toString()); + } + + // 文件类型 (1=文件, 2=目录) + Integer fileType = fileList.getInteger("fileType", 1); + shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder"); + + // 下载次数 + Integer downloads = fileList.getInteger("fileDownloads", 0); + shareLinkInfo.getOtherParam().put("downloadCount", downloads); + + // 点赞数 + Integer likes = fileList.getInteger("fileLikes", 0); + shareLinkInfo.getOtherParam().put("likeCount", likes); + + // 评论数 + Integer comments = fileList.getInteger("fileComments", 0); + shareLinkInfo.getOtherParam().put("commentCount", comments); + + // 评分 + Double stars = fileList.getDouble("fileStars", 0.0); + shareLinkInfo.getOtherParam().put("stars", stars); + + // 更新时间 + String updateTime = fileList.getString("updTime"); + if (StringUtils.isNotBlank(updateTime)) { + shareLinkInfo.getOtherParam().put("updateTime", updateTime); + } + + // 创建时间 + String createTime = null; + + // 分享信息 + if (shareInfo != null) { + // 分享ID + Integer shareId = shareInfo.getInteger("shareId"); + if (shareId != null) { + shareLinkInfo.getOtherParam().put("shareId", shareId.toString()); + } + + // 上传时间 + String addTime = shareInfo.getString("addTime"); + if (StringUtils.isNotBlank(addTime)) { + shareLinkInfo.getOtherParam().put("createTime", addTime); + createTime = addTime; + } + + // 预览次数 + Integer previewNum = shareInfo.getInteger("previewNum", 0); + shareLinkInfo.getOtherParam().put("previewCount", previewNum); + + // 用户信息 + JsonObject userMap = shareInfo.getJsonObject("map"); + if (userMap != null) { + String userName = userMap.getString("userName"); + if (StringUtils.isNotBlank(userName)) { + shareLinkInfo.getOtherParam().put("userName", userName); + } + + // VIP信息 + Integer isVip = userMap.getInteger("isVip", 0); + shareLinkInfo.getOtherParam().put("isVip", isVip == 1); + } + } + + // 创建 FileInfo 对象并存入 otherParam + FileInfo fileInfoObj = new FileInfo() + .setPanType(shareLinkInfo.getType()) + .setFileName(fileName) + .setFileId(fileList.getLong("fileId") != null ? fileList.getLong("fileId").toString() : null) + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setFileType(fileType == 1 ? "file" : "folder") + .setFileIcon(fileList.getString("fileIcon")) + .setDownloadCount(downloads) + .setCreateTime(createTime) + .setUpdateTime(updateTime); + shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj); + + log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}", + fileName, fileSize, downloads); + } catch (Exception e) { + log.warn("提取文件信息失败: {}", e.getMessage()); + } + } + + private void down(HttpResponse res2) { + MultiMap headers = res2.headers(); + if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) { + fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误"); + return; + } + promise.complete(headers.get("Location")); + } + + // 目录解析 @Override public Future> parseFileList() { Promise> promise = Promise.promise(); @@ -205,11 +468,7 @@ public class IzTool extends PanBase { return promise.future(); } parse().onSuccess(id -> { - if (id != null && id.matches("^[a-zA-Z0-9]+$")) { - parserDir(id, shareId, promise); - } else { - promise.fail("解析目录ID失败"); - } + parserDir(id, shareId, promise); }).onFailure(failRes -> { log.error("解析目录失败: {}", failRes.getMessage()); promise.fail(failRes); @@ -218,6 +477,22 @@ public class IzTool extends PanBase { } private void parserDir(String id, String shareId, Promise> promise) { + if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileName(id) + .setFileId(id) + .setFileType("file") + .setParserUrl(id) + .setPanType(shareLinkInfo.getType()); + List result = new ArrayList<>(); + result.add(fileInfo); + promise.complete(result); + return; + } + + long nowTs = System.currentTimeMillis(); + String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs)); + log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode); // 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860 // 拿到目录ID @@ -228,103 +503,134 @@ public class IzTool extends PanBase { .setTemplateParam("ts", tsEncode) .setTemplateParam("folderId", id) .send().onSuccess(res -> { - JsonObject jsonObject; - try { - jsonObject = asJson(res); - } catch (Exception e) { - promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString()); + String resBody = asText(res); + // 检查是否包含 cookie 验证 + if (resBody.contains("var arg1='")) { + log.debug("目录解析需要 cookie 验证,重新创建 session"); + webClientSession = WebClientSession.create(clientNoRedirects); + setCookie(resBody); + // 重新请求目录列表 + webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL)) + .putHeaders(header) + .setTemplateParam("shareId", shareId) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode) + .setTemplateParam("folderId", id) + .send().onSuccess(res2 -> { + processDirResponse(res2, shareId, promise); + }).onFailure(err -> { + log.error("目录解析重试失败: {}", err.getMessage()); + promise.fail("目录解析失败: " + err.getMessage()); + }); return; } - // System.out.println(jsonObject.encodePrettily()); - JsonArray list = jsonObject.getJsonArray("list"); - ArrayList result = new ArrayList<>(); - list.forEach(item->{ - JsonObject fileJson = (JsonObject) item; - FileInfo fileInfo = new FileInfo(); - - // 映射已知字段 - String fileId = fileJson.getString("fileId"); - String userId = fileJson.getString("userId"); - - // 回传用到的参数 - //"fidEncode", paramJson.getString("fidEncode")) - //"uuid", paramJson.getString("uuid")) - //"ts", paramJson.getString("ts")) - //"auth", paramJson.getString("auth")) - //"shareId", paramJson.getString("shareId")) - String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId); - String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs); - JsonObject entries = JsonObject.of( - "fidEncode", fidEncode, - "uuid", uuid, - "ts", tsEncode, - "auth", auth, - "shareId", shareId); - byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes()); - String param = new String(encode); - - if (fileJson.getInteger("fileType") == 2) { - // 如果是目录 - fileInfo.setFileName(fileJson.getString("name")) - .setFileId(fileJson.getString("folderId")) - .setCreateTime(fileJson.getString("updTime")) - .setFileType("folder") - .setSize(0L) - .setSizeStr("0B") - .setCreateBy(fileJson.getLong("userId").toString()) - .setDownloadCount(fileJson.getInteger("fileDownloads")) - .setCreateTime(fileJson.getString("updTime")) - .setFileIcon(fileJson.getString("fileIcon")) - .setPanType(shareLinkInfo.getType()) - // 设置目录解析的URL - .setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(), - shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid)); - result.add(fileInfo); - return; - } - long fileSize = fileJson.getLong("fileSize") * 1024; - fileInfo.setFileName(fileJson.getString("fileName")) - .setFileId(fileId) - .setCreateTime(fileJson.getString("createTime")) - .setFileType("file") - .setSize(fileSize) - .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) - .setCreateBy(fileJson.getLong("userId").toString()) - .setDownloadCount(fileJson.getInteger("fileDownloads")) - .setCreateTime(fileJson.getString("updTime")) - .setFileIcon(fileJson.getString("fileIcon")) - .setPanType(shareLinkInfo.getType()) - .setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(), - shareLinkInfo.getType(), param)) - .setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(), - shareLinkInfo.getType(), param)); - result.add(fileInfo); - }); - promise.complete(result); - }).onFailure(failRes -> { - log.error("解析目录请求失败: {}", failRes.getMessage()); - promise.fail(failRes); + processDirResponse(res, shareId, promise); + }).onFailure(err -> { + log.error("目录解析请求失败: {}", err.getMessage()); + promise.fail("目录解析失败: " + err.getMessage()); }); } + /** + * 处理目录解析响应 + */ + private void processDirResponse(HttpResponse res, String shareId, Promise> promise) { + try { + JsonObject jsonObject = asJson(res); + log.debug("目录解析响应: {}", jsonObject.encodePrettily()); + + if (!jsonObject.containsKey("list")) { + log.error("目录解析响应缺少 list 字段: {}", jsonObject); + promise.fail("目录解析失败: 响应格式错误"); + return; + } + + JsonArray list = jsonObject.getJsonArray("list"); + ArrayList result = new ArrayList<>(); + list.forEach(item->{ + JsonObject fileJson = (JsonObject) item; + FileInfo fileInfo = new FileInfo(); + + // 映射已知字段 + String fileId = fileJson.getString("fileId"); + String userId = fileJson.getString("userId"); + + // 其他参数 - 每个文件使用新的时间戳 + long nowTs2 = System.currentTimeMillis(); + String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2)); + String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId); + String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2); + + // 回传用到的参数 + JsonObject entries = JsonObject.of( + "fidEncode", fidEncode, + "uuid", uuid, + "ts", tsEncode2, + "auth", auth, + "shareId", shareId); + String param = CommonUtils.urlBase64Encode(entries.encode()); + + if (fileJson.getInteger("fileType") == 2) { + // 如果是目录 + fileInfo.setFileName(fileJson.getString("name")) + .setFileId(fileJson.getString("folderId")) + .setCreateTime(fileJson.getString("updTime")) + .setFileType("folder") + .setSize(0L) + .setSizeStr("0B") + .setCreateBy(fileJson.getLong("userId").toString()) + .setDownloadCount(fileJson.getInteger("fileDownloads")) + .setCreateTime(fileJson.getString("updTime")) + .setFileIcon(fileJson.getString("fileIcon")) + .setPanType(shareLinkInfo.getType()) + // 设置目录解析的URL + .setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(), + shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid)); + result.add(fileInfo); + return; + } + long fileSize = fileJson.getLong("fileSize") * 1024; + fileInfo.setFileName(fileJson.getString("fileName")) + .setFileId(fileId) + .setCreateTime(fileJson.getString("createTime")) + .setFileType("file") + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setCreateBy(fileJson.getLong("userId").toString()) + .setDownloadCount(fileJson.getInteger("fileDownloads")) + .setCreateTime(fileJson.getString("updTime")) + .setFileIcon(fileJson.getString("fileIcon")) + .setPanType(shareLinkInfo.getType()) + .setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(), + shareLinkInfo.getType(), param)) + .setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(), + shareLinkInfo.getType(), param)); + result.add(fileInfo); + }); + promise.complete(result); + } catch (Exception e) { + log.error("处理目录响应异常: {}", e.getMessage(), e); + promise.fail("目录解析失败: " + e.getMessage()); + } + } + @Override public Future parseById() { - // 第二次请求 - JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson"); + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + // 使用免登录接口 webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) .setTemplateParam("fidEncode", paramJson.getString("fidEncode")) .setTemplateParam("uuid", paramJson.getString("uuid")) .setTemplateParam("ts", paramJson.getString("ts")) .setTemplateParam("auth", paramJson.getString("auth")) - .setTemplateParam("shareId", paramJson.getString("shareId")) - .putHeaders(header).send().onSuccess(res2 -> { - MultiMap headers = res2.headers(); - if (!headers.contains("Location")) { - fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers()); - return; - } - promise.complete(headers.get("Location")); - }).onFailure(handleFail(SECOND_REQUEST_URL)); + .setTemplateParam("dataKey", paramJson.getString("shareId")) + .send().onSuccess(this::down).onFailure(handleFail("parseById")); return promise.future(); } + + public static void resetToken() { + token = null; + authFlag = true; + } } diff --git a/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java b/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java new file mode 100644 index 0000000..459e8d7 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/impl/IzToolWithAuth.java @@ -0,0 +1,658 @@ +package cn.qaiu.parser.impl; + +import cn.qaiu.entity.FileInfo; +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.PanBase; +import cn.qaiu.util.AESUtils; +import cn.qaiu.util.AcwScV2Generator; +import cn.qaiu.util.CommonUtils; +import cn.qaiu.util.FileSizeConverter; +import io.netty.handler.codec.http.cookie.DefaultCookie; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Promise; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.client.HttpRequest; +import io.vertx.ext.web.client.HttpResponse; +import io.vertx.ext.web.client.WebClientSession; +import io.vertx.uritemplate.UriTemplate; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 蓝奏云优享 - 需要登录版本(支持大文件) + */ +public class IzToolWithAuth extends PanBase { + + private static final String API_URL0 = "https://api.ilanzou.com/"; + private static final String API_URL_PREFIX = "https://api.ilanzou.com/unproved/"; + + private static final String FIRST_REQUEST_URL = API_URL_PREFIX + "recommend/list?devType=6&devModel=Chrome" + + "&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60"; + + private static final String LOGIN_URL = API_URL_PREFIX + + "login?uuid={uuid}&devType=6&devCode={uuid}&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken=&extra=2"; + + // https://api.ilanzou.com/proved/user/info/map?devType=3&devModel=Chrome&uuid=TInRHH3QzRaMo-Ajl2PkJ&extra=2×tamp=EC2C6E7F45EB21338A17A7621E0BB437 + private static final String TOKEN_VERIFY_URL = API_URL0 + + "proved/user/info/map?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}"; + + private static final String SECOND_REQUEST_URL = API_URL_PREFIX + "file/redirect?downloadId={fidEncode}&enable=1" + + "&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}"; + + private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX + "file/redirect?uuid={uuid}&devType=6&devCode={uuid}" + + "&devModel=chrome&devVersion=127&appVersion=×tamp={ts}&appToken={appToken}&enable=1&downloadId={fidEncode}&auth={auth}"; + + + private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" + + "={uuid}&extra=2×tamp={ts}"; + + private static final String FILE_LIST_URL = API_URL_PREFIX + "/share/list?devType=6&devModel=Chrome&uuid" + + "={uuid}&extra=2×tamp={ts}&shareId={shareId}&folderId" + + "={folderId}&offset=1&limit=60"; + + + WebClientSession webClientSession = WebClientSession.create(clientNoRedirects); + + private static final MultiMap header; + + static { + header = MultiMap.caseInsensitiveMultiMap(); + header.set("Accept", "application/json, text/plain, */*"); + header.set("Accept-Encoding", "gzip, deflate"); + header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8"); + header.set("Cache-Control", "no-cache"); + header.set("Connection", "keep-alive"); + header.set("Content-Length", "0"); + header.set("DNT", "1"); + header.set("Host", "api.ilanzou.com"); + header.set("Origin", "https://www.ilanzou.com/"); + header.set("Pragma", "no-cache"); + header.set("Referer", "https://www.ilanzou.com/"); + header.set("Sec-Fetch-Dest", "empty"); + header.set("Sec-Fetch-Mode", "cors"); + header.set("Sec-Fetch-Site", "cross-site"); + header.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"); + header.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\""); + header.set("sec-ch-ua-mobile", "?0"); + header.set("sec-ch-ua-platform", "\"Windows\""); + } + public IzToolWithAuth(ShareLinkInfo shareLinkInfo) { + super(shareLinkInfo); + } + + String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString() + + public static String token = null; + public static boolean authFlag = true; + + public Future parse() { + + String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey)); + long nowTs = System.currentTimeMillis(); + String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs)); + + // 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口 + webClientSession.postAbs(UriTemplate.of(VIP_REQUEST_URL)) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode) + .send().onSuccess(r0 -> { // 忽略res + + String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL + : (FIRST_REQUEST_URL + "&code=" + shareLinkInfo.getSharePassword()); + // 第一次请求 获取文件信息 + // POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60 + webClientSession.postAbs(UriTemplate.of(url)) + .putHeaders(header) + .setTemplateParam("shareId", shareId) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode) + .send().onSuccess(res -> { + String resBody = asText(res); + // 检查是否包含 cookie 验证 + if (resBody.contains("var arg1='")) { + webClientSession = WebClientSession.create(clientNoRedirects); + setCookie(resBody); + // 重新请求 + webClientSession.postAbs(UriTemplate.of(url)) + .putHeaders(header) + .setTemplateParam("shareId", shareId) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode) + .send().onSuccess(res2 -> { + processFirstResponse(res2); + }).onFailure(handleFail("请求1-重试")); + return; + } + processFirstResponse(res); + }).onFailure(handleFail("请求1")); + + }).onFailure(handleFail("请求1")); + + return promise.future(); + } + + /** + * 设置 cookie + */ + private void setCookie(String html) { + int beginIndex = html.indexOf("arg1='") + 6; + String arg1 = html.substring(beginIndex, html.indexOf("';", beginIndex)); + String acw_sc__v2 = AcwScV2Generator.acwScV2Simple(arg1); + // 创建一个 Cookie 并放入 CookieStore + DefaultCookie nettyCookie = new DefaultCookie("acw_sc__v2", acw_sc__v2); + nettyCookie.setDomain(".ilanzou.com"); // 设置域名 + nettyCookie.setPath("/"); // 设置路径 + nettyCookie.setSecure(false); + nettyCookie.setHttpOnly(false); + webClientSession.cookieStore().put(nettyCookie); + } + + /** + * 处理第一次请求的响应 + */ + private void processFirstResponse(HttpResponse res) { + JsonObject resJson = asJson(res); + if (resJson.getInteger("code") != 200) { + fail(FIRST_REQUEST_URL + " 返回异常: " + resJson); + return; + } + if (!resJson.containsKey("list") || resJson.getJsonArray("list").isEmpty()) { + fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson); + return; + } + // 文件Id + JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0); + // 如果是目录返回目录ID + if (!fileInfo.containsKey("fileList") || fileInfo.getJsonArray("fileList").isEmpty()) { + fail(FIRST_REQUEST_URL + " 文件列表为空: " + fileInfo); + return; + } + JsonObject fileList = fileInfo.getJsonArray("fileList").getJsonObject(0); + if (fileList.getInteger("fileType") == 2) { + promise.complete(fileList.getInteger("folderId").toString()); + return; + } + // 提取文件信息 + extractFileInfo(fileList, fileInfo); + getDownURL(resJson); + } + + private void getDownURL(JsonObject resJson) { + String dataKey = shareLinkInfo.getShareKey(); + // 文件Id + JsonObject fileInfo = resJson.getJsonArray("list").getJsonObject(0); + String fileId = fileInfo.getString("fileIds"); + String userId = fileInfo.getString("userId"); + // 其他参数 + long nowTs2 = System.currentTimeMillis(); + String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2)); + String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId); + String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2); + + // 检查是否有认证信息 + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + // 检查是否为临时认证(临时认证每次都尝试登录) + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + // 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证 + if (isTempAuth || authFlag) { + log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag); + HttpRequest httpRequest = + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP)) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey); + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + if (token == null) { + // 执行登录 + login(tsEncode2, auths).onFailure(failRes-> { + log.warn("登录失败: {}", failRes.getMessage()); + fail(failRes.getMessage()); + }).onSuccess(r-> { + httpRequest.setTemplateParam("appToken", header.get("appToken")) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + }); + } else { + // 验证token + webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL)) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header).send().onSuccess(res -> { + // log.info("res: {}",asJson(res)); + if (asJson(res).getInteger("code") != 200) { + login(tsEncode2, auths).onFailure(failRes -> { + log.warn("重新登录失败: {}", failRes.getMessage()); + fail(failRes.getMessage()); + }).onSuccess(r-> { + httpRequest.setTemplateParam("appToken", header.get("appToken")) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + }); + } else { + httpRequest.setTemplateParam("appToken", header.get("appToken")) + .putHeaders(header); + httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2")); + } + }).onFailure(handleFail("Token验证")); + } + } else { + // authFlag 为 false,使用免登录解析 + log.debug("authFlag=false,使用免登录解析"); + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey).send() + .onSuccess(this::down).onFailure(handleFail("请求2")); + } + } else { + // 没有认证信息,使用免登录解析 + log.debug("无认证信息,使用免登录解析"); + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", fidEncode) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode2) + .setTemplateParam("auth", auth) + .setTemplateParam("dataKey", dataKey).send() + .onSuccess(this::down).onFailure(handleFail("请求2")); + } + } + + private Future login(String tsEncode2, MultiMap auths) { + Promise promise1 = Promise.promise(); + webClientSession.postAbs(UriTemplate.of(LOGIN_URL)) + .setTemplateParam("uuid",uuid) + .setTemplateParam("ts", tsEncode2) + .putHeaders(header) + .sendJsonObject(JsonObject.of("loginName", auths.get("username"), "loginPwd", auths.get("password"))) + .onSuccess(res2->{ + JsonObject json = asJson(res2); + if (json.getInteger("code") == 200) { + token = json.getJsonObject("data").getString("appToken"); + header.set("appToken", token); + log.info("登录成功 token: {}", token); + promise1.complete(); + } else { + // 检查是否为临时认证 + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + if (isTempAuth) { + // 临时认证失败,直接返回错误,不影响后台配置的认证 + log.warn("临时认证失败: {}", json.getString("msg")); + promise1.fail("临时认证失败: " + json.getString("msg")); + } else { + // 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析 + log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg")); + authFlag = false; + promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式"); + } + } + }).onFailure(err -> { + log.error("登录请求异常: {}", err.getMessage()); + promise1.fail("登录请求异常: " + err.getMessage()); + }); + return promise1.future(); + } + + /** + * 从接口返回数据中提取文件信息 + */ + private void extractFileInfo(JsonObject fileList, JsonObject shareInfo) { + try { + // 文件名 + String fileName = fileList.getString("fileName"); + shareLinkInfo.getOtherParam().put("fileName", fileName); + + // 文件大小 (KB -> Bytes) + Long fileSize = fileList.getLong("fileSize", 0L) * 1024; + shareLinkInfo.getOtherParam().put("fileSize", fileSize); + shareLinkInfo.getOtherParam().put("fileSizeFormat", FileSizeConverter.convertToReadableSize(fileSize)); + + // 文件图标 + String fileIcon = fileList.getString("fileIcon"); + if (StringUtils.isNotBlank(fileIcon)) { + shareLinkInfo.getOtherParam().put("fileIcon", fileIcon); + } + + // 文件ID + Long fileId = fileList.getLong("fileId"); + if (fileId != null) { + shareLinkInfo.getOtherParam().put("fileId", fileId.toString()); + } + + // 文件类型 (1=文件, 2=目录) + Integer fileType = fileList.getInteger("fileType", 1); + shareLinkInfo.getOtherParam().put("fileType", fileType == 1 ? "file" : "folder"); + + // 下载次数 + Integer downloads = fileList.getInteger("fileDownloads", 0); + shareLinkInfo.getOtherParam().put("downloadCount", downloads); + + // 点赞数 + Integer likes = fileList.getInteger("fileLikes", 0); + shareLinkInfo.getOtherParam().put("likeCount", likes); + + // 评论数 + Integer comments = fileList.getInteger("fileComments", 0); + shareLinkInfo.getOtherParam().put("commentCount", comments); + + // 评分 + Double stars = fileList.getDouble("fileStars", 0.0); + shareLinkInfo.getOtherParam().put("stars", stars); + + // 更新时间 + String updateTime = fileList.getString("updTime"); + if (StringUtils.isNotBlank(updateTime)) { + shareLinkInfo.getOtherParam().put("updateTime", updateTime); + } + + // 创建时间 + String createTime = null; + + // 分享信息 + if (shareInfo != null) { + // 分享ID + Integer shareId = shareInfo.getInteger("shareId"); + if (shareId != null) { + shareLinkInfo.getOtherParam().put("shareId", shareId.toString()); + } + + // 上传时间 + String addTime = shareInfo.getString("addTime"); + if (StringUtils.isNotBlank(addTime)) { + shareLinkInfo.getOtherParam().put("createTime", addTime); + createTime = addTime; + } + + // 预览次数 + Integer previewNum = shareInfo.getInteger("previewNum", 0); + shareLinkInfo.getOtherParam().put("previewCount", previewNum); + + // 用户信息 + JsonObject userMap = shareInfo.getJsonObject("map"); + if (userMap != null) { + String userName = userMap.getString("userName"); + if (StringUtils.isNotBlank(userName)) { + shareLinkInfo.getOtherParam().put("userName", userName); + } + + // VIP信息 + Integer isVip = userMap.getInteger("isVip", 0); + shareLinkInfo.getOtherParam().put("isVip", isVip == 1); + } + } + + // 创建 FileInfo 对象并存入 otherParam + FileInfo fileInfoObj = new FileInfo() + .setPanType(shareLinkInfo.getType()) + .setFileName(fileName) + .setFileId(fileList.getLong("fileId") != null ? fileList.getLong("fileId").toString() : null) + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setFileType(fileType == 1 ? "file" : "folder") + .setFileIcon(fileList.getString("fileIcon")) + .setDownloadCount(downloads) + .setCreateTime(createTime) + .setUpdateTime(updateTime); + shareLinkInfo.getOtherParam().put("fileInfo", fileInfoObj); + + log.debug("提取文件信息成功: fileName={}, fileSize={}, downloads={}", + fileName, fileSize, downloads); + } catch (Exception e) { + log.warn("提取文件信息失败: {}", e.getMessage()); + } + } + + private void down(HttpResponse res2) { + MultiMap headers = res2.headers(); + if (!headers.contains("Location") || StringUtils.isBlank(headers.get("Location"))) { + fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误"); + return; + } + promise.complete(headers.get("Location")); + } + + // 目录解析 + @Override + public Future> parseFileList() { + Promise> promise = Promise.promise(); + + String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey)); + + // 如果参数里的目录ID不为空,则直接解析目录 + String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); + if (dirId != null && !dirId.isEmpty()) { + uuid = shareLinkInfo.getOtherParam().get("uuid").toString(); + parserDir(dirId, shareId, promise); + return promise.future(); + } + parse().onSuccess(id -> { + parserDir(id, shareId, promise); + }).onFailure(failRes -> { + log.error("解析目录失败: {}", failRes.getMessage()); + promise.fail(failRes); + }); + return promise.future(); + } + + private void parserDir(String id, String shareId, Promise> promise) { + if (id != null && (id.startsWith("http://") || id.startsWith("https://"))) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileName(id) + .setFileId(id) + .setFileType("file") + .setParserUrl(id) + .setPanType(shareLinkInfo.getType()); + List result = new ArrayList<>(); + result.add(fileInfo); + promise.complete(result); + return; + } + + long nowTs = System.currentTimeMillis(); + String tsEncode = AESUtils.encrypt2HexIz(Long.toString(nowTs)); + + log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode); + + // 检查是否需要登录(有认证信息且需要使用认证) + if (shareLinkInfo.getOtherParam().containsKey("auths")) { + boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED"); + log.debug("目录解析检查认证: isTempAuth={}, authFlag={}, token={}", isTempAuth, authFlag, token != null ? "已有" : "null"); + + if ((isTempAuth || authFlag) && token == null) { + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + log.info("目录解析需要登录,开始执行登录..."); + // 先登录获取 token + login(tsEncode, auths) + .onFailure(err -> { + log.warn("目录解析登录失败,使用免登录模式: {}", err.getMessage()); + // 登录失败,继续使用免登录 + requestDirList(id, shareId, tsEncode, promise); + }) + .onSuccess(r -> { + log.info("目录解析登录成功,token={}, 使用 VIP 模式", token != null ? token.substring(0, 10) + "..." : "null"); + requestDirList(id, shareId, tsEncode, promise); + }); + return; + } else if (token != null) { + log.debug("目录解析已有 token,直接使用 VIP 模式"); + } else { + log.debug("目录解析: authFlag=false 或为临时认证但已失败,使用免登录模式"); + } + } else { + log.debug("目录解析无认证信息,使用免登录模式"); + } + + // 无需登录或已登录,直接请求 + requestDirList(id, shareId, tsEncode, promise); + } + + /** + * 请求目录列表 + */ + private void requestDirList(String id, String shareId, String tsEncode, Promise> promise) { + webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL)) + .putHeaders(header) + .setTemplateParam("shareId", shareId) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode) + .setTemplateParam("folderId", id) + .send().onSuccess(res -> { + String resBody = asText(res); + // 检查是否包含 cookie 验证 + if (resBody.contains("var arg1='")) { + log.debug("目录解析需要 cookie 验证,重新创建 session"); + webClientSession = WebClientSession.create(clientNoRedirects); + setCookie(resBody); + // 重新请求目录列表 + webClientSession.postAbs(UriTemplate.of(FILE_LIST_URL)) + .putHeaders(header) + .setTemplateParam("shareId", shareId) + .setTemplateParam("uuid", uuid) + .setTemplateParam("ts", tsEncode) + .setTemplateParam("folderId", id) + .send().onSuccess(res2 -> { + processDirResponse(res2, shareId, promise); + }).onFailure(err -> { + log.error("目录解析重试失败: {}", err.getMessage()); + promise.fail("目录解析失败: " + err.getMessage()); + }); + return; + } + processDirResponse(res, shareId, promise); + }).onFailure(err -> { + log.error("目录解析请求失败: {}", err.getMessage()); + promise.fail("目录解析失败: " + err.getMessage()); + }); + } + + /** + * 处理目录解析响应 + */ + private void processDirResponse(HttpResponse res, String shareId, Promise> promise) { + try { + JsonObject jsonObject = asJson(res); + log.debug("目录解析响应: {}", jsonObject.encodePrettily()); + + if (!jsonObject.containsKey("list")) { + log.error("目录解析响应缺少 list 字段: {}", jsonObject); + promise.fail("目录解析失败: 响应格式错误"); + return; + } + + JsonArray list = jsonObject.getJsonArray("list"); + ArrayList result = new ArrayList<>(); + list.forEach(item->{ + JsonObject fileJson = (JsonObject) item; + FileInfo fileInfo = new FileInfo(); + + // 映射已知字段 + String fileId = fileJson.getString("fileId"); + String userId = fileJson.getString("userId"); + + // 其他参数 - 每个文件使用新的时间戳 + long nowTs2 = System.currentTimeMillis(); + String tsEncode2 = AESUtils.encrypt2HexIz(Long.toString(nowTs2)); + String fidEncode = AESUtils.encrypt2HexIz(fileId + "|" + userId); + String auth = AESUtils.encrypt2HexIz(fileId + "|" + nowTs2); + + // 回传用到的参数(包含 token) + JsonObject entries = JsonObject.of( + "fidEncode", fidEncode, + "uuid", uuid, + "ts", tsEncode2, + "auth", auth, + "shareId", shareId, + "appToken", token != null ? token : ""); + String param = CommonUtils.urlBase64Encode(entries.encode()); + + if (fileJson.getInteger("fileType") == 2) { + // 如果是目录 + fileInfo.setFileName(fileJson.getString("name")) + .setFileId(fileJson.getString("folderId")) + .setCreateTime(fileJson.getString("updTime")) + .setFileType("folder") + .setSize(0L) + .setSizeStr("0B") + .setCreateBy(fileJson.getLong("userId").toString()) + .setDownloadCount(fileJson.getInteger("fileDownloads")) + .setCreateTime(fileJson.getString("updTime")) + .setFileIcon(fileJson.getString("fileIcon")) + .setPanType(shareLinkInfo.getType()) + // 设置目录解析的URL + .setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&uuid=%s", getDomainName(), + shareLinkInfo.getShareUrl(), fileJson.getString("folderId"), uuid)); + result.add(fileInfo); + return; + } + long fileSize = fileJson.getLong("fileSize") * 1024; + fileInfo.setFileName(fileJson.getString("fileName")) + .setFileId(fileId) + .setCreateTime(fileJson.getString("createTime")) + .setFileType("file") + .setSize(fileSize) + .setSizeStr(FileSizeConverter.convertToReadableSize(fileSize)) + .setCreateBy(fileJson.getLong("userId").toString()) + .setDownloadCount(fileJson.getInteger("fileDownloads")) + .setCreateTime(fileJson.getString("updTime")) + .setFileIcon(fileJson.getString("fileIcon")) + .setPanType(shareLinkInfo.getType()) + .setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(), + shareLinkInfo.getType(), param)) + .setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(), + shareLinkInfo.getType(), param)); + result.add(fileInfo); + }); + promise.complete(result); + } catch (Exception e) { + log.error("处理目录响应异常: {}", e.getMessage(), e); + promise.fail("目录解析失败: " + e.getMessage()); + } + } + + @Override + public Future parseById() { + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + String appToken = paramJson.getString("appToken", ""); + + // 如果有 token,使用 VIP 接口 + if (StringUtils.isNotBlank(appToken)) { + log.debug("parseById 使用 VIP 接口, appToken={}", appToken.substring(0, Math.min(10, appToken.length())) + "..."); + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP)) + .putHeaders(header) + .setTemplateParam("fidEncode", paramJson.getString("fidEncode")) + .setTemplateParam("uuid", paramJson.getString("uuid")) + .setTemplateParam("ts", paramJson.getString("ts")) + .setTemplateParam("auth", paramJson.getString("auth")) + .setTemplateParam("appToken", appToken) + .send().onSuccess(this::down).onFailure(handleFail("parseById-VIP")); + } else { + // 无 token,使用免登录接口 + log.debug("parseById 使用免登录接口"); + webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL)) + .putHeaders(header) + .setTemplateParam("fidEncode", paramJson.getString("fidEncode")) + .setTemplateParam("uuid", paramJson.getString("uuid")) + .setTemplateParam("ts", paramJson.getString("ts")) + .setTemplateParam("auth", paramJson.getString("auth")) + .setTemplateParam("dataKey", paramJson.getString("shareId")) + .send().onSuccess(this::down).onFailure(handleFail("parseById")); + } + return promise.future(); + } + + public static void resetToken() { + token = null; + authFlag = true; + } +} 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 4204913..08a9828 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java @@ -25,24 +25,24 @@ import java.util.Map; * 夸克网盘解析 */ 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; // 批量获取下载链接的批次大小 - + // 静态变量:缓存 __puus cookie 和过期时间 private static volatile String cachedPuus = null; private static volatile long puusExpireTime = 0; // __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新) private static final long PUUS_TTL_MS = 55 * 60 * 1000L; - + 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 Content-Type: application/json;charset=UTF-8 @@ -63,7 +63,7 @@ public class QkTool extends PanBase { if (cookie != null && !cookie.isEmpty()) { // 过滤出夸克网盘所需的 cookie 字段 cookie = CookieUtils.filterUcQuarkCookie(cookie); - + // 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) { cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus); @@ -75,14 +75,14 @@ public class QkTool extends PanBase { } } this.client = clientDisableUA; - + // 如果 __puus 已过期或不存在,触发异步刷新 if (needRefreshPuus()) { log.debug("夸克: __puus 需要刷新,触发异步刷新"); refreshPuusCookie(); } } - + /** * 判断是否需要刷新 __puus * @return true 表示需要刷新 @@ -107,23 +107,23 @@ public class QkTool extends PanBase { */ public Future refreshPuusCookie() { Promise refreshPromise = Promise.promise(); - + 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") @@ -134,7 +134,7 @@ public class QkTool extends PanBase { // 从响应头获取 set-cookie List setCookies = res.cookies(); String newPuus = null; - + for (String cookie : setCookies) { if (cookie.startsWith("__puus=")) { // 提取 __puus 值(只取到分号前的部分) @@ -143,21 +143,21 @@ public class QkTool extends PanBase { break; } } - + if (newPuus != null) { // 更新 cookie:替换或添加 __puus String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus); header.set(HttpHeaders.COOKIE, updatedCookie); - + // 同步更新 auths 中的 cookie if (auths != null) { auths.set("cookie", updatedCookie); } - + // 更新静态缓存 cachedPuus = newPuus; puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS; - + log.info("夸克: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime); refreshPromise.complete(true); } else { @@ -169,7 +169,7 @@ public class QkTool extends PanBase { log.warn("夸克: 刷新 __puus cookie 失败: {}", t.getMessage()); refreshPromise.complete(false); }); - + return refreshPromise.future(); } @@ -180,14 +180,14 @@ public class QkTool extends PanBase { 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") @@ -196,20 +196,20 @@ public class QkTool extends PanBase { .onSuccess(res -> { log.debug("第一阶段响应: {}", res.bodyAsString()); JsonObject resJson = asJson(res); - + if (resJson.getInteger("code") != 0) { fail(TOKEN_URL + " 返回异常: " + resJson); return; } - + String stoken = resJson.getJsonObject("data").getString("stoken"); if (stoken == null || stoken.isEmpty()) { fail("无法获取分享 token,可能的原因:1. Cookie 已过期 2. 分享链接已失效 3. 需要提取码但未提供"); return; } - + log.debug("成功获取 stoken: {}", stoken); - + // 第二步:获取文件列表 client.getAbs(DETAIL_URL) .addQueryParam("pr", "ucpro") @@ -229,85 +229,102 @@ public class QkTool extends PanBase { .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("未找到文件"); return; } - + // 过滤出文件(排除文件夹) List files = new ArrayList<>(); for (int i = 0; i < fileList.size(); i++) { JsonObject item = fileList.getJsonObject(i); // 判断是否为文件:file=true 或 obj_category 不为空 - if (item.getBoolean("file", false) || - (item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) { + if (item.getBoolean("file", false) || + (item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) { files.add(item); } } - + if (files.isEmpty()) { fail("没有可下载的文件(可能都是文件夹)"); 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(firstFile.getString("updated_at")) - .setUpdateTime(firstFile.getString("updated_at")) - .setPanType(shareLinkInfo.getType()); - - // 保存到 otherParam,供 CacheServiceImpl 使用 - shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); - log.debug("夸克提取文件信息: {}", fileInfo.getFileName()); - } catch (Exception e) { - log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage()); - } - - // 提取文件ID列表 + + // 构建文件映射和文件ID列表(参考 kuake.py:下载结果通过 fid 回填文件信息) List fileIds = new ArrayList<>(); + Map fileMap = new HashMap<>(); for (JsonObject file : files) { String fid = file.getString("fid"); if (fid != null && !fid.isEmpty()) { fileIds.add(fid); + fileMap.put(fid, file); } } - + if (fileIds.isEmpty()) { fail("无法提取文件ID"); return; } - + // 第三步:批量获取下载链接 - getDownloadLinksBatch(fileIds, stoken) + getDownloadLinksBatch(fileIds) .onSuccess(downloadData -> { if (downloadData.isEmpty()) { fail("未能获取到下载链接"); return; } - // 获取第一个文件的下载链接 - String downloadUrl = downloadData.get(0).getString("download_url"); + // 按 fid 对齐下载结果和文件信息,取首个有效下载链接 + String downloadUrl = null; + JsonObject matchedFile = null; + for (JsonObject item : downloadData) { + String fid = item.getString("fid"); + String currentUrl = item.getString("download_url"); + if (currentUrl != null && !currentUrl.isEmpty() && fid != null) { + JsonObject fileMeta = fileMap.get(fid); + if (fileMeta != null) { + downloadUrl = currentUrl; + matchedFile = fileMeta; + break; + } + } + } + if (downloadUrl == null || downloadUrl.isEmpty()) { fail("下载链接为空"); return; } + // 提取匹配文件的信息并保存到 otherParam + if (matchedFile != null) { + try { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileId(matchedFile.getString("fid")) + .setFileName(matchedFile.getString("file_name")) + .setSize(matchedFile.getLong("size", 0L)) + .setSizeStr(FileSizeConverter.convertToReadableSize(matchedFile.getLong("size", 0L))) + .setFileType(matchedFile.getBoolean("file", true) ? "file" : "folder") + .setCreateTime(matchedFile.getString("updated_at")) + .setUpdateTime(matchedFile.getString("updated_at")) + .setPanType(shareLinkInfo.getType()); + + // 保存到 otherParam,供 CacheServiceImpl 使用 + shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); + log.debug("夸克提取文件信息: {}", fileInfo.getFileName()); + } catch (Exception e) { + log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage()); + } + } + // 夸克网盘需要配合下载请求头,保存下载请求头 Map downloadHeaders = new HashMap<>(); downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE)); @@ -318,28 +335,28 @@ public class QkTool extends PanBase { completeWithMeta(downloadUrl, downloadHeaders); }) .onFailure(handleFail(DOWNLOAD_URL)); - + }).onFailure(handleFail(DETAIL_URL)); }) .onFailure(handleFail(TOKEN_URL)); - + return promise.future(); } - + /** * 批量获取下载链接(分批处理) */ - private Future> getDownloadLinksBatch(List fileIds, String stoken) { + private Future> getDownloadLinksBatch(List fileIds) { List allResults = new ArrayList<>(); Promise> promise = Promise.promise(); // 同步处理每个批次 - processBatch(fileIds, stoken, 0, allResults, promise); + processBatch(fileIds, 0, allResults, promise); return promise.future(); } - private void processBatch(List fileIds, String stoken, int startIndex, List allResults, Promise> promise) { + private void processBatch(List fileIds, int startIndex, List allResults, Promise> promise) { if (startIndex >= fileIds.size()) { // 所有批次处理完成 promise.complete(allResults); @@ -382,7 +399,7 @@ public class QkTool extends PanBase { } // 处理下一批次 - processBatch(fileIds, stoken, endIndex, allResults, promise); + processBatch(fileIds, endIndex, allResults, promise); }) .onFailure(t -> promise.fail("获取下载链接失败: " + t.getMessage())); } @@ -391,11 +408,11 @@ public class QkTool extends PanBase { @Override public Future> parseFileList() { 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()) { @@ -405,12 +422,12 @@ public class QkTool extends PanBase { 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") @@ -432,7 +449,7 @@ public class QkTool extends PanBase { parseDir(rootDirId, pwdId, finalPasscode, stoken, promise); }) .onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage())); - + return promise.future(); } @@ -440,7 +457,7 @@ public class QkTool extends PanBase { // 第二步:获取文件列表(支持指定目录) // 夸克 API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0" log.info("夸克 parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken); - + client.getAbs(DETAIL_URL) .addQueryParam("pr", "ucpro") .addQueryParam("fr", "pc") @@ -462,26 +479,26 @@ public class QkTool extends PanBase { 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); @@ -490,10 +507,10 @@ public class QkTool extends PanBase { 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={}", + + log.info("处理夸克 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}, objCategory={}", i, fid, fileName, parentId, dirId, isFile, objCategory); - + fileInfo.setFileId(fid) .setFileName(fileName) .setSize(fileSize) @@ -501,7 +518,7 @@ public class QkTool extends PanBase { .setCreateTime(updatedAt) .setUpdateTime(updatedAt) .setPanType(shareLinkInfo.getType()); - + // 判断是否为文件:file=true 或 obj_category 不为空 if (isFile || (objCategory != null && !objCategory.isEmpty())) { // 文件 @@ -518,7 +535,7 @@ public class QkTool extends PanBase { // 设置解析URL(用于下载) JsonObject paramJson = new JsonObject(extParams); String param = CommonUtils.urlBase64Encode(paramJson.encode()); - fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", + fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(), shareLinkInfo.getType(), param)); } else { // 文件夹 @@ -531,18 +548,18 @@ public class QkTool extends PanBase { 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", + fileInfo.setParserUrl(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", + fileInfo.setParserUrl(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())); @@ -551,36 +568,36 @@ public class QkTool extends PanBase { @Override public Future parseById() { Promise promise = Promise.promise(); - + // 从 paramJson 中提取参数 JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); if (paramJson == null) { promise.fail("缺少必要的参数"); return promise.future(); } - + 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); - + // 调用下载链接 API 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") @@ -589,17 +606,17 @@ public class QkTool extends PanBase { .onSuccess(res -> { log.debug("夸克 parseById 响应: {}", res.bodyAsString()); JsonObject resJson = asJson(res); - + if (resJson.getInteger("code") == 31001) { promise.fail("未登录或 Cookie 已失效"); return; } - + if (resJson.getInteger("code") != 0) { promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson); return; } - + try { JsonArray dataList = resJson.getJsonArray("data"); if (dataList == null || dataList.isEmpty()) { @@ -617,7 +634,7 @@ public class QkTool extends PanBase { } }) .onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage())); - + return promise.future(); } } \ No newline at end of file diff --git a/web-front/src/views/Home.vue b/web-front/src/views/Home.vue index 3514759..92334e7 100644 --- a/web-front/src/views/Home.vue +++ b/web-front/src/views/Home.vue @@ -64,7 +64,7 @@
-
NFD网盘直链解析0.2.1
+
NFD网盘直链解析0.2.1b2
支持网盘:蓝奏云、蓝奏云优享、小飞机盘、123云盘、奶牛快传、移动云空间、QQ邮箱云盘、QQ闪传等 >>
文件夹解析支持:蓝奏云、蓝奏云优享、小飞机盘、123云盘
@@ -957,7 +957,8 @@ export default { } else if (panType === 'fj' || panType === 'lz' || panType === 'iz' || panType === 'le') { // 小飞机、蓝奏、优享、联想乐云:提示大文件需要认证 const hasAuth = this.allAuthConfigs[panType]?.cookie || - this.allAuthConfigs[panType]?.username + this.allAuthConfigs[panType]?.username || + (this.donateAccountCounts.active[panType.toUpperCase()] || 0) > 0 if (!hasAuth) { this.$message.info({ message: `${panName}的大文件解析需要配置认证信息,请在"配置认证"中添加`, diff --git a/web-service/src/main/resources/secret.yml b/web-service/src/main/resources/secret.yml new file mode 100644 index 0000000..0d893bc --- /dev/null +++ b/web-service/src/main/resources/secret.yml @@ -0,0 +1,4 @@ +# This file contains sensitive information and should not be committed to version control. +# It is used to store the encryption key for the application. +encrypt: + key: "nfd_secret_key_32bytes_2026_abcd"