mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-30 05:22:01 +00:00
- 修复 12 处 NPE 风险: FjTool/FsTool/IzTool/LzTool/MkwTool/P115Tool/PdbTool/QQTool/ParserCreate/CommonUtils/ShareLinkInfo/URLParamUtil - 修复 4 处 Vert.x 资源泄漏: 测试类中 Vertx 实例未关闭 - 修复 CacheManager 防重入和 registerPeriodicCleanup 就绪检查 - 修复 ParserApi 中 redirectUrl()/viewUrl() Promise 未 complete - 修复 CacheManager.updateTotalByField Promise 永不完成 - 修复 AppMain ShutdownHook 注册,确保 Vert.x 先于 JDBCPoolInit 关闭 - 修复 RouterHandlerFactory failureHandler 恢复返回 failure message - 修复 ParserCreate/LzTool 收窄 catch 异常类型 - 修复 IzTool/FjTool/IzToolWithAuth 并发安全 (volatile + header 副本) - 修复 P115Tool UA 为 null 时的 NPE,添加默认 User-Agent - Font Awesome CDN 换源为 s4.zstatic.net,避免 bootcdn 投毒风险 - DirectoryTree selectAll 补 parserUrl 检查,Home 组件名 App→Home
497 lines
20 KiB
Java
497 lines
20 KiB
Java
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 io.vertx.core.Future;
|
|
import io.vertx.core.Promise;
|
|
import io.vertx.core.json.JsonArray;
|
|
import io.vertx.core.json.JsonObject;
|
|
|
|
import java.net.URLDecoder;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
/**
|
|
* <a href="https://www.feishu.cn/">飞书云盘</a>
|
|
* <p>
|
|
* 支持飞书公开分享文件和文件夹的解析。
|
|
* <ul>
|
|
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
|
|
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
|
|
* </ul>
|
|
* 飞书下载需要先获取匿名会话Cookie,然后使用Cookie请求下载接口。
|
|
* </p>
|
|
*/
|
|
public class FsTool extends PanBase {
|
|
|
|
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
|
|
|
/**
|
|
* 飞书 obj_type: type=12 表示上传文件可下载
|
|
*/
|
|
private static final int OBJ_TYPE_FILE = 12;
|
|
|
|
/**
|
|
* v3 列表 API 支持的 obj_type
|
|
*/
|
|
private static final int[] LIST_OBJ_TYPES = {
|
|
0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124
|
|
};
|
|
|
|
/** 每页返回条目数 */
|
|
private static final int PAGE_SIZE = 50;
|
|
|
|
/**
|
|
* 从分享链接中提取 tenant 的正则
|
|
*/
|
|
private static final Pattern TENANT_PATTERN =
|
|
Pattern.compile("https://([^.]+)\\.feishu\\.cn/");
|
|
|
|
/** 解析 Content-Disposition: filename*=UTF-8''xxx */
|
|
private static final Pattern CD_FILENAME_STAR_PATTERN =
|
|
Pattern.compile("filename\\*=UTF-8''(.+?)(?:;|$)");
|
|
|
|
/** 解析 Content-Disposition: filename="xxx" 或 filename=xxx */
|
|
private static final Pattern CD_FILENAME_PATTERN =
|
|
Pattern.compile("filename=\"?([^\";]+)\"?");
|
|
|
|
/** 解析 Content-Range 中的总大小 */
|
|
private static final Pattern CONTENT_RANGE_SIZE_PATTERN =
|
|
Pattern.compile("/(\\d+)");
|
|
|
|
public FsTool(ShareLinkInfo shareLinkInfo) {
|
|
super(shareLinkInfo);
|
|
}
|
|
|
|
@Override
|
|
public Future<String> parse() {
|
|
String shareUrl = shareLinkInfo.getShareUrl();
|
|
String tenant = extractTenant(shareUrl);
|
|
String token = shareLinkInfo.getShareKey();
|
|
|
|
if (tenant == null || token == null) {
|
|
fail("无法从链接中提取tenant或token: {}", shareUrl);
|
|
return promise.future();
|
|
}
|
|
|
|
boolean isFolder = shareUrl.contains("/drive/folder/");
|
|
if (isFolder) {
|
|
fetchSessionAndParseFolder(tenant, token, shareUrl);
|
|
} else {
|
|
fetchSessionAndParseFile(tenant, token, shareUrl);
|
|
}
|
|
|
|
return promise.future();
|
|
}
|
|
|
|
/**
|
|
* 获取匿名session后解析文件
|
|
*/
|
|
private void fetchSessionAndParseFile(String tenant, String token, String shareUrl) {
|
|
clientSession.getAbs(shareUrl)
|
|
.putHeader("User-Agent", UA)
|
|
.putHeader("Accept", "text/html,*/*")
|
|
.send()
|
|
.onSuccess(res -> {
|
|
String dlUrl = buildDownloadUrl(tenant, token);
|
|
|
|
// Range探测获取文件名和大小
|
|
clientSession.getAbs(dlUrl)
|
|
.putHeader("User-Agent", UA)
|
|
.putHeader("Referer", shareUrl)
|
|
.putHeader("Range", "bytes=0-0")
|
|
.send()
|
|
.onSuccess(probeRes -> {
|
|
String fileName = parseFileNameFromContentDisposition(
|
|
probeRes.getHeader("Content-Disposition"));
|
|
|
|
Map<String, String> headers = new HashMap<>();
|
|
headers.put("Referer", shareUrl);
|
|
headers.put("User-Agent", UA);
|
|
|
|
String cookies = extractCookiesFromResponse(probeRes);
|
|
if (cookies != null && !cookies.isEmpty()) {
|
|
headers.put("Cookie", cookies);
|
|
}
|
|
|
|
if (fileName != null) {
|
|
FileInfo fileInfo = new FileInfo();
|
|
fileInfo.setFileName(fileName);
|
|
fileInfo.setFileId(token);
|
|
fileInfo.setFileType("file");
|
|
fileInfo.setPanType(shareLinkInfo.getType());
|
|
fileInfo.setParserUrl(buildRedirectUrl(shareUrl, token));
|
|
parseSizeFromContentRange(
|
|
probeRes.getHeader("Content-Range"), fileInfo);
|
|
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
|
|
}
|
|
|
|
completeWithMeta(dlUrl, headers);
|
|
})
|
|
.onFailure(handleFail("探测文件信息失败"));
|
|
})
|
|
.onFailure(handleFail("获取匿名会话失败"));
|
|
}
|
|
|
|
/**
|
|
* 获取匿名session后解析文件夹(取第一个可下载文件)
|
|
*/
|
|
private void fetchSessionAndParseFolder(String tenant, String folderToken,
|
|
String shareUrl) {
|
|
clientSession.getAbs(shareUrl)
|
|
.putHeader("User-Agent", UA)
|
|
.putHeader("Accept", "text/html,*/*")
|
|
.send()
|
|
.onSuccess(res ->
|
|
listFolderAll(tenant, folderToken, "").onSuccess(items -> {
|
|
if (items.isEmpty()) {
|
|
fail("文件夹中没有可下载的文件");
|
|
return;
|
|
}
|
|
FileInfo first = items.get(0);
|
|
String objToken = first.getFileId();
|
|
String dlUrl = buildDownloadUrl(tenant, objToken);
|
|
String referer = "https://" + tenant
|
|
+ ".feishu.cn/drive/folder/" + folderToken;
|
|
|
|
Map<String, String> headers = new HashMap<>();
|
|
headers.put("Referer", referer);
|
|
headers.put("User-Agent", UA);
|
|
|
|
shareLinkInfo.getOtherParam().put("fileInfo", first);
|
|
completeWithMeta(dlUrl, headers);
|
|
}).onFailure(t -> fail("列出文件夹内容失败: {}", t.getMessage())))
|
|
.onFailure(handleFail("获取匿名会话失败"));
|
|
}
|
|
|
|
@Override
|
|
public Future<List<FileInfo>> parseFileList() {
|
|
Promise<List<FileInfo>> listPromise = Promise.promise();
|
|
String shareUrl = shareLinkInfo.getShareUrl();
|
|
String tenant = extractTenant(shareUrl);
|
|
String token = shareLinkInfo.getShareKey();
|
|
|
|
if (tenant == null || token == null) {
|
|
listPromise.fail("无法从链接中提取tenant或token: " + shareUrl);
|
|
return listPromise.future();
|
|
}
|
|
|
|
boolean isFolder = shareUrl.contains("/drive/folder/");
|
|
|
|
clientSession.getAbs(shareUrl)
|
|
.putHeader("User-Agent", UA)
|
|
.putHeader("Accept", "text/html,*/*")
|
|
.send()
|
|
.onSuccess(res -> {
|
|
if (isFolder) {
|
|
listFolderAll(tenant, token, "")
|
|
.onSuccess(listPromise::complete)
|
|
.onFailure(listPromise::fail);
|
|
} else {
|
|
probeSingleFile(tenant, token, shareUrl)
|
|
.onSuccess(fileInfo -> {
|
|
List<FileInfo> list = new ArrayList<>();
|
|
list.add(fileInfo);
|
|
listPromise.complete(list);
|
|
})
|
|
.onFailure(listPromise::fail);
|
|
}
|
|
})
|
|
.onFailure(t -> listPromise.fail("获取匿名会话失败: " + t.getMessage()));
|
|
|
|
return listPromise.future();
|
|
}
|
|
|
|
/**
|
|
* 分页获取文件夹所有可下载文件
|
|
*/
|
|
private Future<List<FileInfo>> listFolderAll(String tenant, String folderToken,
|
|
String pageLabel) {
|
|
Promise<List<FileInfo>> p = Promise.promise();
|
|
|
|
listFolderPage(tenant, folderToken, pageLabel).onSuccess(pageResult -> {
|
|
List<FileInfo> items = new ArrayList<>(pageResult.items);
|
|
if (pageResult.hasMore) {
|
|
listFolderAll(tenant, folderToken, pageResult.nextLabel)
|
|
.onSuccess(moreItems -> {
|
|
items.addAll(moreItems);
|
|
p.complete(items);
|
|
})
|
|
.onFailure(p::fail);
|
|
} else {
|
|
p.complete(items);
|
|
}
|
|
}).onFailure(p::fail);
|
|
|
|
return p.future();
|
|
}
|
|
|
|
/**
|
|
* 列出文件夹内容(单页)
|
|
*/
|
|
private Future<FolderPageResult> listFolderPage(String tenant, String folderToken,
|
|
String pageLabel) {
|
|
Promise<FolderPageResult> p = Promise.promise();
|
|
String baseUrl = "https://" + tenant + ".feishu.cn";
|
|
|
|
StringBuilder urlBuilder = new StringBuilder();
|
|
urlBuilder.append(baseUrl)
|
|
.append("/space/api/explorer/v3/children/list/")
|
|
.append("?length=").append(PAGE_SIZE)
|
|
.append("&asc=1&rank=5&token=").append(folderToken);
|
|
|
|
for (int type : LIST_OBJ_TYPES) {
|
|
urlBuilder.append("&obj_type=").append(type);
|
|
}
|
|
|
|
if (pageLabel != null && !pageLabel.isEmpty()) {
|
|
urlBuilder.append("&last_label=").append(pageLabel);
|
|
}
|
|
|
|
String url = urlBuilder.toString();
|
|
String referer = baseUrl + "/drive/folder/" + folderToken;
|
|
|
|
clientSession.getAbs(url)
|
|
.putHeader("User-Agent", UA)
|
|
.putHeader("Accept", "application/json, text/plain, */*")
|
|
.putHeader("Referer", referer)
|
|
.send()
|
|
.onSuccess(res -> {
|
|
try {
|
|
JsonObject json = asJson(res);
|
|
int code = json.getInteger("code", -1);
|
|
if (code != 0) {
|
|
p.fail("飞书API错误: " + json.getString("msg"));
|
|
return;
|
|
}
|
|
|
|
JsonObject data = json.getJsonObject("data");
|
|
JsonObject entities = data.getJsonObject("entities",
|
|
new JsonObject());
|
|
JsonObject nodes = entities.getJsonObject("nodes",
|
|
new JsonObject());
|
|
JsonArray nodeList = data.getJsonArray("node_list",
|
|
new JsonArray());
|
|
|
|
List<FileInfo> items = new ArrayList<>();
|
|
for (int i = 0; i < nodeList.size(); i++) {
|
|
String nid = nodeList.getString(i);
|
|
JsonObject node = nodes.getJsonObject(nid,
|
|
new JsonObject());
|
|
int objType = node.getInteger("type", -1);
|
|
String objToken = node.getString("obj_token", "");
|
|
String name = node.getString("name", "unknown");
|
|
|
|
// 排除文件夹自身节点
|
|
if (objToken.equals(folderToken)) {
|
|
continue;
|
|
}
|
|
|
|
// 只返回可下载的文件(type=12)
|
|
if (objType == OBJ_TYPE_FILE) {
|
|
FileInfo fileInfo = new FileInfo();
|
|
fileInfo.setFileName(name);
|
|
fileInfo.setFileId(objToken);
|
|
fileInfo.setPanType(shareLinkInfo.getType());
|
|
fileInfo.setFileType("file");
|
|
|
|
JsonObject extra = node.getJsonObject("extra",
|
|
new JsonObject());
|
|
try {
|
|
long size = Long.parseLong(
|
|
extra.getString("size", "0"));
|
|
fileInfo.setSize(size);
|
|
} catch (NumberFormatException e) {
|
|
log.warn("无法解析文件大小: {}", extra.getString("size"), e);
|
|
}
|
|
|
|
fileInfo.setParserUrl(buildRedirectUrl(
|
|
shareLinkInfo.getShareUrl(), objToken));
|
|
|
|
// 添加下载所需的请求头到extParameters
|
|
Map<String, Object> extParams = new HashMap<>();
|
|
Map<String, String> downloadHeaders = new HashMap<>();
|
|
downloadHeaders.put("Referer", referer);
|
|
downloadHeaders.put("User-Agent", UA);
|
|
extParams.put("downloadHeaders", downloadHeaders);
|
|
fileInfo.setExtParameters(extParams);
|
|
|
|
items.add(fileInfo);
|
|
}
|
|
}
|
|
|
|
boolean hasMore = data.getBoolean("has_more", false);
|
|
String nextLabel = data.getString("last_label", "");
|
|
|
|
p.complete(new FolderPageResult(items, hasMore, nextLabel));
|
|
} catch (Exception e) {
|
|
p.fail("解析文件列表响应失败: " + e.getMessage());
|
|
}
|
|
})
|
|
.onFailure(t -> p.fail("请求文件列表失败: " + t.getMessage()));
|
|
|
|
return p.future();
|
|
}
|
|
|
|
/**
|
|
* 探测单个文件信息
|
|
*/
|
|
private Future<FileInfo> probeSingleFile(String tenant, String token,
|
|
String referer) {
|
|
Promise<FileInfo> p = Promise.promise();
|
|
String dlUrl = buildDownloadUrl(tenant, token);
|
|
|
|
clientSession.getAbs(dlUrl)
|
|
.putHeader("User-Agent", UA)
|
|
.putHeader("Referer", referer)
|
|
.putHeader("Range", "bytes=0-0")
|
|
.send()
|
|
.onSuccess(probeRes -> {
|
|
FileInfo fileInfo = new FileInfo();
|
|
String fileName = parseFileNameFromContentDisposition(
|
|
probeRes.getHeader("Content-Disposition"));
|
|
if (fileName != null) {
|
|
fileInfo.setFileName(fileName);
|
|
}
|
|
parseSizeFromContentRange(
|
|
probeRes.getHeader("Content-Range"), fileInfo);
|
|
fileInfo.setFileId(token);
|
|
fileInfo.setPanType(shareLinkInfo.getType());
|
|
fileInfo.setFileType("file");
|
|
fileInfo.setParserUrl(buildRedirectUrl(referer, token));
|
|
|
|
// 添加下载所需的请求头到extParameters
|
|
Map<String, Object> extParams = new HashMap<>();
|
|
Map<String, String> downloadHeaders = new HashMap<>();
|
|
downloadHeaders.put("Referer", referer);
|
|
downloadHeaders.put("User-Agent", UA);
|
|
extParams.put("downloadHeaders", downloadHeaders);
|
|
fileInfo.setExtParameters(extParams);
|
|
|
|
p.complete(fileInfo);
|
|
})
|
|
.onFailure(t -> p.fail("探测文件失败: " + t.getMessage()));
|
|
|
|
return p.future();
|
|
}
|
|
|
|
@Override
|
|
public Future<String> parseById() {
|
|
Promise<String> parsePromise = Promise.promise();
|
|
|
|
try {
|
|
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
|
|
if (paramJson == null) {
|
|
parsePromise.fail("缺少 paramJson 参数");
|
|
return parsePromise.future();
|
|
}
|
|
String shareUrl = paramJson.getString("shareUrl");
|
|
String objToken = paramJson.getString("objToken");
|
|
String tenant = extractTenant(shareUrl);
|
|
|
|
if (shareUrl == null || objToken == null || tenant == null) {
|
|
parsePromise.fail("飞书目录文件下载参数不完整");
|
|
return parsePromise.future();
|
|
}
|
|
|
|
parsePromise.complete(buildDownloadUrl(tenant, objToken));
|
|
} catch (Exception e) {
|
|
parsePromise.fail("解析飞书目录文件参数失败: " + e.getMessage());
|
|
}
|
|
|
|
return parsePromise.future();
|
|
}
|
|
|
|
// ─── 工具方法 ────────────────────────────────────────
|
|
|
|
private String buildRedirectUrl(String shareUrl, String objToken) {
|
|
JsonObject paramJson = new JsonObject()
|
|
.put("shareUrl", shareUrl)
|
|
.put("objToken", objToken);
|
|
return String.format("%s/v2/redirectUrl/%s/%s",
|
|
getDomainName(),
|
|
shareLinkInfo.getType(),
|
|
CommonUtils.urlBase64Encode(paramJson.encode()));
|
|
}
|
|
|
|
private String buildDownloadUrl(String tenant, String objToken) {
|
|
return "https://" + tenant
|
|
+ ".feishu.cn/space/api/box/stream/download/all/" + objToken;
|
|
}
|
|
|
|
private String extractTenant(String url) {
|
|
if (url == null) return null;
|
|
Matcher m = TENANT_PATTERN.matcher(url);
|
|
if (m.find()) {
|
|
return m.group(1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 从Content-Disposition头解析文件名。
|
|
* 支持 filename*=UTF-8''xxx 和 filename="xxx" 两种格式。
|
|
*/
|
|
private String parseFileNameFromContentDisposition(String cd) {
|
|
if (cd == null || cd.isEmpty()) return null;
|
|
|
|
// 优先解析 filename*=UTF-8''xxx
|
|
Matcher m1 = CD_FILENAME_STAR_PATTERN.matcher(cd);
|
|
if (m1.find()) {
|
|
try {
|
|
return URLDecoder.decode(m1.group(1).trim(), StandardCharsets.UTF_8);
|
|
} catch (IllegalArgumentException ignored) {
|
|
}
|
|
}
|
|
|
|
// 降级解析 filename="xxx" 或 filename=xxx
|
|
Matcher m2 = CD_FILENAME_PATTERN.matcher(cd);
|
|
if (m2.find()) {
|
|
try {
|
|
return URLDecoder.decode(m2.group(1).trim(), StandardCharsets.UTF_8);
|
|
} catch (IllegalArgumentException ignored) {
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private void parseSizeFromContentRange(String cr, FileInfo fileInfo) {
|
|
if (cr != null) {
|
|
Matcher m = CONTENT_RANGE_SIZE_PATTERN.matcher(cr);
|
|
if (m.find()) {
|
|
fileInfo.setSize(Long.parseLong(m.group(1)));
|
|
}
|
|
}
|
|
}
|
|
|
|
private String extractCookiesFromResponse(
|
|
io.vertx.ext.web.client.HttpResponse<?> response) {
|
|
List<String> setCookies = response.cookies();
|
|
if (setCookies == null || setCookies.isEmpty()) return null;
|
|
|
|
StringBuilder sb = new StringBuilder();
|
|
for (String cookie : setCookies) {
|
|
String nameValue = cookie.split(";")[0].trim();
|
|
if (!sb.isEmpty()) sb.append("; ");
|
|
sb.append(nameValue);
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
/**
|
|
* 文件夹分页结果
|
|
*/
|
|
private record FolderPageResult(List<FileInfo> items, boolean hasMore,
|
|
String nextLabel) {
|
|
}
|
|
}
|