diff --git a/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java b/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java index f190591..ff1c2f9 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/QQscTool.java @@ -3,9 +3,11 @@ package cn.qaiu.parser.impl; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.util.CommonUtils; import cn.qaiu.util.HeaderUtils; import io.vertx.core.Future; import io.vertx.core.MultiMap; +import io.vertx.core.Promise; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import org.slf4j.Logger; @@ -13,27 +15,33 @@ import org.slf4j.LoggerFactory; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * QQ闪传
- * 只能客户端上传 支持Android QQ 9.2.5, MACOS QQ 6.9.78,可生成分享链接,通过浏览器下载,支持超大文件,有效期默认7天(暂时没找到续期方法)。
+ * 支持多文件、多级目录解析。通过 GetFileList API 获取文件列表,BatchDownload API 获取下载直链。
+ * 有效期默认7天。 */ public class QQscTool extends PanBase { Logger LOG = LoggerFactory.getLogger(QQscTool.class); - private static final String API_URL = "https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.qqntv2.richmedia.InnerProxy/BatchDownload"; + private static final String BATCH_DOWNLOAD_API = + "https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.qqntv2.richmedia.InnerProxy/BatchDownload"; - private static final MultiMap HEADERS = HeaderUtils.parseHeaders(""" + private static final String GET_FILE_LIST_API = + "https://qfile.qq.com/http2rpc/gotrpc/noauth/trpc.file.FileFlashTrans/GetFileList"; + + private static final MultiMap BATCH_DOWNLOAD_HEADERS = HeaderUtils.parseHeaders(""" Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: keep-alive Cookie: uin=9000002; p_uin=9000002 DNT: 1 Origin: https://qfile.qq.com - Referer: https://qfile.qq.com/q/Xolxtv5b4O Sec-Fetch-Dest: empty Sec-Fetch-Mode: cors Sec-Fetch-Site: same-origin @@ -46,91 +54,256 @@ public class QQscTool extends PanBase { x-oidb: {"uint32_command":"0x9248", "uint32_service_type":"4"} """); + private static final MultiMap GET_FILE_LIST_HEADERS = HeaderUtils.parseHeaders(""" + Accept-Encoding: gzip, deflate + Cookie: uin=9000002; p_uin=9000002 + Origin: https://qfile.qq.com + User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0 + content-type: application/json + x-oidb: {"uint32_command":"0x93d4", "uint32_service_type":"1"} + """); + public QQscTool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); } + @Override public Future parse() { - String jsonTemplate = """ - {"req_head":{"agent":8},"download_info":[{"batch_id":"%s","scene":{"business_type":4,"app_type":22,"scene_type":5},"index_node":{"file_uuid":"%s"},"url_type":2,"download_scene":0}],"scene_type":103} - """; - client.getAbs(shareLinkInfo.getShareUrl()).send(result -> { - if (result.succeeded()) { - String htmlJs = result.result().bodyAsString(); - LOG.debug("获取到的HTML内容: {}", htmlJs); - String fileUUID = getFileUUID(htmlJs); - String fileName = extractFileNameFromTitle(htmlJs); - if (fileName != null) { - LOG.info("提取到的文件名: {}", fileName); - FileInfo fileInfo = new FileInfo(); - fileInfo.setFileName(fileName); - shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); - } else { - LOG.warn("未能提取到文件名"); - } - if (fileUUID != null) { - LOG.info("提取到的文件UUID: {}", fileUUID); - String formatted = jsonTemplate.formatted(fileUUID, fileUUID); - JsonObject entries = new JsonObject(formatted); - client.postAbs(API_URL) - .putHeaders(HEADERS) - .sendJsonObject(entries) - .onSuccess(result2 -> { - if (result2.statusCode() == 200) { - JsonObject body = asJson(result2); - LOG.debug("API响应内容: {}", body.encodePrettily()); - // { - // "retcode": 0, - // "cost": 132, - // "message": "", - // "error": { - // "message": "", - // "code": 0 - // }, - // "data": { - // "download_rsp": [{ - - // 取 download_rsp - if (!body.containsKey("retcode") || body.getInteger("retcode") != 0) { - promise.fail("API请求失败,错误信息: " + body.encodePrettily()); - return; - } - JsonArray downloadRsp = body.getJsonObject("data").getJsonArray("download_rsp"); - if (downloadRsp != null && !downloadRsp.isEmpty()) { - String url = downloadRsp.getJsonObject(0).getString("url"); - // 检测文件是否被和谐 - if (url != null && url.startsWith("&filename=")) { - promise.fail("该文件已被和谐"); - return; - } - if (fileName != null) { - url = url + "&filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8); - } - promise.complete(url); - } else { - promise.fail("API响应中缺少 download_rsp"); - } - } else { - promise.fail("API请求失败,状态码: " + result2.statusCode()); - } - }).onFailure(e -> { - LOG.error("API请求异常", e); - promise.fail(e); - }); - } else { - LOG.error("未能提取到文件UUID"); - promise.fail("未能提取到文件UUID"); - } - } else { + if (result.failed()) { LOG.error("请求失败: {}", result.cause().getMessage()); promise.fail(result.cause()); + return; + } + String html = result.result().bodyAsString(); + String fileName = extractFileNameFromTitle(html); + if (fileName != null) { + FileInfo fileInfo = new FileInfo(); + fileInfo.setFileName(fileName); + shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); + } + // 尝试用 GetFileList API 获取第一个文件的下载链接 + String filesetId = extractFilesetId(html); + if (filesetId != null) { + fetchFileList(filesetId, "").onSuccess(fileList -> { + for (int i = 0; i < fileList.size(); i++) { + JsonObject file = fileList.getJsonObject(i); + if (!file.getBoolean("is_dir", false)) { + String physicalId = file.getJsonObject("physical").getString("id"); + String name = file.getString("name"); + downloadFile(physicalId, name); + return; + } + } + promise.fail("未找到可下载的文件"); + }).onFailure(e -> { + LOG.warn("GetFileList 失败,回退到旧解析方式: {}", e.getMessage()); + parseLegacy(html, fileName); + }); + } else { + parseLegacy(html, fileName); } }); - return promise.future(); } + @Override + public Future> parseFileList() { + Promise> resultPromise = Promise.promise(); + String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); + + client.getAbs(shareLinkInfo.getShareUrl()).send(result -> { + if (result.failed()) { + resultPromise.fail(result.cause()); + return; + } + String html = result.result().bodyAsString(); + String filesetId = extractFilesetId(html); + if (filesetId == null) { + resultPromise.fail("无法从页面提取 filesetId"); + return; + } + String parentId = dirId != null ? dirId : ""; + fetchFileList(filesetId, parentId).onSuccess(fileList -> { + try { + List list = new ArrayList<>(); + String panType = shareLinkInfo.getType(); + for (int i = 0; i < fileList.size(); i++) { + JsonObject file = fileList.getJsonObject(i); + FileInfo fileInfo = new FileInfo(); + String name = file.getString("name"); + String cliFileid = file.getString("cli_fileid"); + boolean isDir = file.getBoolean("is_dir", false); + String sizeStr = file.getString("file_size"); + + fileInfo.setFileName(name) + .setFileId(cliFileid) + .setPanType(panType) + .setSizeStr(sizeStr); + + if (isDir) { + fileInfo.setFileType("folder") + .setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s", + getDomainName(), + URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8), + cliFileid)); + } else { + String physicalId = file.getJsonObject("physical").getString("id"); + JsonObject paramJson = new JsonObject() + .put("fileId", physicalId) + .put("fileName", name) + .put("cliFileid", cliFileid); + String param = CommonUtils.urlBase64Encode(paramJson.encode()); + fileInfo.setFileType("file") + .setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", + getDomainName(), panType, param)); + } + list.add(fileInfo); + } + resultPromise.complete(list); + } catch (Exception e) { + resultPromise.fail(e); + } + }).onFailure(resultPromise::fail); + }); + return resultPromise.future(); + } + + @Override + public Future parseById() { + JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); + String fileId = paramJson.getString("fileId"); + String fileName = paramJson.getString("fileName"); + + Promise p = Promise.promise(); + callBatchDownload(fileId, fileName, p); + return p.future(); + } + + // ========== 内部方法 ========== + + /** + * 调用 BatchDownload API 获取单个文件的下载直链 + */ + private void downloadFile(String physicalId, String fileName) { + callBatchDownload(physicalId, fileName, promise); + } + + private void callBatchDownload(String physicalId, String fileName, Promise p) { + String body = """ + {"req_head":{"agent":8},"download_info":[{"batch_id":"%s","scene":{"business_type":4,"app_type":22,"scene_type":5},"index_node":{"file_uuid":"%s"},"url_type":2,"download_scene":0}],"scene_type":103} + """.formatted(physicalId, physicalId); + + client.postAbs(BATCH_DOWNLOAD_API) + .putHeaders(BATCH_DOWNLOAD_HEADERS) + .sendJsonObject(new JsonObject(body)) + .onSuccess(resp -> { + if (resp.statusCode() != 200) { + p.fail("BatchDownload 请求失败,状态码: " + resp.statusCode()); + return; + } + JsonObject respBody = asJson(resp); + if (!respBody.containsKey("retcode") || respBody.getInteger("retcode") != 0) { + p.fail("BatchDownload 请求失败: " + respBody.encodePrettily()); + return; + } + JsonArray downloadRsp = respBody.getJsonObject("data").getJsonArray("download_rsp"); + if (downloadRsp == null || downloadRsp.isEmpty()) { + p.fail("BatchDownload 响应中缺少 download_rsp"); + return; + } + String url = downloadRsp.getJsonObject(0).getString("url"); + if (url != null && url.startsWith("&filename=")) { + p.fail("该文件已被和谐"); + return; + } + if (fileName != null) { + url = url + "&filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8); + } + p.complete(url); + }) + .onFailure(e -> { + LOG.error("BatchDownload 请求异常", e); + p.fail(e); + }); + } + + /** + * 调用 GetFileList API 获取指定目录下的文件列表 + */ + private Future fetchFileList(String filesetId, String parentId) { + Promise p = Promise.promise(); + JsonObject body = new JsonObject() + .put("fileset_id", filesetId) + .put("req_infos", new JsonArray() + .add(new JsonObject() + .put("parent_id", parentId) + .put("req_depth", 1) + .put("count", 50) + .put("filter_condition", new JsonObject().put("file_category", 0)) + .put("sort_conditions", new JsonArray() + .add(new JsonObject() + .put("sort_field", 0) + .put("sort_order", 0))))) + .put("support_folder_status", true); + + MultiMap headers = GET_FILE_LIST_HEADERS.set("Referer", shareLinkInfo.getShareUrl()); + + client.postAbs(GET_FILE_LIST_API) + .putHeaders(headers) + .sendJsonObject(body) + .onSuccess(resp -> { + if (resp.statusCode() != 200) { + p.fail("GetFileList 请求失败,状态码: " + resp.statusCode()); + return; + } + JsonObject respBody = asJson(resp); + if (respBody.getInteger("retcode", -1) != 0) { + p.fail("GetFileList 请求失败: " + respBody.getString("message", "未知错误")); + return; + } + JsonArray fileLists = respBody.getJsonObject("data").getJsonArray("file_lists"); + if (fileLists == null || fileLists.isEmpty()) { + p.fail("GetFileList 响应中缺少 file_lists"); + return; + } + JsonArray fileList = fileLists.getJsonObject(0).getJsonArray("file_list"); + p.complete(fileList != null ? fileList : new JsonArray()); + }) + .onFailure(e -> { + LOG.error("GetFileList 请求异常", e); + p.fail(e); + }); + return p.future(); + } + + /** + * 从 HTML 的 __NUXT_DATA__ 中提取 fileset_id + */ + String extractFilesetId(String html) { + // 匹配 UUID 格式的 fileset_id(出现在 Nuxt 数据的 fileset_id 字段值位置) + Pattern pattern = Pattern.compile( + "\"fileset_id\"[:\\s]*\"([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\""); + Matcher matcher = pattern.matcher(html); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + /** + * 旧版解析方式(兼容单文件链接,通过 HTML 字符串搜索提取 UUID) + */ + private void parseLegacy(String html, String fileName) { + String fileUUID = getFileUUID(html); + if (fileUUID == null) { + promise.fail("未能提取到文件UUID"); + return; + } + LOG.info("使用旧版解析,提取到的文件UUID: {}", fileUUID); + downloadFile(fileUUID, fileName); + } + String getFileUUID(String htmlJs) { String keyword = "\"download_limit_status\""; String marker = "},\""; @@ -145,32 +318,23 @@ public class QQscTool extends PanBase { String extracted = htmlJs.substring(quoteStart, quoteEnd); LOG.debug("提取结果: {}", extracted); return extracted; - } else { - LOG.error("未找到结束引号: {}", marker); } - } else { - LOG.error("未找到标记: {} 在关键字: {} 之后", marker, keyword); } - } else { - LOG.error("未找到关键字: {}", keyword); } return null; } public static String extractFileNameFromTitle(String content) { - // 匹配之间的内容 Pattern pattern = Pattern.compile("(.*?)"); Matcher matcher = pattern.matcher(content); if (matcher.find()) { String fullTitle = matcher.group(1); - // 按 "|" 分割,取前半部分 int sepIndex = fullTitle.indexOf("|"); if (sepIndex != -1) { return fullTitle.substring(0, sepIndex); } - return fullTitle; // 如果没有分隔符,就返回全部 + return fullTitle; } return null; } } -