mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-21 08:06:55 +00:00
Compare commits
14 Commits
copilot/op
...
copilot/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79478c421 | ||
|
|
c401a84eb8 | ||
|
|
a9978a6202 | ||
|
|
cc9d0a4b30 | ||
|
|
696ef832f8 | ||
|
|
442f9d1d2e | ||
|
|
1a949725f3 | ||
|
|
7c14f3437b | ||
|
|
bc402da365 | ||
|
|
b95b474660 | ||
|
|
691a3770d9 | ||
|
|
2fc15f437e | ||
|
|
190f6ca7ab | ||
|
|
c683fd27d4 |
44
README.md
44
README.md
@@ -1,3 +1,15 @@
|
|||||||
|
# 一款网盘分享链接云解析快速下载服务
|
||||||
|
QQ交流群:1017480890
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
||||||
|
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
||||||
|
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
|
||||||
|
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
||||||
|
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div align="center" style="display:flex; justify-content:center; gap:10px; align-items:flex-start;">
|
<div align="center" style="display:flex; justify-content:center; gap:10px; align-items:flex-start;">
|
||||||
<img
|
<img
|
||||||
src="https://github.com/user-attachments/assets/bf266d0a-aaf8-4772-9231-e38a4b7bb6cb"
|
src="https://github.com/user-attachments/assets/bf266d0a-aaf8-4772-9231-e38a4b7bb6cb"
|
||||||
@@ -10,21 +22,14 @@
|
|||||||
style="width:300px; max-width:300px; flex:none;"
|
style="width:300px; max-width:300px; flex:none;"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p align="center">
|
|
||||||
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||||
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
|
||||||
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
|
||||||
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
|
|
||||||
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
|
||||||
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
|
||||||
|
|
||||||
# netdisk-fast-download 网盘分享链接云解析服务
|
[官方文档](https://nfd-parser.github.io/)
|
||||||
QQ交流群:1017480890
|
[API接入](https://nfdparser.apifox.cn/)
|
||||||
|
[公益解析,lz站](https://lz.qaiu.top)
|
||||||
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||||
|
[专业版189站,注册体验](https://189.qaiu.top)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
命令行下载分享文件:
|
命令行下载分享文件:
|
||||||
@@ -50,15 +55,10 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
|
|
||||||
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
||||||
|
|
||||||
## 体验地址
|
|
||||||
[公益解析1](https://lz.qaiu.top)
|
|
||||||
[公益解析2](https://lz0.qaiu.top)
|
|
||||||
[大文件解析专属版,限时开放,注册体验](https://189.qaiu.top)
|
|
||||||
|
|
||||||
main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/netdisk-fast-download/tree/main-jdk11)
|
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||||
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
|
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
||||||
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效,** [lz站](https://lz.qaiu.top) 和 [lz0](https://lz0.qaiu.top) 不支持大文件,请使用 [189站](https://189.qaiu.top) 注册体验。
|
||||||
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
|
|
||||||
|
|
||||||
## 网盘支持情况:
|
## 网盘支持情况:
|
||||||
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
||||||
@@ -94,6 +94,7 @@ main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/
|
|||||||
- Onedrive-pod
|
- Onedrive-pod
|
||||||
- Dropbox-pdp
|
- Dropbox-pdp
|
||||||
- iCloud-pic
|
- iCloud-pic
|
||||||
|
- [飞书云盘-fs](https://www.feishu.cn/)
|
||||||
### 专属版提供
|
### 专属版提供
|
||||||
- [夸克云盘-qk](https://pan.quark.cn/)
|
- [夸克云盘-qk](https://pan.quark.cn/)
|
||||||
- [UC云盘-uc](https://fast.uc.cn/)
|
- [UC云盘-uc](https://fast.uc.cn/)
|
||||||
@@ -340,6 +341,7 @@ json返回数据格式示例:
|
|||||||
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
|
||||||
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
| 夸克网盘 | x | √ | 10G | 不限大小 |
|
||||||
| UC网盘 | x | √ | 10G | 不限大小 |
|
| UC网盘 | x | √ | 10G | 不限大小 |
|
||||||
|
| 飞书云盘 | √ | X | 15G | 不限大小 |
|
||||||
|
|
||||||
# 打包部署
|
# 打包部署
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,14 @@ public enum PanDomainTemplate {
|
|||||||
"https://pan.quark.cn/s/{shareKey}",
|
"https://pan.quark.cn/s/{shareKey}",
|
||||||
QkTool.class),
|
QkTool.class),
|
||||||
|
|
||||||
|
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
|
||||||
|
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
|
||||||
|
FS("飞书云盘",
|
||||||
|
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
|
||||||
|
"https://feishu.cn/file/{shareKey}",
|
||||||
|
"https://www.feishu.cn/",
|
||||||
|
FsTool.class),
|
||||||
|
|
||||||
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
|
||||||
// http://163cn.tv/xxx
|
// http://163cn.tv/xxx
|
||||||
MNES("网易云音乐分享",
|
MNES("网易云音乐分享",
|
||||||
|
|||||||
437
parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
Normal file
437
parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import cn.qaiu.entity.FileInfo;
|
||||||
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
|
import cn.qaiu.parser.PanBase;
|
||||||
|
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.setPanType(shareLinkInfo.getType());
|
||||||
|
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(
|
||||||
|
buildDownloadUrl(tenant, objToken));
|
||||||
|
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(dlUrl);
|
||||||
|
p.complete(fileInfo);
|
||||||
|
})
|
||||||
|
.onFailure(t -> p.fail("探测文件失败: " + t.getMessage()));
|
||||||
|
|
||||||
|
return p.future();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 工具方法 ────────────────────────────────────────
|
||||||
|
|
||||||
|
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 (Exception 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 (Exception 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -250,6 +250,72 @@ public class PanDomainTemplateTest {
|
|||||||
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
|
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFsPatternMatching() {
|
||||||
|
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
||||||
|
|
||||||
|
// 文件链接
|
||||||
|
Matcher m1 = fsPattern.matcher(
|
||||||
|
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc");
|
||||||
|
assertTrue("FS should match file link", m1.matches());
|
||||||
|
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m1.group("KEY"));
|
||||||
|
|
||||||
|
// 文件链接带 ?from=from_copylink
|
||||||
|
Matcher m2 = fsPattern.matcher(
|
||||||
|
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink");
|
||||||
|
assertTrue("FS should match file link with query param", m2.matches());
|
||||||
|
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m2.group("KEY"));
|
||||||
|
|
||||||
|
// 文件夹链接
|
||||||
|
Matcher m3 = fsPattern.matcher(
|
||||||
|
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg");
|
||||||
|
assertTrue("FS should match folder link", m3.matches());
|
||||||
|
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m3.group("KEY"));
|
||||||
|
|
||||||
|
// 文件夹链接带 ?from=from_copylink
|
||||||
|
Matcher m4 = fsPattern.matcher(
|
||||||
|
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink");
|
||||||
|
assertTrue("FS should match folder link with query param", m4.matches());
|
||||||
|
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m4.group("KEY"));
|
||||||
|
|
||||||
|
// 不同的 tenant 子域名
|
||||||
|
Matcher m5 = fsPattern.matcher(
|
||||||
|
"https://pokepangle.feishu.cn/file/VW30bpK74ontiTxvRg1cZcgvnGg");
|
||||||
|
assertTrue("FS should match different tenant", m5.matches());
|
||||||
|
assertEquals("VW30bpK74ontiTxvRg1cZcgvnGg", m5.group("KEY"));
|
||||||
|
|
||||||
|
// 负例: 非feishu域名不匹配
|
||||||
|
assertFalse("FS should NOT match non-feishu domain",
|
||||||
|
fsPattern.matcher("https://evil.com/file/abc123").matches());
|
||||||
|
|
||||||
|
// 负例: feishu.cn上的其他路径不匹配
|
||||||
|
assertFalse("FS should NOT match other feishu paths",
|
||||||
|
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFsFromShareUrl() {
|
||||||
|
// 测试文件链接解析
|
||||||
|
String fileUrl = "https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink";
|
||||||
|
ParserCreate parserCreate = ParserCreate.fromShareUrl(fileUrl);
|
||||||
|
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
||||||
|
|
||||||
|
assertNotNull("ShareLinkInfo should not be null", info);
|
||||||
|
assertEquals("fs", info.getType());
|
||||||
|
assertEquals("飞书云盘", info.getPanName());
|
||||||
|
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
|
||||||
|
|
||||||
|
// 测试文件夹链接解析
|
||||||
|
String folderUrl = "https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg";
|
||||||
|
ParserCreate parserCreate2 = ParserCreate.fromShareUrl(folderUrl);
|
||||||
|
ShareLinkInfo info2 = parserCreate2.getShareLinkInfo();
|
||||||
|
|
||||||
|
assertNotNull("ShareLinkInfo should not be null", info2);
|
||||||
|
assertEquals("fs", info2.getType());
|
||||||
|
assertEquals("飞书云盘", info2.getPanName());
|
||||||
|
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", info2.getShareKey());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void verifyDuplicates() {
|
public void verifyDuplicates() {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user