mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-21 16:16:54 +00:00
Compare commits
3 Commits
copilot/im
...
copilot/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79478c421 | ||
|
|
c401a84eb8 | ||
|
|
a9978a6202 |
@@ -87,7 +87,6 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
- ~[微雨云存储-pvvy](https://www.vyuyun.com/)~
|
||||||
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
- [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com)
|
||||||
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
- [WPS云文档-pwps](https://www.kdocs.cn/)
|
||||||
- [飞书云盘-fs](https://www.feishu.cn/)
|
|
||||||
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
- [汽水音乐-qishui_music](https://music.douyin.com/qishui/)
|
||||||
- [咪咕音乐-migu](https://music.migu.cn/)
|
- [咪咕音乐-migu](https://music.migu.cn/)
|
||||||
- [一刻相册-baidu_photo](https://photo.baidu.com/)
|
- [一刻相册-baidu_photo](https://photo.baidu.com/)
|
||||||
@@ -95,6 +94,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.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/)
|
||||||
@@ -341,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 | 不限大小 |
|
||||||
|
|
||||||
# 打包部署
|
# 打包部署
|
||||||
|
|
||||||
|
|||||||
@@ -312,10 +312,11 @@ public enum PanDomainTemplate {
|
|||||||
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
|
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\w+)([&#].*)?"),
|
||||||
"https://pan.quark.cn/s/{shareKey}",
|
"https://pan.quark.cn/s/{shareKey}",
|
||||||
QkTool.class),
|
QkTool.class),
|
||||||
// https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink
|
|
||||||
// https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink
|
// https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
|
||||||
|
// https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
|
||||||
FS("飞书云盘",
|
FS("飞书云盘",
|
||||||
compile("https://[a-zA-Z\\d]+\\.feishu\\.cn/(file|drive/folder)/(?<KEY>[a-zA-Z\\d_-]+)(\\?.*)?"),
|
compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
|
||||||
"https://feishu.cn/file/{shareKey}",
|
"https://feishu.cn/file/{shareKey}",
|
||||||
"https://www.feishu.cn/",
|
"https://www.feishu.cn/",
|
||||||
FsTool.class),
|
FsTool.class),
|
||||||
|
|||||||
@@ -1,26 +1,70 @@
|
|||||||
package cn.qaiu.parser.impl;
|
package cn.qaiu.parser.impl;
|
||||||
|
|
||||||
|
import cn.qaiu.entity.FileInfo;
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
import cn.qaiu.entity.ShareLinkInfo;
|
||||||
import cn.qaiu.parser.PanBase;
|
import cn.qaiu.parser.PanBase;
|
||||||
import io.vertx.core.Future;
|
import io.vertx.core.Future;
|
||||||
|
import io.vertx.core.Promise;
|
||||||
|
import io.vertx.core.json.JsonArray;
|
||||||
import io.vertx.core.json.JsonObject;
|
import io.vertx.core.json.JsonObject;
|
||||||
|
|
||||||
import java.net.URL;
|
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/">飞书云盘(fs)</a>
|
* <a href="https://www.feishu.cn/">飞书云盘</a>
|
||||||
* 分享格式:
|
* <p>
|
||||||
|
* 支持飞书公开分享文件和文件夹的解析。
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>文件: https://xxx.feishu.cn/file/TOKEN?from=from_copylink</li>
|
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
|
||||||
* <li>文件夹: https://xxx.feishu.cn/drive/folder/TOKEN?from=from_copylink</li>
|
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* ?from=from_copylink 是可选参数,没有分享密码
|
* 飞书下载需要先获取匿名会话Cookie,然后使用Cookie请求下载接口。
|
||||||
|
* </p>
|
||||||
*/
|
*/
|
||||||
public class FsTool extends PanBase {
|
public class FsTool extends PanBase {
|
||||||
|
|
||||||
private static final String DOWNLOAD_API_PATH = "/space/api/box/stream/download/all/";
|
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
||||||
"(KHTML, like Gecko) Chrome/120.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) {
|
public FsTool(ShareLinkInfo shareLinkInfo) {
|
||||||
super(shareLinkInfo);
|
super(shareLinkInfo);
|
||||||
@@ -29,72 +73,365 @@ public class FsTool extends PanBase {
|
|||||||
@Override
|
@Override
|
||||||
public Future<String> parse() {
|
public Future<String> parse() {
|
||||||
String shareUrl = shareLinkInfo.getShareUrl();
|
String shareUrl = shareLinkInfo.getShareUrl();
|
||||||
try {
|
String tenant = extractTenant(shareUrl);
|
||||||
// 去除查询参数以获取干净的URL
|
String token = shareLinkInfo.getShareKey();
|
||||||
String cleanUrl = shareUrl.contains("?") ? shareUrl.substring(0, shareUrl.indexOf("?")) : shareUrl;
|
|
||||||
URL url = new URL(cleanUrl);
|
|
||||||
String host = url.getHost();
|
|
||||||
String path = url.getPath();
|
|
||||||
String token = shareLinkInfo.getShareKey();
|
|
||||||
|
|
||||||
if (path.contains("/file/")) {
|
if (tenant == null || token == null) {
|
||||||
// 文件分享 - 获取下载直链
|
fail("无法从链接中提取tenant或token: {}", shareUrl);
|
||||||
getDownloadUrl(host, token);
|
return promise.future();
|
||||||
} else if (path.contains("/drive/folder/")) {
|
|
||||||
fail("飞书文件夹分享暂不支持直接下载,请使用文件分享链接");
|
|
||||||
} else {
|
|
||||||
fail("不支持的飞书链接格式: {}", path);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
fail(e, "解析飞书分享链接失败");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean isFolder = shareUrl.contains("/drive/folder/");
|
||||||
|
if (isFolder) {
|
||||||
|
fetchSessionAndParseFolder(tenant, token, shareUrl);
|
||||||
|
} else {
|
||||||
|
fetchSessionAndParseFile(tenant, token, shareUrl);
|
||||||
|
}
|
||||||
|
|
||||||
return promise.future();
|
return promise.future();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过飞书下载API获取文件直链
|
* 获取匿名session后解析文件
|
||||||
*
|
|
||||||
* @param host 飞书域名 (如 kcncuknojm60.feishu.cn)
|
|
||||||
* @param token 文件token
|
|
||||||
*/
|
*/
|
||||||
private void getDownloadUrl(String host, String token) {
|
private void fetchSessionAndParseFile(String tenant, String token, String shareUrl) {
|
||||||
String downloadApiUrl = "https://" + host + DOWNLOAD_API_PATH + token + "?mount_point=explorer";
|
clientSession.getAbs(shareUrl)
|
||||||
|
|
||||||
clientNoRedirects.getAbs(downloadApiUrl)
|
|
||||||
.putHeader("User-Agent", UA)
|
.putHeader("User-Agent", UA)
|
||||||
.putHeader("Referer", "https://" + host + "/")
|
.putHeader("Accept", "text/html,*/*")
|
||||||
.send()
|
.send()
|
||||||
.onSuccess(res -> {
|
.onSuccess(res -> {
|
||||||
int statusCode = res.statusCode();
|
String dlUrl = buildDownloadUrl(tenant, token);
|
||||||
if (statusCode == 302 || statusCode == 301) {
|
|
||||||
String location = res.getHeader("Location");
|
// Range探测获取文件名和大小
|
||||||
if (location != null && !location.isEmpty()) {
|
clientSession.getAbs(dlUrl)
|
||||||
log.info("飞书文件解析成功: token={}", token);
|
.putHeader("User-Agent", UA)
|
||||||
complete(location);
|
.putHeader("Referer", shareUrl)
|
||||||
} else {
|
.putHeader("Range", "bytes=0-0")
|
||||||
fail("飞书下载API返回{}但没有Location头", statusCode);
|
.send()
|
||||||
}
|
.onSuccess(probeRes -> {
|
||||||
} else if (statusCode == 200) {
|
String fileName = parseFileNameFromContentDisposition(
|
||||||
// 部分情况下API返回JSON格式的下载链接
|
probeRes.getHeader("Content-Disposition"));
|
||||||
try {
|
|
||||||
JsonObject json = asJson(res);
|
Map<String, String> headers = new HashMap<>();
|
||||||
if (json.containsKey("code") && json.getInteger("code") == 0) {
|
headers.put("Referer", shareUrl);
|
||||||
String downloadUrl = json.getString("url");
|
headers.put("User-Agent", UA);
|
||||||
if (downloadUrl != null && !downloadUrl.isEmpty()) {
|
|
||||||
log.info("飞书文件解析成功(JSON): token={}", token);
|
String cookies = extractCookiesFromResponse(probeRes);
|
||||||
complete(downloadUrl);
|
if (cookies != null && !cookies.isEmpty()) {
|
||||||
return;
|
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;
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
FileInfo first = items.get(0);
|
||||||
}
|
String objToken = first.getFileId();
|
||||||
// 如果返回200且不是JSON,则API地址本身可能就是下载地址
|
String dlUrl = buildDownloadUrl(tenant, objToken);
|
||||||
complete(downloadApiUrl);
|
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 {
|
} else {
|
||||||
fail("飞书下载API返回非预期状态码: {}, body: {}", statusCode,
|
probeSingleFile(tenant, token, shareUrl)
|
||||||
res.bodyAsString());
|
.onSuccess(fileInfo -> {
|
||||||
|
List<FileInfo> list = new ArrayList<>();
|
||||||
|
list.add(fileInfo);
|
||||||
|
listPromise.complete(list);
|
||||||
|
})
|
||||||
|
.onFailure(listPromise::fail);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onFailure(handleFail("请求飞书下载API"));
|
.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) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
"""
|
|
||||||
飞书云盘分享链接解析工具
|
|
||||||
支持格式:
|
|
||||||
文件: https://xxx.feishu.cn/file/TOKEN?from=from_copylink
|
|
||||||
文件夹: https://xxx.feishu.cn/drive/folder/TOKEN?from=from_copylink
|
|
||||||
?from=from_copylink 是可选参数,没有分享密码
|
|
||||||
|
|
||||||
用法: python feishu-dl.py <飞书分享链接>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import requests
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def download_feishu_file(share_url):
|
|
||||||
"""
|
|
||||||
解析飞书云盘文件分享链接,获取直链下载地址
|
|
||||||
|
|
||||||
:param share_url: 飞书分享链接
|
|
||||||
:return: 下载直链 或 None
|
|
||||||
"""
|
|
||||||
# 提取域名和文件token
|
|
||||||
match = re.match(
|
|
||||||
r'https://([a-zA-Z\d]+)\.feishu\.cn/(file|drive/folder)/([a-zA-Z\d_-]+)',
|
|
||||||
share_url
|
|
||||||
)
|
|
||||||
if not match:
|
|
||||||
print(f"无法解析链接: {share_url}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
tenant = match.group(1)
|
|
||||||
link_type = match.group(2)
|
|
||||||
token = match.group(3)
|
|
||||||
host = f"{tenant}.feishu.cn"
|
|
||||||
|
|
||||||
print(f"租户: {tenant}")
|
|
||||||
print(f"类型: {'文件' if link_type == 'file' else '文件夹'}")
|
|
||||||
print(f"Token: {token}")
|
|
||||||
|
|
||||||
if link_type == "drive/folder":
|
|
||||||
print("文件夹分享暂不支持直接下载")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 构建下载API URL
|
|
||||||
download_api_url = (
|
|
||||||
f"https://{host}/space/api/box/stream/download/all/{token}"
|
|
||||||
f"?mount_point=explorer"
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'User-Agent': (
|
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
|
|
||||||
'AppleWebKit/537.36 (KHTML, like Gecko) '
|
|
||||||
'Chrome/120.0.0.0 Safari/537.36'
|
|
||||||
),
|
|
||||||
'Referer': f'https://{host}/',
|
|
||||||
}
|
|
||||||
|
|
||||||
print(f"\n请求下载API: {download_api_url}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
download_api_url,
|
|
||||||
headers=headers,
|
|
||||||
allow_redirects=False,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
if response.status_code in (301, 302):
|
|
||||||
download_url = response.headers.get('Location')
|
|
||||||
if download_url:
|
|
||||||
print(f"解析成功!")
|
|
||||||
print(f"下载链接: {download_url}")
|
|
||||||
return download_url
|
|
||||||
else:
|
|
||||||
print("重定向但没有Location头")
|
|
||||||
elif response.status_code == 200:
|
|
||||||
# 尝试解析JSON响应
|
|
||||||
try:
|
|
||||||
data = response.json()
|
|
||||||
if data.get('code') == 0 and data.get('url'):
|
|
||||||
print(f"解析成功(JSON)!")
|
|
||||||
print(f"下载链接: {data['url']}")
|
|
||||||
return data['url']
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# 如果返回200且不是JSON, API地址本身可能就是下载地址
|
|
||||||
return download_api_url
|
|
||||||
else:
|
|
||||||
print(f"非预期状态码: {response.status_code}")
|
|
||||||
print(f"响应: {response.text[:500]}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"请求失败: {e}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("=" * 60)
|
|
||||||
print(" 飞书云盘分享链接解析工具")
|
|
||||||
print("=" * 60)
|
|
||||||
print("\n用法: python feishu-dl.py <飞书分享链接>")
|
|
||||||
print("\n示例:")
|
|
||||||
print(" python feishu-dl.py "
|
|
||||||
"https://xxx.feishu.cn/file/TOKEN")
|
|
||||||
print(" python feishu-dl.py "
|
|
||||||
"https://xxx.feishu.cn/file/TOKEN?from=from_copylink")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
url = sys.argv[1]
|
|
||||||
download_feishu_file(url)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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() {
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
package cn.qaiu.parser.impl;
|
|
||||||
|
|
||||||
import cn.qaiu.entity.ShareLinkInfo;
|
|
||||||
import cn.qaiu.parser.PanDomainTemplate;
|
|
||||||
import cn.qaiu.parser.ParserCreate;
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 飞书云盘解析测试
|
|
||||||
*/
|
|
||||||
public class FsToolTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFsPatternMatchFile() {
|
|
||||||
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
|
||||||
|
|
||||||
// 文件链接 (带 ?from=from_copylink)
|
|
||||||
Matcher m1 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink");
|
|
||||||
assertTrue("FS pattern should match file URL with query params", m1.matches());
|
|
||||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m1.group("KEY"));
|
|
||||||
|
|
||||||
// 文件链接 (不带查询参数)
|
|
||||||
Matcher m2 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc");
|
|
||||||
assertTrue("FS pattern should match file URL without query params", m2.matches());
|
|
||||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", m2.group("KEY"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFsPatternMatchFolder() {
|
|
||||||
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
|
||||||
|
|
||||||
// 文件夹链接 (带 ?from=from_copylink)
|
|
||||||
Matcher m1 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink");
|
|
||||||
assertTrue("FS pattern should match folder URL with query params", m1.matches());
|
|
||||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m1.group("KEY"));
|
|
||||||
|
|
||||||
// 文件夹链接 (不带查询参数)
|
|
||||||
Matcher m2 = fsPattern.matcher(
|
|
||||||
"https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg");
|
|
||||||
assertTrue("FS pattern should match folder URL without query params", m2.matches());
|
|
||||||
assertEquals("RQSKf8EQ4l7dMedqzHucpMbancg", m2.group("KEY"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFsPatternDifferentSubdomains() {
|
|
||||||
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
|
||||||
|
|
||||||
// 不同的租户子域名
|
|
||||||
String[] subdomains = {"abc123", "kcncuknojm60", "tenant01", "xyz"};
|
|
||||||
for (String subdomain : subdomains) {
|
|
||||||
String url = "https://" + subdomain + ".feishu.cn/file/TestToken123";
|
|
||||||
Matcher m = fsPattern.matcher(url);
|
|
||||||
assertTrue("FS pattern should match subdomain: " + subdomain, m.matches());
|
|
||||||
assertEquals("TestToken123", m.group("KEY"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFsPatternNegativeCases() {
|
|
||||||
Pattern fsPattern = PanDomainTemplate.FS.getPattern();
|
|
||||||
|
|
||||||
// 不匹配: 非feishu.cn域名
|
|
||||||
assertFalse("Should not match non-feishu domain",
|
|
||||||
fsPattern.matcher("https://evil.com/file/TOKEN").matches());
|
|
||||||
|
|
||||||
// 不匹配: 其他路径
|
|
||||||
assertFalse("Should not match other paths",
|
|
||||||
fsPattern.matcher("https://abc.feishu.cn/docs/TOKEN").matches());
|
|
||||||
|
|
||||||
// 不匹配: 没有子域名的feishu.cn
|
|
||||||
assertFalse("Should not match feishu.cn without subdomain",
|
|
||||||
fsPattern.matcher("https://feishu.cn/file/TOKEN").matches());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFromShareUrlFile() {
|
|
||||||
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("Type should be fs", "fs", info.getType());
|
|
||||||
assertEquals("Pan name should be 飞书云盘", "飞书云盘", info.getPanName());
|
|
||||||
assertEquals("Share key should be the token",
|
|
||||||
"VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFromShareUrlFolder() {
|
|
||||||
String folderUrl = "https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink";
|
|
||||||
|
|
||||||
ParserCreate parserCreate = ParserCreate.fromShareUrl(folderUrl);
|
|
||||||
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
|
||||||
|
|
||||||
assertNotNull("ShareLinkInfo should not be null", info);
|
|
||||||
assertEquals("Type should be fs", "fs", info.getType());
|
|
||||||
assertEquals("Pan name should be 飞书云盘", "飞书云盘", info.getPanName());
|
|
||||||
assertEquals("Share key should be the token",
|
|
||||||
"RQSKf8EQ4l7dMedqzHucpMbancg", info.getShareKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFromType() {
|
|
||||||
ParserCreate parserCreate = ParserCreate.fromType("fs")
|
|
||||||
.shareKey("VnCxbt35KoowKoxldO3c3C7VnMc");
|
|
||||||
|
|
||||||
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
|
||||||
|
|
||||||
assertNotNull("ShareLinkInfo should not be null", info);
|
|
||||||
assertEquals("Type should be fs", "fs", info.getType());
|
|
||||||
assertEquals("Pan name should be 飞书云盘", "飞书云盘", info.getPanName());
|
|
||||||
assertEquals("Share key should match", "VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFromShareUrlFileWithoutQueryParams() {
|
|
||||||
String fileUrl = "https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc";
|
|
||||||
|
|
||||||
ParserCreate parserCreate = ParserCreate.fromShareUrl(fileUrl);
|
|
||||||
ShareLinkInfo info = parserCreate.getShareLinkInfo();
|
|
||||||
|
|
||||||
assertNotNull("ShareLinkInfo should not be null", info);
|
|
||||||
assertEquals("fs", info.getType());
|
|
||||||
assertEquals("VnCxbt35KoowKoxldO3c3C7VnMc", info.getShareKey());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user