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;
}
}
-