mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-02-24 06:05:23 +00:00
feat(v0.2.1): 添加认证参数支持和客户端下载命令生成
主要更新: - 新增 auth 参数加密传递支持 (QK/UC Cookie认证) - 实现下载命令自动生成 (curl/aria2c/迅雷) - aria2c 命令支持 8 线程 8 片段下载 - 修复 cookie 字段映射问题 - 优化前端 clientLinks 页面 - 添加认证参数文档和测试用例 - 更新 .gitignore 忽略编译目录
This commit is contained in:
@@ -56,7 +56,15 @@ public abstract class PanBase implements IPanTool {
|
||||
protected WebClient clientNoRedirects = WebClient.create(WebClientVertxInit.get(),
|
||||
new WebClientOptions().setFollowRedirects(false));
|
||||
|
||||
/**
|
||||
* Http client disable UserAgent
|
||||
*/
|
||||
protected WebClient clientDisableUA = WebClient.create(WebClientVertxInit.get()
|
||||
, new WebClientOptions().setUserAgentEnabled(false)
|
||||
);
|
||||
|
||||
protected ShareLinkInfo shareLinkInfo;
|
||||
|
||||
|
||||
/**
|
||||
* 子类重写此构造方法不需要添加额外逻辑
|
||||
|
||||
@@ -272,6 +272,19 @@ public enum PanDomainTemplate {
|
||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kdocs\\.cn/l/(?<KEY>.+)"),
|
||||
"https://www.kdocs.cn/l/{shareKey}",
|
||||
PwpsTool.class),
|
||||
|
||||
// https://fast.uc.cn/s/33197dd53ace4
|
||||
// https://drive.uc.cn/s/e623b6da278e4?public=1#/list/share
|
||||
UC("UC网盘",
|
||||
compile("https://(fast|drive)\\.uc\\.cn/s/(?<KEY>\\w+)(\\?public=\\d+)?([&#].*)?"),
|
||||
"https://drive.uc.cn/s/{shareKey}",
|
||||
UcTool.class),
|
||||
// https://pan.quark.cn/s/6a325cdaec58
|
||||
QK("夸克网盘",
|
||||
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
|
||||
"https://pan.quark.cn/s/{shareKey}",
|
||||
QkTool.class),
|
||||
|
||||
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||
// http://163cn.tv/xxx
|
||||
MNES("网易云音乐分享",
|
||||
|
||||
@@ -11,6 +11,13 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 客户端下载链接生成器工厂类
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令,支持 Cookie</li>
|
||||
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
@@ -25,16 +32,10 @@ public class ClientLinkGeneratorFactory {
|
||||
// 静态初始化块,注册默认的生成器
|
||||
static {
|
||||
try {
|
||||
// 注册默认生成器 - 按指定顺序注册
|
||||
register(new Aria2LinkGenerator());
|
||||
register(new MotrixLinkGenerator());
|
||||
register(new BitCometLinkGenerator());
|
||||
register(new ThunderLinkGenerator());
|
||||
register(new WgetLinkGenerator());
|
||||
register(new CurlLinkGenerator());
|
||||
register(new IdmLinkGenerator());
|
||||
register(new FdmLinkGenerator());
|
||||
register(new PowerShellLinkGenerator());
|
||||
// 注册默认生成器 - 只保留3种(按需求)
|
||||
register(new CurlLinkGenerator()); // cURL 命令,支持 Cookie
|
||||
register(new Aria2LinkGenerator()); // Aria2 命令,支持 Cookie
|
||||
register(new ThunderLinkGenerator()); // 迅雷协议,不支持 Cookie
|
||||
|
||||
log.info("客户端链接生成器工厂初始化完成,已注册 {} 个生成器", generators.size());
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -2,27 +2,32 @@ package cn.qaiu.parser.clientlink;
|
||||
|
||||
/**
|
||||
* 客户端下载工具类型枚举
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令行工具,支持 Cookie</li>
|
||||
* <li>ARIA2 - 多线程下载器,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷下载器,不支持 Cookie(使用迅雷协议)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public enum ClientLinkType {
|
||||
ARIA2("aria2", "Aria2"),
|
||||
MOTRIX("motrix", "Motrix"),
|
||||
BITCOMET("bitcomet", "比特彗星"),
|
||||
THUNDER("thunder", "迅雷"),
|
||||
WGET("wget", "wget 命令"),
|
||||
CURL("curl", "cURL 命令"),
|
||||
IDM("idm", "IDM"),
|
||||
FDM("fdm", "Free Download Manager"),
|
||||
POWERSHELL("powershell", "PowerShell");
|
||||
CURL("curl", "cURL 命令", true, "命令行下载工具,支持Cookie"),
|
||||
ARIA2("aria2", "Aria2", true, "多线程下载器,支持Cookie"),
|
||||
THUNDER("thunder", "迅雷", false, "迅雷下载器,不支持Cookie");
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
private final boolean supportsCookie;
|
||||
private final String description;
|
||||
|
||||
ClientLinkType(String code, String displayName) {
|
||||
ClientLinkType(String code, String displayName, boolean supportsCookie, String description) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
this.supportsCookie = supportsCookie;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
@@ -33,6 +38,14 @@ public enum ClientLinkType {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public boolean isSupportsCookie() {
|
||||
return supportsCookie;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return displayName;
|
||||
|
||||
@@ -7,6 +7,13 @@ import java.util.Map;
|
||||
/**
|
||||
* 客户端下载链接生成工具类
|
||||
* 提供便捷的静态方法来生成各种客户端下载链接
|
||||
* <p>
|
||||
* 支持的客户端类型:
|
||||
* <ul>
|
||||
* <li>CURL - cURL 命令,支持 Cookie</li>
|
||||
* <li>ARIA2 - Aria2 命令,支持 Cookie</li>
|
||||
* <li>THUNDER - 迅雷协议,不支持 Cookie</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
@@ -35,7 +42,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 curl 命令
|
||||
* 生成 curl 命令(支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return curl 命令字符串
|
||||
@@ -45,17 +52,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 wget 命令
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return wget 命令字符串
|
||||
*/
|
||||
public static String generateWgetCommand(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.WGET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 aria2 命令
|
||||
* 生成 aria2 命令(支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return aria2 命令字符串
|
||||
@@ -65,7 +62,7 @@ public class ClientLinkUtils {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成迅雷链接
|
||||
* 生成迅雷链接(不支持 Cookie)
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return 迅雷协议链接
|
||||
@@ -74,56 +71,6 @@ public class ClientLinkUtils {
|
||||
return generateClientLink(info, ClientLinkType.THUNDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 IDM 链接
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return IDM 协议链接
|
||||
*/
|
||||
public static String generateIdmLink(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.IDM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成比特彗星链接
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return 比特彗星协议链接
|
||||
*/
|
||||
public static String generateBitCometLink(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.BITCOMET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Motrix 导入格式
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return Motrix JSON 格式字符串
|
||||
*/
|
||||
public static String generateMotrixFormat(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.MOTRIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 FDM 导入格式
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return FDM 格式字符串
|
||||
*/
|
||||
public static String generateFdmFormat(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.FDM);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PowerShell 命令
|
||||
*
|
||||
* @param info ShareLinkInfo 对象
|
||||
* @return PowerShell 命令字符串
|
||||
*/
|
||||
public static String generatePowerShellCommand(ShareLinkInfo info) {
|
||||
return generateClientLink(info, ClientLinkType.POWERSHELL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 ShareLinkInfo 是否包含有效的下载元数据
|
||||
*
|
||||
|
||||
@@ -41,6 +41,8 @@ public class Aria2LinkGenerator implements ClientLinkGenerator {
|
||||
parts.add("--continue"); // 支持断点续传
|
||||
parts.add("--max-tries=3"); // 最大重试次数
|
||||
parts.add("--retry-wait=5"); // 重试等待时间
|
||||
parts.add("-s 8"); // 分成8片段下载
|
||||
parts.add("-x 8"); // 每个服务器使用8个连接
|
||||
|
||||
// 添加URL
|
||||
parts.add("\"" + meta.getUrl() + "\"");
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 比特彗星协议链接生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class BitCometLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 比特彗星支持 HTTP 下载,格式类似 IDM
|
||||
String encodedUrl = Base64.getEncoder().encodeToString(
|
||||
meta.getUrl().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
StringBuilder link = new StringBuilder("bitcomet:///?url=").append(encodedUrl);
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
StringBuilder headerStr = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (headerStr.length() > 0) {
|
||||
headerStr.append("\\r\\n");
|
||||
}
|
||||
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
}
|
||||
|
||||
String encodedHeaders = Base64.getEncoder().encodeToString(
|
||||
headerStr.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&header=").append(encodedHeaders);
|
||||
}
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
String encodedFileName = Base64.getEncoder().encodeToString(
|
||||
meta.getFileName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&filename=").append(encodedFileName);
|
||||
}
|
||||
|
||||
return link.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,返回简单的URL
|
||||
return "bitcomet:///?url=" + meta.getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.BITCOMET;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Free Download Manager 导入格式生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class FdmLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// FDM 支持简单的文本格式导入
|
||||
StringBuilder result = new StringBuilder();
|
||||
result.append("URL=").append(meta.getUrl()).append("\n");
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
result.append("Filename=").append(meta.getFileName()).append("\n");
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
result.append("Headers=");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (!first) {
|
||||
result.append("; ");
|
||||
}
|
||||
result.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
first = false;
|
||||
}
|
||||
result.append("\n");
|
||||
}
|
||||
|
||||
result.append("Referer=").append(meta.getReferer() != null ? meta.getReferer() : "").append("\n");
|
||||
result.append("User-Agent=").append(meta.getUserAgent() != null ? meta.getUserAgent() : "").append("\n");
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.FDM;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IDM 协议链接生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class IdmLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 对URL进行Base64编码
|
||||
String encodedUrl = Base64.getEncoder().encodeToString(
|
||||
meta.getUrl().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
StringBuilder link = new StringBuilder("idm:///?url=").append(encodedUrl);
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
StringBuilder headerStr = new StringBuilder();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (headerStr.length() > 0) {
|
||||
headerStr.append("\\r\\n");
|
||||
}
|
||||
headerStr.append(entry.getKey()).append(": ").append(entry.getValue());
|
||||
}
|
||||
|
||||
String encodedHeaders = Base64.getEncoder().encodeToString(
|
||||
headerStr.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&header=").append(encodedHeaders);
|
||||
}
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
String encodedFileName = Base64.getEncoder().encodeToString(
|
||||
meta.getFileName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
link.append("&filename=").append(encodedFileName);
|
||||
}
|
||||
|
||||
return link.toString();
|
||||
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,返回简单的URL
|
||||
return "idm:///?url=" + meta.getUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.IDM;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Motrix 导入格式生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class MotrixLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 使用 Vert.x JsonObject 构建 JSON
|
||||
JsonObject taskJson = new JsonObject();
|
||||
taskJson.put("url", meta.getUrl());
|
||||
|
||||
// 添加文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
taskJson.put("filename", meta.getFileName());
|
||||
}
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
JsonObject headersJson = new JsonObject();
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
headersJson.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
taskJson.put("headers", headersJson);
|
||||
}
|
||||
|
||||
// 设置输出文件名
|
||||
String outputFile = meta.getFileName() != null ? meta.getFileName() : "";
|
||||
taskJson.put("out", outputFile);
|
||||
|
||||
return taskJson.encodePrettily();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.MOTRIX;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* PowerShell 命令生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class PowerShellLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> lines = new ArrayList<>();
|
||||
|
||||
// 创建 WebRequestSession
|
||||
lines.add("$session = New-Object Microsoft.PowerShell.Commands.WebRequestSession");
|
||||
|
||||
// 设置 User-Agent(如果存在)
|
||||
String userAgent = meta.getUserAgent();
|
||||
if (userAgent == null && meta.getHeaders() != null) {
|
||||
userAgent = meta.getHeaders().get("User-Agent");
|
||||
}
|
||||
if (userAgent != null && !userAgent.trim().isEmpty()) {
|
||||
lines.add("$session.UserAgent = \"" + escapePowerShellString(userAgent) + "\"");
|
||||
}
|
||||
|
||||
// 构建 Invoke-WebRequest 命令
|
||||
List<String> invokeParams = new ArrayList<>();
|
||||
invokeParams.add("Invoke-WebRequest");
|
||||
invokeParams.add("-UseBasicParsing");
|
||||
invokeParams.add("-Uri \"" + escapePowerShellString(meta.getUrl()) + "\"");
|
||||
|
||||
// 添加 WebSession
|
||||
invokeParams.add("-WebSession $session");
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
List<String> headerLines = new ArrayList<>();
|
||||
headerLines.add("-Headers @{");
|
||||
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
if (!first) {
|
||||
headerLines.add("");
|
||||
}
|
||||
headerLines.add(" \"" + escapePowerShellString(entry.getKey()) + "\"=\"" +
|
||||
escapePowerShellString(entry.getValue()) + "\"");
|
||||
first = false;
|
||||
}
|
||||
|
||||
headerLines.add("}");
|
||||
|
||||
// 将头部参数添加到主命令中
|
||||
invokeParams.add(String.join("`\n", headerLines));
|
||||
}
|
||||
|
||||
// 设置输出文件(如果指定了文件名)
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
invokeParams.add("-OutFile \"" + escapePowerShellString(meta.getFileName()) + "\"");
|
||||
}
|
||||
|
||||
// 将所有参数连接起来
|
||||
String invokeCommand = String.join(" `\n", invokeParams);
|
||||
lines.add(invokeCommand);
|
||||
|
||||
return String.join("\n", lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义 PowerShell 字符串中的特殊字符
|
||||
*/
|
||||
private String escapePowerShellString(String str) {
|
||||
if (str == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return str.replace("`", "``")
|
||||
.replace("\"", "`\"")
|
||||
.replace("$", "`$");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.POWERSHELL;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package cn.qaiu.parser.clientlink.impl;
|
||||
|
||||
import cn.qaiu.parser.clientlink.ClientLinkGenerator;
|
||||
import cn.qaiu.parser.clientlink.ClientLinkType;
|
||||
import cn.qaiu.parser.clientlink.DownloadLinkMeta;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* wget 命令生成器
|
||||
*
|
||||
* @author <a href="https://qaiu.top">QAIU</a>
|
||||
* Create at 2025/01/21
|
||||
*/
|
||||
public class WgetLinkGenerator implements ClientLinkGenerator {
|
||||
|
||||
@Override
|
||||
public String generate(DownloadLinkMeta meta) {
|
||||
if (!supports(meta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
parts.add("wget");
|
||||
|
||||
// 添加请求头
|
||||
if (meta.getHeaders() != null && !meta.getHeaders().isEmpty()) {
|
||||
for (Map.Entry<String, String> entry : meta.getHeaders().entrySet()) {
|
||||
parts.add("--header=\"" + entry.getKey() + ": " + entry.getValue() + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
// 设置输出文件名
|
||||
if (meta.getFileName() != null && !meta.getFileName().trim().isEmpty()) {
|
||||
parts.add("-O");
|
||||
parts.add("\"" + meta.getFileName() + "\"");
|
||||
}
|
||||
|
||||
// 添加URL
|
||||
parts.add("\"" + meta.getUrl() + "\"");
|
||||
|
||||
return String.join(" \\\n ", parts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientLinkType getType() {
|
||||
return ClientLinkType.WGET;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package cn.qaiu.parser.impl;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.AESUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import cn.qaiu.util.UUIDUtil;
|
||||
import cn.qaiu.util.*;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
@@ -13,12 +11,14 @@ 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.uritemplate.UriTemplate;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 小飞机网盘
|
||||
@@ -26,69 +26,113 @@ import java.util.List;
|
||||
* @version V016_230609
|
||||
*/
|
||||
public class FjTool extends PanBase {
|
||||
public static final String REFERER_URL = "https://share.feijipan.com/";
|
||||
private static final String API_URL_PREFIX = "https://api.feejii.com/ws/";
|
||||
|
||||
public static final String API_URL0 = "https://api.feijipan.com";
|
||||
private static final String API_URL_PREFIX = "https://api.feijipan.com/ws/";
|
||||
|
||||
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";
|
||||
/// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}&shareId={shareId}&type=0&offset=1&limit=60
|
||||
// recommend/list?devType=6&devModel=Chrome&uuid={uuid}&extra=2×tamp={ts}&shareId=JoUTkZYj&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";
|
||||
|
||||
private static final String TOKEN_VERIFY_URL = API_URL0 +
|
||||
"/app/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}";
|
||||
// https://api.feijipan.com/ws/file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}
|
||||
|
||||
//https://api.feijipan.com/ws/file/redirect?
|
||||
// downloadId=DBD34FFEDB71708FA5C284527F78E9EC104A9667FFEEA62CB6E00B54A3E0F5BB
|
||||
// &enable=1
|
||||
// &devType=6
|
||||
// &uuid=rTaNVSgmwY5MbEEuiMmQL
|
||||
// ×tamp=839E6B5E19223B8DF730A52F44062D48
|
||||
// &auth=F799422BCD9D05D7CCC5C9C53C1092C7029B420536135C3B4B7E064F49459DCC
|
||||
// &shareId=4wF7grHR
|
||||
private static final String SECOND_REQUEST_URL_VIP = API_URL_PREFIX +
|
||||
"file/redirect?downloadId={fidEncode}&enable=1&devType=6&uuid={uuid}×tamp={ts}&auth={auth}&shareId={dataKey}";
|
||||
|
||||
|
||||
private static final String VIP_REQUEST_URL = API_URL_PREFIX + "/buy/vip/list?devType=6&devModel=Chrome&uuid" +
|
||||
"={uuid}&extra=2×tamp={ts}";
|
||||
// https://api.feijipan.com/ws/buy/vip/list?devType=6&devModel=Chrome&uuid=WQAl5yBy1naGudJEILBvE&extra=2×tamp=E2C53155F6D09417A27981561134CB73
|
||||
|
||||
// https://api.feijipan.com/ws/share/list?devType=6&devModel=Chrome&uuid=pwRWqwbk1J-KMTlRZowrn&extra=2×tamp=C5F8A68C53121AB21FA35BA3529E8758&shareId=fmAuOh3m&folderId=28986333&offset=1&limit=60
|
||||
|
||||
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";
|
||||
|
||||
private static final MultiMap header;
|
||||
|
||||
private static final MultiMap header0;
|
||||
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs));
|
||||
String uuid = UUIDUtil.fjUuid(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
static {
|
||||
header = MultiMap.caseInsensitiveMultiMap();
|
||||
header.set("Accept", "application/json, text/plain, */*");
|
||||
header.set("Accept-Encoding", "gzip, deflate, br, zstd");
|
||||
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.feijipan.com");
|
||||
header.set("Origin", "https://www.feijix.com");
|
||||
header.set("Pragma", "no-cache");
|
||||
header.set("Referer", "https://www.feijix.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\"");
|
||||
header0 = MultiMap.caseInsensitiveMultiMap();
|
||||
header0.set("Accept-Encoding", "gzip, deflate");
|
||||
header0.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8");
|
||||
header0.set("Cache-Control", "no-cache");
|
||||
header0.set("Connection", "keep-alive");
|
||||
header0.set("Content-Length", "0");
|
||||
header0.set("DNT", "1");
|
||||
header0.set("Pragma", "no-cache");
|
||||
header0.set("Referer", "https://www.feijipan.com/");
|
||||
header0.set("Sec-Fetch-Dest", "empty");
|
||||
header0.set("Sec-Fetch-Mode", "cors");
|
||||
header0.set("Sec-Fetch-Site", "cross-site");
|
||||
header0.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");
|
||||
header0.set("sec-ch-ua", "\"Google Chrome\";v=\"131\", \"Chromium\";v=\"131\", \"Not_A Brand\";v=\"24\"");
|
||||
header0.set("sec-ch-ua-mobile", "?0");
|
||||
header0.set("sec-ch-ua-platform", "\"Windows\"");
|
||||
|
||||
header = HeaderUtils.parseHeaders("""
|
||||
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
|
||||
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
DNT: 1
|
||||
Pragma: no-cache
|
||||
Referer: https://www.feijix.com/
|
||||
Sec-Fetch-Dest: document
|
||||
Sec-Fetch-Mode: navigate
|
||||
Sec-Fetch-Site: cross-site
|
||||
Sec-Fetch-User: ?1
|
||||
Upgrade-Insecure-Requests: 1
|
||||
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0
|
||||
sec-ch-ua: "Microsoft Edge";v="135", "Not-A.Brand";v="8", "Chromium";v="135"
|
||||
sec-ch-ua-mobile: ?0
|
||||
sec-ch-ua-platform: "Windows"
|
||||
""");
|
||||
}
|
||||
|
||||
// String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
|
||||
|
||||
static String token = null;
|
||||
static String userId = null;
|
||||
public static boolean authFlag = true;
|
||||
|
||||
public FjTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
|
||||
// 240530 此处shareId又改为了原始的shareId
|
||||
// String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
final String shareId = shareLinkInfo.getShareKey();
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
long nowTs = System.currentTimeMillis();
|
||||
String tsEncode = AESUtils.encrypt2Hex(Long.toString(nowTs));
|
||||
|
||||
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
// 获取用户id
|
||||
if (auths.contains("userId")) {
|
||||
FjTool.userId = auths.get("userId");
|
||||
log.info("已配置用户ID: {}", FjTool.userId);
|
||||
} else {
|
||||
log.warn("未配置用户ID, 可能会导致解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 24.5.12 飞机盘 规则修改 需要固定UUID先请求会员接口, 再请求后续接口
|
||||
String url = StringUtils.isBlank(shareLinkInfo.getSharePassword()) ? FIRST_REQUEST_URL
|
||||
@@ -98,77 +142,312 @@ public class FjTool extends PanBase {
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(r0 -> { // 忽略res
|
||||
|
||||
// 第一次请求 获取文件信息
|
||||
// POST https://api.feijipan.com/ws/recommend/list?devType=6&devModel=Chrome&extra=2&shareId=146731&type=0&offset=1&limit=60
|
||||
client.postAbs(UriTemplate.of(url))
|
||||
.putHeaders(header)
|
||||
.putHeaders(header0)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 200) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||
|
||||
JsonObject resJson;
|
||||
try {
|
||||
resJson = asJson(res);
|
||||
} catch (Exception e) {
|
||||
log.error("获取文件信息失败: {}", res.bodyAsString());
|
||||
return;
|
||||
}
|
||||
if (resJson.getJsonArray("list").isEmpty()) {
|
||||
fail(FIRST_REQUEST_URL + " 解析文件列表为空: " + resJson);
|
||||
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);
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
String fileId = fileInfo.getString("fileIds");
|
||||
String userId = fileInfo.getString("userId");
|
||||
// 其他参数
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId);
|
||||
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
|
||||
|
||||
// 第二次请求
|
||||
HttpRequest<Buffer> httpRequest =
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.putHeaders(header)
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.setTemplateParam("auth", auth)
|
||||
.setTemplateParam("dataKey", shareId);
|
||||
// System.out.println(httpRequest.toString());
|
||||
httpRequest.send().onSuccess(res2 -> {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location")) {
|
||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res.headers());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
|
||||
}).onFailure(handleFail(FIRST_REQUEST_URL));
|
||||
}).onFailure(handleFail("请求1"));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
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.encrypt2Hex(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId);
|
||||
String auth = AESUtils.encrypt2Hex(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<Buffer> httpRequest =
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.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("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
// 验证token
|
||||
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header0).send().onSuccess(res -> {
|
||||
if (asJson(res).getInteger("code") != 200) {
|
||||
login(tsEncode2, auths).onFailure(failRes -> {
|
||||
log.warn("重新登录失败: {}", failRes.getMessage());
|
||||
fail(failRes.getMessage());
|
||||
}).onSuccess(r-> {
|
||||
httpRequest
|
||||
.setTemplateParam("fidEncode", fidEncode)
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
});
|
||||
} else {
|
||||
httpRequest
|
||||
.setTemplateParam("fidEncode", AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId))
|
||||
.putHeaders(header);
|
||||
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
|
||||
}
|
||||
}).onFailure(handleFail("Token验证"));
|
||||
}
|
||||
} else {
|
||||
// authFlag 为 false,使用免登录解析
|
||||
log.debug("authFlag=false,使用免登录解析");
|
||||
clientNoRedirects.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("无认证信息,使用免登录解析");
|
||||
clientNoRedirects.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<Void> login(String tsEncode2, MultiMap auths) {
|
||||
Promise<Void> promise1 = Promise.promise();
|
||||
// 如果配置了用户ID 则不登录
|
||||
if (FjTool.userId != null) {
|
||||
promise1.complete();
|
||||
return promise1.future();
|
||||
}
|
||||
client.postAbs(UriTemplate.of(LOGIN_URL))
|
||||
.setTemplateParam("uuid",uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header0)
|
||||
.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");
|
||||
header0.set("appToken", token);
|
||||
log.info("登录成功 token: {}", token);
|
||||
client.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode2)
|
||||
.putHeaders(header0).send().onSuccess(res -> {
|
||||
if (asJson(res).getInteger("code") == 200) {
|
||||
if (FjTool.userId == null) {
|
||||
FjTool.userId = asJson(res).getJsonObject("map").getString("userId");
|
||||
}
|
||||
log.info("验证成功 userId: {}", FjTool.userId);
|
||||
promise1.complete();
|
||||
} else {
|
||||
promise1.fail("验证失败: " + res.bodyAsString());
|
||||
}
|
||||
});
|
||||
} 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(fileId != null ? fileId.toString() : null)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setFileType(fileType == 1 ? "file" : "folder")
|
||||
.setFileIcon(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<Buffer> res2) {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location") || headers.get("Location") == null) {
|
||||
fail("找不到下载链接可能服务器已被禁止或者配置的认证信息有误: " + res2.bodyAsString());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
Promise<List<FileInfo>> promise0 = Promise.promise();
|
||||
|
||||
String shareId = shareLinkInfo.getShareKey(); // String.valueOf(AESUtils.idEncrypt(dataKey));
|
||||
|
||||
@@ -176,42 +455,52 @@ public class FjTool extends PanBase {
|
||||
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();
|
||||
parserDir(dirId, shareId, promise0);
|
||||
return promise0.future();
|
||||
}
|
||||
parse().onSuccess(id -> {
|
||||
if (id != null && id.matches("^[a-zA-Z0-9]+$")) {
|
||||
parserDir(id, shareId, promise);
|
||||
} else {
|
||||
promise.fail("解析目录ID失败");
|
||||
}
|
||||
parserDir(id, shareId, promise0);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
promise0.fail(failRes);
|
||||
});
|
||||
return promise.future();
|
||||
return promise0.future();
|
||||
}
|
||||
|
||||
private void parserDir(String id, String shareId, Promise<List<FileInfo>> promise) {
|
||||
// id以http开头直接返回 封装数组返回
|
||||
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<FileInfo> result = new ArrayList<>();
|
||||
result.add(fileInfo);
|
||||
promise.complete(result);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("开始解析目录: {}, shareId: {}, uuid: {}, ts: {}", id, shareId, uuid, tsEncode);
|
||||
// 开始解析目录: 164312216, shareId: bPMsbg5K, uuid: 0fmVWTx2Ea4zFwkpd7KXf, ts: 20865d7b7f00828279f437cd1f097860
|
||||
// 拿到目录ID
|
||||
client.postAbs(UriTemplate.of(FILE_LIST_URL))
|
||||
.putHeaders(header)
|
||||
.putHeaders(header0)
|
||||
.setTemplateParam("shareId", shareId)
|
||||
.setTemplateParam("uuid", uuid)
|
||||
.setTemplateParam("ts", tsEncode)
|
||||
.setTemplateParam("folderId", id)
|
||||
.send().onSuccess(res -> {
|
||||
JsonObject jsonObject;
|
||||
JsonArray list;
|
||||
try {
|
||||
jsonObject = asJson(res);
|
||||
JsonObject jsonObject = asJson(res);
|
||||
System.out.println(jsonObject.encodePrettily());
|
||||
list = jsonObject.getJsonArray("list");
|
||||
} catch (Exception e) {
|
||||
promise.fail(FIRST_REQUEST_URL + " 解析JSON失败: " + res.bodyAsString());
|
||||
log.error("解析目录失败: {}", res.bodyAsString());
|
||||
return;
|
||||
}
|
||||
// System.out.println(jsonObject.encodePrettily());
|
||||
JsonArray list = jsonObject.getJsonArray("list");
|
||||
ArrayList<FileInfo> result = new ArrayList<>();
|
||||
list.forEach(item->{
|
||||
JsonObject fileJson = (JsonObject) item;
|
||||
@@ -224,7 +513,7 @@ public class FjTool extends PanBase {
|
||||
// 其他参数
|
||||
long nowTs2 = System.currentTimeMillis();
|
||||
String tsEncode2 = AESUtils.encrypt2Hex(Long.toString(nowTs2));
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + userId);
|
||||
String fidEncode = AESUtils.encrypt2Hex(fileId + "|" + FjTool.userId);
|
||||
String auth = AESUtils.encrypt2Hex(fileId + "|" + nowTs2);
|
||||
|
||||
// 回传用到的参数
|
||||
@@ -239,8 +528,7 @@ public class FjTool extends PanBase {
|
||||
"ts", tsEncode2,
|
||||
"auth", auth,
|
||||
"shareId", shareId);
|
||||
byte[] encode = Base64.getEncoder().encode(entries.encode().getBytes());
|
||||
String param = new String(encode);
|
||||
String param = CommonUtils.urlBase64Encode(entries.encode());
|
||||
|
||||
if (fileJson.getInteger("fileType") == 2) {
|
||||
// 如果是目录
|
||||
@@ -280,17 +568,15 @@ public class FjTool extends PanBase {
|
||||
result.add(fileInfo);
|
||||
});
|
||||
promise.complete(result);
|
||||
}).onFailure(failRes -> {
|
||||
log.error("解析目录请求失败: {}", failRes.getMessage());
|
||||
promise.fail(failRes);
|
||||
});;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
|
||||
// 第二次请求
|
||||
JsonObject paramJson = (JsonObject)shareLinkInfo.getOtherParam().get("paramJson");
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
clientNoRedirects.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
|
||||
.setTemplateParam("fidEncode", paramJson.getString("fidEncode"))
|
||||
.setTemplateParam("uuid", paramJson.getString("uuid"))
|
||||
.setTemplateParam("ts", paramJson.getString("ts"))
|
||||
@@ -299,11 +585,16 @@ public class FjTool extends PanBase {
|
||||
.putHeaders(header).send().onSuccess(res2 -> {
|
||||
MultiMap headers = res2.headers();
|
||||
if (!headers.contains("Location")) {
|
||||
fail(SECOND_REQUEST_URL + " 未找到重定向URL: \n" + res2.headers());
|
||||
fail(SECOND_REQUEST_URL_VIP + " 未找到重定向URL: \n" + res2.headers());
|
||||
return;
|
||||
}
|
||||
promise.complete(headers.get("Location"));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL_VIP));
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
public static void resetToken() {
|
||||
token = null;
|
||||
authFlag = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +1,623 @@
|
||||
package cn.qaiu.parser.impl;
|
||||
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.entity.FileInfo;
|
||||
import cn.qaiu.entity.ShareLinkInfo;
|
||||
import cn.qaiu.parser.PanBase;
|
||||
import cn.qaiu.util.CommonUtils;
|
||||
import cn.qaiu.util.CookieUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import cn.qaiu.util.HeaderUtils;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.HttpHeaders;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.IntStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
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
|
||||
Referer: https://pan.quark.cn/
|
||||
Origin: https://pan.quark.cn
|
||||
Accept: application/json, text/plain, */*
|
||||
""");
|
||||
|
||||
// 保存 auths 引用,用于更新 cookie
|
||||
private MultiMap auths;
|
||||
|
||||
public QkTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
// 参考 UcTool 实现,从认证配置中取 cookie 放到请求头
|
||||
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
String cookie = auths.get("cookie");
|
||||
if (cookie != null && !cookie.isEmpty()) {
|
||||
// 过滤出夸克网盘所需的 cookie 字段
|
||||
cookie = CookieUtils.filterUcQuarkCookie(cookie);
|
||||
|
||||
// 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie
|
||||
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
|
||||
cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus);
|
||||
log.debug("夸克: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
|
||||
}
|
||||
header.set(HttpHeaders.COOKIE, cookie);
|
||||
// 同步更新 auths
|
||||
auths.set("cookie", cookie);
|
||||
}
|
||||
}
|
||||
this.client = clientDisableUA;
|
||||
|
||||
// 如果 __puus 已过期或不存在,触发异步刷新
|
||||
if (needRefreshPuus()) {
|
||||
log.debug("夸克: __puus 需要刷新,触发异步刷新");
|
||||
refreshPuusCookie();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要刷新 __puus
|
||||
* @return true 表示需要刷新
|
||||
*/
|
||||
private boolean needRefreshPuus() {
|
||||
String currentCookie = header.get(HttpHeaders.COOKIE);
|
||||
if (currentCookie == null || currentCookie.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 必须包含 __pus 才能刷新
|
||||
if (!currentCookie.contains("__pus=")) {
|
||||
return false;
|
||||
}
|
||||
// 缓存过期或不存在时需要刷新
|
||||
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
|
||||
}
|
||||
|
||||
public Future<String> parse() {
|
||||
final String key = shareLinkInfo.getShareKey();
|
||||
final String pwd = shareLinkInfo.getSharePassword();
|
||||
/**
|
||||
* 刷新 __puus Cookie
|
||||
* 通过调用 auth/pc/flush API,服务器会返回 set-cookie 来更新 __puus
|
||||
* @return Future 包含是否刷新成功
|
||||
*/
|
||||
public Future<Boolean> refreshPuusCookie() {
|
||||
Promise<Boolean> 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")
|
||||
.addQueryParam("uc_param_str", "")
|
||||
.putHeaders(header)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
// 从响应头获取 set-cookie
|
||||
List<String> setCookies = res.cookies();
|
||||
String newPuus = null;
|
||||
|
||||
for (String cookie : setCookies) {
|
||||
if (cookie.startsWith("__puus=")) {
|
||||
// 提取 __puus 值(只取到分号前的部分)
|
||||
int endIndex = cookie.indexOf(';');
|
||||
newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newPuus != null) {
|
||||
// 更新 cookie:替换或添加 __puus
|
||||
String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus);
|
||||
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 {
|
||||
log.debug("夸克: 响应中未包含 __puus,可能 cookie 仍然有效");
|
||||
refreshPromise.complete(false);
|
||||
}
|
||||
})
|
||||
.onFailure(t -> {
|
||||
log.warn("夸克: 刷新 __puus cookie 失败: {}", t.getMessage());
|
||||
refreshPromise.complete(false);
|
||||
});
|
||||
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parse() {
|
||||
String pwdId = shareLinkInfo.getShareKey();
|
||||
String passcode = shareLinkInfo.getSharePassword();
|
||||
if (passcode == null) {
|
||||
passcode = "";
|
||||
}
|
||||
|
||||
log.debug("开始解析夸克网盘分享,pwd_id: {}, passcode: {}", pwdId, passcode.isEmpty() ? "无" : "有");
|
||||
|
||||
// 第一步:获取分享 token
|
||||
JsonObject tokenRequest = new JsonObject()
|
||||
.put("pwd_id", pwdId)
|
||||
.put("passcode", passcode);
|
||||
|
||||
client.postAbs(TOKEN_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(tokenRequest)
|
||||
.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")
|
||||
.addQueryParam("fr", "pc")
|
||||
.addQueryParam("pwd_id", pwdId)
|
||||
.addQueryParam("stoken", stoken)
|
||||
.addQueryParam("pdir_fid", "0")
|
||||
.addQueryParam("force", "0")
|
||||
.addQueryParam("_page", "1")
|
||||
.addQueryParam("_size", "50")
|
||||
.addQueryParam("_fetch_banner", "1")
|
||||
.addQueryParam("_fetch_share", "1")
|
||||
.addQueryParam("_fetch_total", "1")
|
||||
.addQueryParam("_sort", "file_type:asc,updated_at:desc")
|
||||
.putHeaders(header)
|
||||
.send()
|
||||
.onSuccess(res2 -> {
|
||||
log.debug("第二阶段响应: {}", res2.bodyAsString());
|
||||
JsonObject resJson2 = asJson(res2);
|
||||
|
||||
if (resJson2.getInteger("code") != 0) {
|
||||
fail(DETAIL_URL + " 返回异常: " + resJson2);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
fail("未找到文件");
|
||||
return;
|
||||
}
|
||||
|
||||
// 过滤出文件(排除文件夹)
|
||||
List<JsonObject> 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())) {
|
||||
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列表
|
||||
List<String> fileIds = new ArrayList<>();
|
||||
for (JsonObject file : files) {
|
||||
String fid = file.getString("fid");
|
||||
if (fid != null && !fid.isEmpty()) {
|
||||
fileIds.add(fid);
|
||||
}
|
||||
}
|
||||
|
||||
if (fileIds.isEmpty()) {
|
||||
fail("无法提取文件ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// 第三步:批量获取下载链接
|
||||
getDownloadLinksBatch(fileIds, stoken)
|
||||
.onSuccess(downloadData -> {
|
||||
if (downloadData.isEmpty()) {
|
||||
fail("未能获取到下载链接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取第一个文件的下载链接
|
||||
String downloadUrl = downloadData.get(0).getString("download_url");
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
fail("下载链接为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 夸克网盘需要配合下载请求头,保存下载请求头
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
|
||||
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
|
||||
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://pan.quark.cn/");
|
||||
|
||||
log.debug("成功获取下载链接: {}", downloadUrl);
|
||||
completeWithMeta(downloadUrl, downloadHeaders);
|
||||
})
|
||||
.onFailure(handleFail(DOWNLOAD_URL));
|
||||
|
||||
}).onFailure(handleFail(DETAIL_URL));
|
||||
})
|
||||
.onFailure(handleFail(TOKEN_URL));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取下载链接(分批处理)
|
||||
*/
|
||||
private Future<List<JsonObject>> getDownloadLinksBatch(List<String> fileIds, String stoken) {
|
||||
List<JsonObject> allResults = new ArrayList<>();
|
||||
Promise<List<JsonObject>> promise = Promise.promise();
|
||||
|
||||
// 同步处理每个批次
|
||||
processBatch(fileIds, stoken, 0, allResults, promise);
|
||||
|
||||
promise.complete("https://lz.qaiu.top");
|
||||
IntStream.range(0, 1000).forEach(num -> {
|
||||
clientNoRedirects.getAbs(key).send()
|
||||
.onSuccess(res -> {
|
||||
String location = res.headers().get("Location");
|
||||
System.out.println(num + ":" + location);
|
||||
})
|
||||
.onFailure(handleFail("连接失败"));
|
||||
try {
|
||||
TimeUnit.MILLISECONDS.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
private void processBatch(List<String> fileIds, String stoken, int startIndex, List<JsonObject> allResults, Promise<List<JsonObject>> promise) {
|
||||
if (startIndex >= fileIds.size()) {
|
||||
// 所有批次处理完成
|
||||
promise.complete(allResults);
|
||||
return;
|
||||
}
|
||||
|
||||
int endIndex = Math.min(startIndex + BATCH_SIZE, fileIds.size());
|
||||
List<String> batch = fileIds.subList(startIndex, endIndex);
|
||||
|
||||
log.debug("正在获取第 {} 批下载链接 ({} 个文件)", startIndex / BATCH_SIZE + 1, batch.size());
|
||||
|
||||
JsonObject downloadRequest = new JsonObject()
|
||||
.put("fids", new JsonArray(batch));
|
||||
|
||||
client.postAbs(DOWNLOAD_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(downloadRequest)
|
||||
.onSuccess(res -> {
|
||||
log.debug("下载链接响应: {}", res.bodyAsString());
|
||||
JsonObject resJson = asJson(res);
|
||||
|
||||
if (resJson.getInteger("code") == 31001) {
|
||||
promise.fail("未登录或 Cookie 已失效");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(DOWNLOAD_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray batchData = resJson.getJsonArray("data");
|
||||
if (batchData != null) {
|
||||
for (int i = 0; i < batchData.size(); i++) {
|
||||
allResults.add(batchData.getJsonObject(i));
|
||||
}
|
||||
log.debug("成功获取 {} 个下载链接", batchData.size());
|
||||
}
|
||||
|
||||
// 处理下一批次
|
||||
processBatch(fileIds, stoken, endIndex, allResults, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("获取下载链接失败: " + t.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
|
||||
String pwdId = shareLinkInfo.getShareKey();
|
||||
String passcode = shareLinkInfo.getSharePassword();
|
||||
final String finalPasscode = (passcode == null) ? "" : passcode;
|
||||
|
||||
// 如果参数里的目录ID不为空,则直接解析目录
|
||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
||||
if (dirId != null && !dirId.isEmpty()) {
|
||||
String stoken = (String) shareLinkInfo.getOtherParam().get("stoken");
|
||||
if (stoken != null) {
|
||||
parseDir(dirId, pwdId, finalPasscode, stoken, promise);
|
||||
return promise.future();
|
||||
}
|
||||
}
|
||||
|
||||
// 第一步:获取 stoken
|
||||
JsonObject tokenRequest = new JsonObject()
|
||||
.put("pwd_id", pwdId)
|
||||
.put("passcode", finalPasscode);
|
||||
|
||||
client.postAbs(TOKEN_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(tokenRequest)
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(TOKEN_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
String stoken = resJson.getJsonObject("data").getString("stoken");
|
||||
if (stoken == null || stoken.isEmpty()) {
|
||||
promise.fail("无法获取分享 token");
|
||||
return;
|
||||
}
|
||||
// 解析根目录(dirId = "0")
|
||||
String rootDirId = dirId != null ? dirId : "0";
|
||||
parseDir(rootDirId, pwdId, finalPasscode, stoken, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage()));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise<List<FileInfo>> promise) {
|
||||
// 第二步:获取文件列表(支持指定目录)
|
||||
// 夸克 API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0"
|
||||
log.info("夸克 parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken);
|
||||
|
||||
client.getAbs(DETAIL_URL)
|
||||
.addQueryParam("pr", "ucpro")
|
||||
.addQueryParam("fr", "pc")
|
||||
.addQueryParam("pwd_id", pwdId)
|
||||
.addQueryParam("stoken", stoken)
|
||||
.addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID
|
||||
.addQueryParam("force", "0")
|
||||
.addQueryParam("_page", "1")
|
||||
.addQueryParam("_size", "50")
|
||||
.addQueryParam("_fetch_banner", "1")
|
||||
.addQueryParam("_fetch_share", "1")
|
||||
.addQueryParam("_fetch_total", "1")
|
||||
.addQueryParam("_sort", "file_type:asc,file_name:asc")
|
||||
.putHeaders(header)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = asJson(res);
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(DETAIL_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list");
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
log.warn("夸克 API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily());
|
||||
promise.complete(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("夸克 API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId);
|
||||
List<FileInfo> result = new ArrayList<>();
|
||||
for (int i = 0; i < fileList.size(); i++) {
|
||||
JsonObject item = fileList.getJsonObject(i);
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
|
||||
// 调试:打印前3个 item 的完整结构
|
||||
if (i < 3) {
|
||||
log.info("夸克 API 返回的 item[{}] 结构: {}", i, item.encodePrettily());
|
||||
log.info("夸克 API item[{}] 所有字段名: {}", i, item.fieldNames());
|
||||
}
|
||||
|
||||
String fid = item.getString("fid");
|
||||
String fileName = item.getString("file_name");
|
||||
Boolean isFile = item.getBoolean("file", true);
|
||||
Long fileSize = item.getLong("size", 0L);
|
||||
String updatedAt = item.getString("updated_at");
|
||||
String objCategory = item.getString("obj_category");
|
||||
String shareFidToken = item.getString("share_fid_token");
|
||||
String parentId = item.getString("parent_id");
|
||||
|
||||
log.info("处理夸克 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}, objCategory={}",
|
||||
i, fid, fileName, parentId, dirId, isFile, objCategory);
|
||||
|
||||
fileInfo.setFileId(fid)
|
||||
.setFileName(fileName)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setCreateTime(updatedAt)
|
||||
.setUpdateTime(updatedAt)
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
// 判断是否为文件:file=true 或 obj_category 不为空
|
||||
if (isFile || (objCategory != null && !objCategory.isEmpty())) {
|
||||
// 文件
|
||||
fileInfo.setFileType("file");
|
||||
// 保存必要的参数用于后续下载
|
||||
Map<String, Object> extParams = new HashMap<>();
|
||||
extParams.put("fid", fid);
|
||||
extParams.put("pwd_id", pwdId);
|
||||
extParams.put("stoken", stoken);
|
||||
if (shareFidToken != null) {
|
||||
extParams.put("share_fid_token", shareFidToken);
|
||||
}
|
||||
fileInfo.setExtParameters(extParams);
|
||||
// 设置解析URL(用于下载)
|
||||
JsonObject paramJson = new JsonObject(extParams);
|
||||
String param = CommonUtils.urlBase64Encode(paramJson.encode());
|
||||
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
getDomainName(), shareLinkInfo.getType(), param));
|
||||
} else {
|
||||
// 文件夹
|
||||
fileInfo.setFileType("folder");
|
||||
fileInfo.setSize(0L);
|
||||
fileInfo.setSizeStr("0B");
|
||||
// 设置目录解析URL(用于递归解析子目录)
|
||||
// 对 URL 参数进行编码,确保特殊字符正确传递
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
|
||||
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
|
||||
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
|
||||
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
||||
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,使用原始值
|
||||
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()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
Promise<String> 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")
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(bodyJson)
|
||||
.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()) {
|
||||
promise.fail("夸克 API 返回的下载链接列表为空");
|
||||
return;
|
||||
}
|
||||
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
promise.fail("未找到下载链接");
|
||||
return;
|
||||
}
|
||||
promise.complete(downloadUrl);
|
||||
} catch (Exception e) {
|
||||
promise.fail("解析夸克下载链接失败: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage()));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,39 @@
|
||||
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.CookieUtils;
|
||||
import cn.qaiu.util.DateTimeUtils;
|
||||
import cn.qaiu.util.FileSizeConverter;
|
||||
import cn.qaiu.util.HeaderUtils;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.MultiMap;
|
||||
import io.vertx.core.Promise;
|
||||
import io.vertx.core.http.HttpHeaders;
|
||||
import io.vertx.core.json.JsonArray;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.uritemplate.UriTemplate;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* UC网盘解析
|
||||
*/
|
||||
public class UcTool extends PanBase {
|
||||
private static final String API_URL_PREFIX = "https://pc-api.uc.cn/1/clouddrive/";
|
||||
|
||||
// 静态变量:缓存 __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;
|
||||
|
||||
public static final String SHARE_URL_PREFIX = "https://fast.uc.cn/s/";
|
||||
|
||||
@@ -23,19 +45,155 @@ public class UcTool extends PanBase {
|
||||
|
||||
private static final String THIRD_REQUEST_URL = API_URL_PREFIX + "file/download?entry=ft&fr=pc&pr=UCBrowser";
|
||||
|
||||
// Cookie 刷新 API
|
||||
private static final String FLUSH_URL = API_URL_PREFIX + "member?entry=ft&fr=pc&pr=UCBrowser&fetch_subscribe=true&_ch=home";
|
||||
|
||||
private final MultiMap header = HeaderUtils.parseHeaders("""
|
||||
accept-language: zh-CN,zh;q=0.9,en;q=0.8
|
||||
cache-control: no-cache
|
||||
dnt: 1
|
||||
origin: https://drive.uc.cn
|
||||
pragma: no-cache
|
||||
priority: u=1, i
|
||||
referer: https://drive.uc.cn/
|
||||
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
|
||||
sec-ch-ua-mobile: ?0
|
||||
sec-ch-ua-platform: "Windows"
|
||||
sec-fetch-dest: empty
|
||||
sec-fetch-mode: cors
|
||||
sec-fetch-site: same-site
|
||||
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
|
||||
""");
|
||||
|
||||
// 保存 auths 引用,用于更新 cookie
|
||||
private MultiMap auths;
|
||||
|
||||
public UcTool(ShareLinkInfo shareLinkInfo) {
|
||||
super(shareLinkInfo);
|
||||
// 参考其它网盘实现,从认证配置中取 cookie 放到请求头
|
||||
if (shareLinkInfo.getOtherParam() != null && shareLinkInfo.getOtherParam().containsKey("auths")) {
|
||||
auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
|
||||
String cookie = auths.get("cookie");
|
||||
if (cookie != null && !cookie.isEmpty()) {
|
||||
// 过滤出 UC 网盘所需的 cookie 字段
|
||||
cookie = CookieUtils.filterUcQuarkCookie(cookie);
|
||||
|
||||
// 如果有缓存的 __puus 且未过期,使用缓存的值更新 cookie
|
||||
if (cachedPuus != null && System.currentTimeMillis() < puusExpireTime) {
|
||||
cookie = CookieUtils.updateCookieValue(cookie, "__puus", cachedPuus);
|
||||
log.debug("UC: 使用缓存的 __puus (剩余有效期: {}s)", (puusExpireTime - System.currentTimeMillis()) / 1000);
|
||||
}
|
||||
header.set(HttpHeaders.COOKIE, cookie);
|
||||
// 同步更新 auths
|
||||
auths.set("cookie", cookie);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果 __puus 已过期或不存在,触发异步刷新
|
||||
if (needRefreshPuus()) {
|
||||
log.debug("UC: __puus 需要刷新,触发异步刷新");
|
||||
refreshPuusCookie();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否需要刷新 __puus
|
||||
* @return true 表示需要刷新
|
||||
*/
|
||||
private boolean needRefreshPuus() {
|
||||
String currentCookie = header.get(HttpHeaders.COOKIE);
|
||||
if (currentCookie == null || currentCookie.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 必须包含 __pus 才能刷新
|
||||
if (!currentCookie.contains("__pus=")) {
|
||||
return false;
|
||||
}
|
||||
// 缓存过期或不存在时需要刷新
|
||||
return cachedPuus == null || System.currentTimeMillis() >= puusExpireTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 __puus Cookie
|
||||
* 通过调用 member API,服务器会返回 set-cookie 来更新 __puus
|
||||
* @return Future 包含是否刷新成功
|
||||
*/
|
||||
public Future<Boolean> refreshPuusCookie() {
|
||||
Promise<Boolean> refreshPromise = Promise.promise();
|
||||
|
||||
String currentCookie = header.get(HttpHeaders.COOKIE);
|
||||
if (currentCookie == null || currentCookie.isEmpty()) {
|
||||
log.debug("UC: 无 cookie,跳过刷新");
|
||||
refreshPromise.complete(false);
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
// 检查是否包含 __pus(用于获取 __puus)
|
||||
if (!currentCookie.contains("__pus=")) {
|
||||
log.debug("UC: cookie 中不包含 __pus,跳过刷新");
|
||||
refreshPromise.complete(false);
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
log.debug("UC: 开始刷新 __puus cookie");
|
||||
|
||||
client.getAbs(FLUSH_URL)
|
||||
.putHeaders(header)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
// 从响应头获取 set-cookie
|
||||
List<String> setCookies = res.cookies();
|
||||
String newPuus = null;
|
||||
|
||||
for (String cookie : setCookies) {
|
||||
if (cookie.startsWith("__puus=")) {
|
||||
// 提取 __puus 值(只取到分号前的部分)
|
||||
int endIndex = cookie.indexOf(';');
|
||||
newPuus = endIndex > 0 ? cookie.substring(0, endIndex) : cookie;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newPuus != null) {
|
||||
// 更新 cookie:替换或添加 __puus
|
||||
String updatedCookie = CookieUtils.updateCookieValue(currentCookie, "__puus", newPuus);
|
||||
header.set(HttpHeaders.COOKIE, updatedCookie);
|
||||
|
||||
// 同步更新 auths 中的 cookie
|
||||
if (auths != null) {
|
||||
auths.set("cookie", updatedCookie);
|
||||
}
|
||||
|
||||
// 更新静态缓存
|
||||
cachedPuus = newPuus;
|
||||
puusExpireTime = System.currentTimeMillis() + PUUS_TTL_MS;
|
||||
|
||||
log.info("UC: __puus cookie 刷新成功,有效期至: {}ms", puusExpireTime);
|
||||
refreshPromise.complete(true);
|
||||
} else {
|
||||
log.debug("UC: 响应中未包含 __puus,可能 cookie 仍然有效");
|
||||
refreshPromise.complete(false);
|
||||
}
|
||||
})
|
||||
.onFailure(t -> {
|
||||
log.warn("UC: 刷新 __puus cookie 失败: {}", t.getMessage());
|
||||
refreshPromise.complete(false);
|
||||
});
|
||||
|
||||
return refreshPromise.future();
|
||||
}
|
||||
|
||||
public Future<String> parse() {
|
||||
var dataKey = shareLinkInfo.getShareKey();
|
||||
var passcode = shareLinkInfo.getSharePassword();
|
||||
String dataKey = shareLinkInfo.getShareKey();
|
||||
String pwd = shareLinkInfo.getShareKey();
|
||||
|
||||
var passcode = (pwd == null) ? "" : pwd;
|
||||
var jsonObject = JsonObject.of("share_for_transfer", true);
|
||||
jsonObject.put("pwd_id", dataKey);
|
||||
jsonObject.put("passcode", passcode);
|
||||
// 第一次请求 获取文件信息
|
||||
client.postAbs(FIRST_REQUEST_URL).sendJsonObject(jsonObject).onSuccess(res -> {
|
||||
client.postAbs(FIRST_REQUEST_URL)
|
||||
.putHeaders(header).sendJsonObject(jsonObject).onSuccess(res -> {
|
||||
log.debug("第一阶段 {}", res.body());
|
||||
var resJson = res.bodyAsJsonObject();
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
@@ -48,6 +206,7 @@ public class UcTool extends PanBase {
|
||||
.setTemplateParam("pwd_id", dataKey)
|
||||
.setTemplateParam("passcode", passcode)
|
||||
.setTemplateParam("stoken", stoken)
|
||||
.putHeaders(header)
|
||||
.send().onSuccess(res2 -> {
|
||||
log.debug("第二阶段 {}", res2.body());
|
||||
JsonObject resJson2 = res2.bodyAsJsonObject();
|
||||
@@ -55,24 +214,71 @@ public class UcTool extends PanBase {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
|
||||
return;
|
||||
}
|
||||
// 文件信息
|
||||
var info = resJson2.getJsonObject("data").getJsonArray("list").getJsonObject(0);
|
||||
// 第二次请求
|
||||
var bodyJson = JsonObject.of()
|
||||
.put("fids", JsonArray.of(info.getString("fid")))
|
||||
.put("pwd_id", dataKey)
|
||||
.put("stoken", stoken)
|
||||
.put("fids_token", JsonArray.of(info.getString("share_fid_token")));
|
||||
client.postAbs(THIRD_REQUEST_URL).sendJsonObject(bodyJson)
|
||||
.onSuccess(res3 -> {
|
||||
log.debug("第三阶段 {}", res3.body());
|
||||
var resJson3 = res3.bodyAsJsonObject();
|
||||
if (resJson3.getInteger("code") != 0) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
|
||||
return;
|
||||
}
|
||||
promise.complete(resJson3.getJsonArray("data").getJsonObject(0).getString("download_url"));
|
||||
}).onFailure(handleFail(THIRD_REQUEST_URL));
|
||||
try {
|
||||
// 文件信息
|
||||
JsonArray list = resJson2.getJsonObject("data").getJsonArray("list");
|
||||
if (list == null || list.isEmpty()) {
|
||||
fail("UC API 返回的文件列表为空");
|
||||
return;
|
||||
}
|
||||
var info = list.getJsonObject(0);
|
||||
|
||||
// 提取文件信息并保存到 otherParam
|
||||
try {
|
||||
FileInfo fileInfo = new FileInfo();
|
||||
fileInfo.setFileId(info.getString("fid"))
|
||||
.setFileName(info.getString("file_name"))
|
||||
.setSize(info.getLong("size", 0L))
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(info.getLong("size", 0L)))
|
||||
.setFileType(info.getBoolean("file", true) ? "file" : "folder")
|
||||
.setCreateTime(DateTimeUtils.formatTimestampToDateTime(info.getString("created_at")))
|
||||
.setUpdateTime(DateTimeUtils.formatTimestampToDateTime(info.getString("updated_at")))
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
// 保存到 otherParam,供 CacheServiceImpl 使用
|
||||
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
||||
log.debug("UC 提取文件信息: {}", fileInfo.getFileName());
|
||||
} catch (Exception e) {
|
||||
log.warn("UC 提取文件信息失败,继续解析: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// 第三次请求获取下载链接
|
||||
var bodyJson = JsonObject.of()
|
||||
.put("fids", JsonArray.of(info.getString("fid")))
|
||||
.put("pwd_id", dataKey)
|
||||
.put("stoken", stoken)
|
||||
.put("fids_token", JsonArray.of(info.getString("share_fid_token")));
|
||||
client.postAbs(THIRD_REQUEST_URL)
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(bodyJson)
|
||||
.onSuccess(res3 -> {
|
||||
log.debug("第三阶段 {}", res3.body());
|
||||
var resJson3 = res3.bodyAsJsonObject();
|
||||
if (resJson3.getInteger("code") != 0) {
|
||||
fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JsonArray dataList = resJson3.getJsonArray("data");
|
||||
if (dataList == null || dataList.isEmpty()) {
|
||||
fail("UC API 返回的下载链接列表为空");
|
||||
return;
|
||||
}
|
||||
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
|
||||
// UC网盘需要配合aria2下载,保存下载请求头
|
||||
Map<String, String> downloadHeaders = new HashMap<>();
|
||||
// 将header转换为Map 只需要包含cookie,user-agent,referer
|
||||
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
|
||||
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
|
||||
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
|
||||
completeWithMeta(downloadUrl, downloadHeaders);
|
||||
} catch (Exception e) {
|
||||
fail("解析 UC 下载链接失败: " + e.getMessage());
|
||||
}
|
||||
}).onFailure(handleFail(THIRD_REQUEST_URL));
|
||||
} catch (Exception e) {
|
||||
fail("解析 UC 文件信息失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
}).onFailure(handleFail(SECOND_REQUEST_URL));
|
||||
}
|
||||
@@ -80,43 +286,288 @@ public class UcTool extends PanBase {
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
// https://dl-uf-zb.pds.uc.cn/l3PNAKfz/64623447/
|
||||
// 646b0de6e9f13000c9b14ba182b805312795a82a/
|
||||
// 646b0de6717e1bfa5bb44dd2a456f103c5177850?
|
||||
// Expires=1737784900&OSSAccessKeyId=LTAI5tJJpWQEfrcKHnd1LqsZ&
|
||||
// Signature=oBVV3anhv3tBKanHUcEIsktkB%2BM%3D&x-oss-traffic-limit=503316480
|
||||
// &response-content-disposition=attachment%3B%20filename%3DC%2523%2520Shell%2520%2528C%2523%2520Offline%2520Compiler%2529_2.5.16.apks
|
||||
// %3Bfilename%2A%3Dutf-8%27%27C%2523%2520Shell%2520%2528C%2523%2520Offline%2520Compiler%2529_2.5.16.apks
|
||||
|
||||
//eyJ4OmF1IjoiLSIsIng6dWQiOiI0LU4tNS0wLTYtTi0zLWZ0LTAtMi1OLU4iLCJ4OnNwIjoiMTAwIiwieDp0b2tlbiI6IjQtZjY0ZmMxMDFjZmQxZGVkNTRkMGM0NmMzYzliMzkyOWYtNS03LTE1MzYxMS1kYWNiMzY2NWJiYWE0ZjVlOWQzNzgwMGVjNjQwMzE2MC0wLTAtMC0wLTQ5YzUzNTE3OGIxOTY0YzhjYzUwYzRlMDk5MTZmYWRhIiwieDp0dGwiOiIxMDgwMCJ9
|
||||
//eyJjYWxsYmFja0JvZHlUeXBlIjoiYXBwbGljYXRpb24vanNvbiIsImNhbGxiYWNrU3RhZ2UiOiJiZWZvcmUtZXhlY3V0ZSIsImNhbGxiYWNrRmFpbHVyZUFjdGlvbiI6Imlnbm9yZSIsImNhbGxiYWNrVXJsIjoiaHR0cHM6Ly9hdXRoLWNkbi51Yy5jbi9vdXRlci9vc3MvY2hlY2twbGF5IiwiY2FsbGJhY2tCb2R5Ijoie1wiaG9zdFwiOiR7aHR0cEhlYWRlci5ob3N0fSxcInNpemVcIjoke3NpemV9LFwicmFuZ2VcIjoke2h0dHBIZWFkZXIucmFuZ2V9LFwicmVmZXJlclwiOiR7aHR0cEhlYWRlci5yZWZlcmVyfSxcImNvb2tpZVwiOiR7aHR0cEhlYWRlci5jb29raWV9LFwibWV0aG9kXCI6JHtodHRwSGVhZGVyLm1ldGhvZH0sXCJpcFwiOiR7Y2xpZW50SXB9LFwicG9ydFwiOiR7Y2xpZW50UG9ydH0sXCJvYmplY3RcIjoke29iamVjdH0sXCJzcFwiOiR7eDpzcH0sXCJ1ZFwiOiR7eDp1ZH0sXCJ0b2tlblwiOiR7eDp0b2tlbn0sXCJhdVwiOiR7eDphdX0sXCJ0dGxcIjoke3g6dHRsfSxcImR0X3NwXCI6JHt4OmR0X3NwfSxcImhzcFwiOiR7eDpoc3B9LFwiY2xpZW50X3Rva2VuXCI6JHtxdWVyeVN0cmluZy5jbGllbnRfdG9rZW59fSJ9
|
||||
//callback-var {"x:au":"-","x:ud":"4-N-5-0-6-N-3-ft-0-2-N-N","x:sp":"100","x:token":"4-f64fc101cfd1ded54d0c46c3c9b3929f-5-7-153611-dacb3665bbaa4f5e9d37800ec6403160-0-0-0-0-49c535178b1964c8cc50c4e09916fada","x:ttl":"10800"}
|
||||
//callback {"callbackBodyType":"application/json","callbackStage":"before-execute","callbackFailureAction":"ignore","callbackUrl":"https://auth-cdn.uc.cn/outer/oss/checkplay","callbackBody":"{\"host\":${httpHeader.host},\"size\":${size},\"range\":${httpHeader.range},\"referer\":${httpHeader.referer},\"cookie\":${httpHeader.cookie},\"method\":${httpHeader.method},\"ip\":${clientIp},\"port\":${clientPort},\"object\":${object},\"sp\":${x:sp},\"ud\":${x:ud},\"token\":${x:token},\"au\":${x:au},\"ttl\":${x:ttl},\"dt_sp\":${x:dt_sp},\"hsp\":${x:hsp},\"client_token\":${queryString.client_token}}"}
|
||||
|
||||
/*
|
||||
// callback-var
|
||||
{
|
||||
"x:au": "-",
|
||||
"x:ud": "4-N-5-0-6-N-3-ft-0-2-N-N",
|
||||
"x:sp": "100",
|
||||
"x:token": "4-f64fc101cfd1ded54d0c46c3c9b3929f-5-7-153611-dacb3665bbaa4f5e9d37800ec6403160-0-0-0-0-49c535178b1964c8cc50c4e09916fada",
|
||||
"x:ttl": "10800"
|
||||
}
|
||||
|
||||
// callback
|
||||
{
|
||||
"callbackBodyType": "application/json",
|
||||
"callbackStage": "before-execute",
|
||||
"callbackFailureAction": "ignore",
|
||||
"callbackUrl": "https://auth-cdn.uc.cn/outer/oss/checkplay",
|
||||
"callbackBody": "{\"host\":${httpHeader.host},\"size\":${size},\"range\":${httpHeader.range},\"referer\":${httpHeader.referer},\"cookie\":${httpHeader.cookie},\"method\":${httpHeader.method},\"ip\":${clientIp},\"port\":${clientPort},\"object\":${object},\"sp\":${x:sp},\"ud\":${x:ud},\"token\":${x:token},\"au\":${x:au},\"ttl\":${x:ttl},\"dt_sp\":${x:dt_sp},\"hsp\":${x:hsp},\"client_token\":${queryString.client_token}}"
|
||||
}
|
||||
*/
|
||||
|
||||
new UcTool(ShareLinkInfo.newBuilder().shareUrl("https://fast.uc.cn/s/33197dd53ace4").shareKey("33197dd53ace4").build()).parse().onSuccess(
|
||||
System.out::println
|
||||
);
|
||||
// 目录解析
|
||||
@Override
|
||||
public Future<List<FileInfo>> parseFileList() {
|
||||
Promise<List<FileInfo>> promise = Promise.promise();
|
||||
|
||||
String pwdId = shareLinkInfo.getShareKey();
|
||||
String passcode = shareLinkInfo.getSharePassword();
|
||||
final String finalPasscode = (passcode == null) ? "" : passcode;
|
||||
|
||||
// 如果参数里的目录ID不为空,则直接解析目录
|
||||
String dirId = (String) shareLinkInfo.getOtherParam().get("dirId");
|
||||
if (dirId != null && !dirId.isEmpty()) {
|
||||
String stoken = (String) shareLinkInfo.getOtherParam().get("stoken");
|
||||
if (stoken != null) {
|
||||
parseDir(dirId, pwdId, finalPasscode, stoken, promise);
|
||||
return promise.future();
|
||||
}
|
||||
}
|
||||
|
||||
// 第一步:获取 stoken
|
||||
JsonObject tokenRequest = JsonObject.of("share_for_transfer", true)
|
||||
.put("pwd_id", pwdId)
|
||||
.put("passcode", finalPasscode);
|
||||
|
||||
client.postAbs(FIRST_REQUEST_URL)
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(tokenRequest)
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = res.bodyAsJsonObject();
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(FIRST_REQUEST_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
String stoken = resJson.getJsonObject("data").getString("stoken");
|
||||
if (stoken == null || stoken.isEmpty()) {
|
||||
promise.fail("无法获取分享 token");
|
||||
return;
|
||||
}
|
||||
// 解析根目录(dirId = "0" 或空)
|
||||
String rootDirId = dirId != null ? dirId : "0";
|
||||
parseDir(rootDirId, pwdId, finalPasscode, stoken, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("获取 token 失败: " + t.getMessage()));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
private void parseDir(String dirId, String pwdId, String passcode, String stoken, Promise<List<FileInfo>> promise) {
|
||||
// 第二步:获取文件列表
|
||||
// UC API 使用 pdir_fid 参数指定父目录 ID,根目录为 "0"
|
||||
log.info("UC parseDir 开始: dirId={}, pwdId={}, stoken={}", dirId, pwdId, stoken);
|
||||
|
||||
client.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
|
||||
.setTemplateParam("pwd_id", pwdId)
|
||||
.setTemplateParam("passcode", passcode)
|
||||
.setTemplateParam("stoken", stoken)
|
||||
.addQueryParam("entry", "ft")
|
||||
.addQueryParam("pdir_fid", dirId != null ? dirId : "0") // 关键参数:父目录 ID
|
||||
.addQueryParam("fetch_file_list", "1")
|
||||
.addQueryParam("_page", "1")
|
||||
.addQueryParam("_size", "50")
|
||||
.addQueryParam("_fetch_total", "1")
|
||||
.addQueryParam("_fetch_share", "1")
|
||||
.addQueryParam("_sort", "file_type:asc,file_name:asc")
|
||||
.addQueryParam("fr", "pc")
|
||||
.addQueryParam("pr", "UCBrowser")
|
||||
.putHeaders(header)
|
||||
.send()
|
||||
.onSuccess(res -> {
|
||||
JsonObject resJson = res.bodyAsJsonObject();
|
||||
Integer code = resJson.getInteger("code");
|
||||
String message = resJson.getString("message");
|
||||
// 如果 stoken 失效(code=14001 或错误消息包含"token"),重新获取 stoken 后重试
|
||||
if ((code != null && code == 14001) ||
|
||||
(message != null && (message.contains("token") || message.contains("Token") || message.contains("非法token")))) {
|
||||
log.debug("stoken 已失效,重新获取: {}", resJson);
|
||||
// 重新获取 stoken
|
||||
JsonObject tokenRequest = JsonObject.of("share_for_transfer", true)
|
||||
.put("pwd_id", pwdId)
|
||||
.put("passcode", passcode);
|
||||
client.postAbs(FIRST_REQUEST_URL)
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(tokenRequest)
|
||||
.onSuccess(res2 -> {
|
||||
JsonObject resJson2 = res2.bodyAsJsonObject();
|
||||
if (resJson2.getInteger("code") != 0) {
|
||||
promise.fail(FIRST_REQUEST_URL + " 返回异常: " + resJson2);
|
||||
return;
|
||||
}
|
||||
String newStoken = resJson2.getJsonObject("data").getString("stoken");
|
||||
if (newStoken == null || newStoken.isEmpty()) {
|
||||
promise.fail("无法获取分享 token");
|
||||
return;
|
||||
}
|
||||
// 使用新的 stoken 重试
|
||||
parseDir(dirId, pwdId, passcode, newStoken, promise);
|
||||
})
|
||||
.onFailure(t -> promise.fail("重新获取 token 失败: " + t.getMessage()));
|
||||
return;
|
||||
}
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(SECOND_REQUEST_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
|
||||
JsonArray fileList = resJson.getJsonObject("data").getJsonArray("list");
|
||||
if (fileList == null || fileList.isEmpty()) {
|
||||
log.warn("UC API 返回的文件列表为空,dirId: {}, response: {}", dirId, resJson.encodePrettily());
|
||||
promise.complete(new ArrayList<>());
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("UC API 返回文件列表,总数: {}, dirId: {}", fileList.size(), dirId);
|
||||
List<FileInfo> 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("UC API 返回的 item[{}] 结构: {}", i, item.encodePrettily());
|
||||
log.info("UC API item[{}] 所有字段名: {}", i, item.fieldNames());
|
||||
}
|
||||
|
||||
String fid = item.getString("fid");
|
||||
// UC API 可能使用 file_name 或 name,优先尝试 file_name
|
||||
String fileName = item.getString("file_name");
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
fileName = item.getString("name");
|
||||
}
|
||||
// 如果还是为空,尝试其他可能的字段名
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
fileName = item.getString("fileName");
|
||||
}
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
fileName = item.getString("title");
|
||||
}
|
||||
|
||||
// 如果文件名仍为空,记录警告
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
log.warn("UC API 返回的 item 中未找到文件名字段,item: {}", item.encode());
|
||||
}
|
||||
Boolean isFile = item.getBoolean("file", true);
|
||||
Long fileSize = item.getLong("size", 0L);
|
||||
String updatedAt = item.getString("updated_at");
|
||||
String shareFidToken = item.getString("share_fid_token");
|
||||
String parentId = item.getString("parent_id");
|
||||
|
||||
// 临时移除过滤逻辑,查看 API 实际返回数据
|
||||
log.info("准备处理 item[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}", i, fid, fileName, parentId, dirId, isFile);
|
||||
|
||||
// 如果当前项的 fid 等于请求的 dirId,说明是当前目录本身,跳过
|
||||
if (fid != null && fid.equals(dirId) && !"0".equals(dirId)) {
|
||||
log.info("跳过当前目录本身: fid={}, dirId={}, fileName={}", fid, dirId, fileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// UC API 可能不支持目录参数,返回所有文件
|
||||
// 暂时不过滤,返回所有文件,查看实际数据
|
||||
log.info("添加文件到结果[{}]: fid={}, fileName={}, parentId={}, dirId={}, isFile={}", i, fid, fileName, parentId, dirId, isFile);
|
||||
|
||||
fileInfo.setFileId(fid)
|
||||
.setFileName(fileName)
|
||||
.setSize(fileSize)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(fileSize))
|
||||
.setCreateTime(DateTimeUtils.formatTimestampToDateTime(updatedAt))
|
||||
.setUpdateTime(DateTimeUtils.formatTimestampToDateTime(updatedAt))
|
||||
.setPanType(shareLinkInfo.getType());
|
||||
|
||||
if (isFile) {
|
||||
// 文件
|
||||
fileInfo.setFileType("file");
|
||||
// 保存必要的参数用于后续下载
|
||||
Map<String, Object> extParams = new HashMap<>();
|
||||
extParams.put("fid", fid);
|
||||
extParams.put("pwd_id", pwdId);
|
||||
extParams.put("stoken", stoken);
|
||||
if (shareFidToken != null) {
|
||||
extParams.put("share_fid_token", shareFidToken);
|
||||
}
|
||||
fileInfo.setExtParameters(extParams);
|
||||
// 设置解析URL(用于下载)
|
||||
JsonObject paramJson = new JsonObject(extParams);
|
||||
String param = CommonUtils.urlBase64Encode(paramJson.encode());
|
||||
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
getDomainName(), shareLinkInfo.getType(), param));
|
||||
} else {
|
||||
// 文件夹
|
||||
fileInfo.setFileType("folder");
|
||||
fileInfo.setSize(0L);
|
||||
fileInfo.setSizeStr("0B");
|
||||
// 设置目录解析URL(用于递归解析子目录)
|
||||
// 对 URL 参数进行编码,确保特殊字符正确传递
|
||||
try {
|
||||
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
|
||||
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
|
||||
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
|
||||
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
|
||||
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
|
||||
} catch (Exception e) {
|
||||
// 如果编码失败,使用原始值
|
||||
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()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Future<String> parseById() {
|
||||
Promise<String> 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("UC parseById: fid={}, pwd_id={}, stoken={}", fid, pwdId, stoken);
|
||||
|
||||
// 调用第三次请求获取下载链接
|
||||
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(THIRD_REQUEST_URL)
|
||||
.putHeaders(header)
|
||||
.sendJsonObject(bodyJson)
|
||||
.onSuccess(res -> {
|
||||
log.debug("UC parseById 响应: {}", res.body());
|
||||
JsonObject resJson = res.bodyAsJsonObject();
|
||||
if (resJson.getInteger("code") != 0) {
|
||||
promise.fail(THIRD_REQUEST_URL + " 返回异常: " + resJson);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JsonArray dataList = resJson.getJsonArray("data");
|
||||
if (dataList == null || dataList.isEmpty()) {
|
||||
promise.fail("UC API 返回的下载链接列表为空");
|
||||
return;
|
||||
}
|
||||
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
|
||||
if (downloadUrl == null || downloadUrl.isEmpty()) {
|
||||
promise.fail("未找到下载链接");
|
||||
return;
|
||||
}
|
||||
promise.complete(downloadUrl);
|
||||
} catch (Exception e) {
|
||||
promise.fail("解析 UC 下载链接失败: " + e.getMessage());
|
||||
}
|
||||
})
|
||||
.onFailure(t -> promise.fail("请求下载链接失败: " + t.getMessage()));
|
||||
|
||||
return promise.future();
|
||||
}
|
||||
|
||||
// public static void main(String[] args) {
|
||||
// // https://drive.uc.cn/s/12450d1694844?public=1
|
||||
// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess(
|
||||
// System.out::println
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
185
parser/src/main/java/cn/qaiu/util/CookieUtils.java
Normal file
185
parser/src/main/java/cn/qaiu/util/CookieUtils.java
Normal file
@@ -0,0 +1,185 @@
|
||||
package cn.qaiu.util;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Cookie 工具类
|
||||
* 用于过滤和处理 Cookie 字符串
|
||||
*/
|
||||
public class CookieUtils {
|
||||
|
||||
/**
|
||||
* UC/夸克网盘常用的 Cookie 字段
|
||||
*/
|
||||
public static final List<String> UC_QUARK_COOKIE_KEYS = Arrays.asList(
|
||||
"__pus", // 主要的用户会话标识(最重要)
|
||||
"__kp", // 用户标识
|
||||
"__kps", // 会话密钥
|
||||
"__ktd", // 会话令牌
|
||||
"__uid", // 用户ID
|
||||
"__puus" // 用户会话签名
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据指定的 key 列表过滤 cookie
|
||||
*
|
||||
* @param cookieStr 原始 cookie 字符串,格式如 "key1=value1; key2=value2"
|
||||
* @param keys 需要保留的 cookie key 列表
|
||||
* @return 过滤后的 cookie 字符串,只包含指定的 key
|
||||
*/
|
||||
public static String filterCookie(String cookieStr, List<String> keys) {
|
||||
if (cookieStr == null || cookieStr.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
if (keys == null || keys.isEmpty()) {
|
||||
return cookieStr;
|
||||
}
|
||||
|
||||
// 将 keys 转为 Set 以提高查找效率
|
||||
Set<String> keySet = new HashSet<>(keys);
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
String[] cookies = cookieStr.split(";\\s*");
|
||||
|
||||
for (String cookie : cookies) {
|
||||
if (cookie.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 提取 cookie 的 key
|
||||
int equalIndex = cookie.indexOf('=');
|
||||
if (equalIndex > 0) {
|
||||
String key = cookie.substring(0, equalIndex).trim();
|
||||
|
||||
// 如果 key 在需要的列表中,保留这个 cookie
|
||||
if (keySet.contains(key)) {
|
||||
if (result.length() > 0) {
|
||||
result.append("; ");
|
||||
}
|
||||
result.append(cookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 UC/夸克网盘默认的 cookie 字段过滤
|
||||
*
|
||||
* @param cookieStr 原始 cookie 字符串
|
||||
* @return 过滤后的 cookie 字符串
|
||||
*/
|
||||
public static String filterUcQuarkCookie(String cookieStr) {
|
||||
return filterCookie(cookieStr, UC_QUARK_COOKIE_KEYS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 cookie 字符串中提取指定 key 的值
|
||||
*
|
||||
* @param cookieStr cookie 字符串
|
||||
* @param key 要提取的 cookie key
|
||||
* @return cookie 值,如果不存在返回 null
|
||||
*/
|
||||
public static String getCookieValue(String cookieStr, String key) {
|
||||
if (cookieStr == null || cookieStr.isEmpty() || key == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String[] cookies = cookieStr.split(";\\s*");
|
||||
for (String cookie : cookies) {
|
||||
if (cookie.startsWith(key + "=")) {
|
||||
int equalIndex = cookie.indexOf('=');
|
||||
if (equalIndex > 0 && equalIndex < cookie.length() - 1) {
|
||||
return cookie.substring(equalIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 cookie 字符串中是否包含指定的 key
|
||||
*
|
||||
* @param cookieStr cookie 字符串
|
||||
* @param key 要检查的 cookie key
|
||||
* @return true 表示包含该 key
|
||||
*/
|
||||
public static boolean containsKey(String cookieStr, String key) {
|
||||
return getCookieValue(cookieStr, key) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 cookie 字符串中的指定 cookie 值
|
||||
*
|
||||
* @param cookieStr 原始 cookie 字符串
|
||||
* @param cookieName cookie 名称
|
||||
* @param newValue 新的完整 cookie 值(格式:cookieName=value)
|
||||
* @return 更新后的 cookie 字符串
|
||||
*/
|
||||
public static String updateCookieValue(String cookieStr, String cookieName, String newValue) {
|
||||
if (cookieStr == null || cookieStr.isEmpty()) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
String[] cookies = cookieStr.split(";\\s*");
|
||||
boolean found = false;
|
||||
|
||||
for (String cookie : cookies) {
|
||||
if (cookie.startsWith(cookieName + "=")) {
|
||||
// 替换为新值
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(newValue);
|
||||
found = true;
|
||||
} else if (!cookie.isEmpty()) {
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(cookie);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果原来没有这个 cookie,添加它
|
||||
if (!found) {
|
||||
if (result.length() > 0) result.append("; ");
|
||||
result.append(newValue);
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并多个 cookie 字符串,后面的会覆盖前面的同名 cookie
|
||||
*
|
||||
* @param cookieStrings cookie 字符串数组
|
||||
* @return 合并后的 cookie 字符串
|
||||
*/
|
||||
public static String mergeCookies(String... cookieStrings) {
|
||||
if (cookieStrings == null || cookieStrings.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
Map<String, String> cookieMap = new LinkedHashMap<>();
|
||||
|
||||
for (String cookieStr : cookieStrings) {
|
||||
if (cookieStr == null || cookieStr.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] cookies = cookieStr.split(";\\s*");
|
||||
for (String cookie : cookies) {
|
||||
if (cookie.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int equalIndex = cookie.indexOf('=');
|
||||
if (equalIndex > 0) {
|
||||
String key = cookie.substring(0, equalIndex).trim();
|
||||
cookieMap.put(key, cookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String.join("; ", cookieMap.values());
|
||||
}
|
||||
}
|
||||
111
parser/src/main/java/cn/qaiu/util/DateTimeUtils.java
Normal file
111
parser/src/main/java/cn/qaiu/util/DateTimeUtils.java
Normal file
@@ -0,0 +1,111 @@
|
||||
package cn.qaiu.util;
|
||||
|
||||
import java.time.*;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 日期时间工具类,用于转换各种时间戳格式为可读的日期字符串
|
||||
*/
|
||||
public class DateTimeUtils {
|
||||
|
||||
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
|
||||
|
||||
/**
|
||||
* 将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式
|
||||
* @param millis 毫秒时间戳
|
||||
* @return 格式化后的日期字符串
|
||||
*/
|
||||
public static String formatMillisToDateTime(long millis) {
|
||||
return FORMATTER.format(Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).toLocalDateTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将毫秒时间戳字符串转换为 yyyy-MM-dd HH:mm:ss 格式
|
||||
* @param millisStr 毫秒时间戳字符串(如 "1684737715067")
|
||||
* @return 格式化后的日期字符串
|
||||
*/
|
||||
public static String formatMillisStringToDateTime(String millisStr) {
|
||||
if (millisStr == null || millisStr.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
long millis = Long.parseLong(millisStr.trim());
|
||||
return formatMillisToDateTime(millis);
|
||||
} catch (NumberFormatException e) {
|
||||
// 如果解析失败,返回原始值
|
||||
return millisStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将秒级时间戳转换为 yyyy-MM-dd HH:mm:ss 格式
|
||||
* @param seconds 秒级时间戳
|
||||
* @return 格式化后的日期字符串
|
||||
*/
|
||||
public static String formatSecondsToDateTime(long seconds) {
|
||||
return FORMATTER.format(Instant.ofEpochSecond(seconds).atZone(ZoneId.systemDefault()).toLocalDateTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将秒级时间戳字符串转换为 yyyy-MM-dd HH:mm:ss 格式
|
||||
* @param secondsStr 秒级时间戳字符串
|
||||
* @return 格式化后的日期字符串
|
||||
*/
|
||||
public static String formatSecondsStringToDateTime(String secondsStr) {
|
||||
if (secondsStr == null || secondsStr.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
long seconds = Long.parseLong(secondsStr.trim());
|
||||
return formatSecondsToDateTime(seconds);
|
||||
} catch (NumberFormatException e) {
|
||||
// 如果解析失败,返回原始值
|
||||
return secondsStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能转换时间戳:自动判断是毫秒还是秒级
|
||||
* 根据值的大小判断:如果大于等于 10000000000(即 2286-11-20),视为毫秒;否则视为秒级
|
||||
* @param timestamp 时间戳字符串
|
||||
* @return 格式化后的日期字符串
|
||||
*/
|
||||
public static String formatTimestampToDateTime(String timestamp) {
|
||||
if (timestamp == null || timestamp.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
long value = Long.parseLong(timestamp.trim());
|
||||
// 10000000000 对应 2286-11-20(毫秒)或 1970-04-26(秒级)
|
||||
// 使用 10^10 作为分界线
|
||||
if (value >= 10_000_000_000L) {
|
||||
return formatMillisToDateTime(value);
|
||||
} else {
|
||||
return formatSecondsToDateTime(value);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// 如果是 ISO 8601 格式,尝试解析
|
||||
return formatISODateTime(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并格式化 ISO 8601 格式的日期时间字符串
|
||||
* @param isoDateTime ISO 8601 格式的日期时间字符串
|
||||
* @return 格式化后的日期字符串
|
||||
*/
|
||||
public static String formatISODateTime(String isoDateTime) {
|
||||
if (isoDateTime == null || isoDateTime.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
OffsetDateTime offsetDateTime = OffsetDateTime.parse(isoDateTime, ISO_FORMATTER);
|
||||
return FORMATTER.format(offsetDateTime.toLocalDateTime());
|
||||
} catch (Exception e) {
|
||||
// 如果格式化失败,直接返回原始值
|
||||
return isoDateTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user