diff --git a/README.md b/README.md
index cfdc05e..99e612f 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- Onedrive-pod
- Dropbox-pdp
- iCloud-pic
+- [飞书云盘-fs](https://www.feishu.cn/)
### 专属版提供
- [夸克云盘-qk](https://pan.quark.cn/)
- [UC云盘-uc](https://fast.uc.cn/)
@@ -340,6 +341,7 @@ json返回数据格式示例:
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 |
+| 飞书云盘 | √ | X | 15G | 不限大小 |
# 打包部署
diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java
index d804202..a870901 100644
--- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java
+++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java
@@ -313,6 +313,14 @@ public enum PanDomainTemplate {
"https://pan.quark.cn/s/{shareKey}",
QkTool.class),
+ // https://xxx.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc
+ // https://xxx.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg
+ FS("飞书云盘",
+ compile("https://[^.]+\\.feishu\\.cn/(?:file|drive/folder)/(?[A-Za-z0-9_-]+)(\\?.*)?"),
+ "https://feishu.cn/file/{shareKey}",
+ "https://www.feishu.cn/",
+ FsTool.class),
+
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx
MNES("网易云音乐分享",
diff --git a/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java b/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
new file mode 100644
index 0000000..99f576e
--- /dev/null
+++ b/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java
@@ -0,0 +1,420 @@
+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;
+
+/**
+ * 飞书云盘
+ *
+ * 支持飞书公开分享文件和文件夹的解析。
+ *
+ * - 文件链接: https://xxx.feishu.cn/file/{token}
+ * - 文件夹链接: https://xxx.feishu.cn/drive/folder/{token}
+ *
+ * 飞书下载需要先获取匿名会话Cookie,然后使用Cookie请求下载接口。
+ *
+ */
+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
+ };
+
+ /**
+ * 从分享链接中提取 tenant 的正则
+ */
+ private static final Pattern TENANT_PATTERN =
+ Pattern.compile("https://([^.]+)\\.feishu\\.cn/");
+
+ public FsTool(ShareLinkInfo shareLinkInfo) {
+ super(shareLinkInfo);
+ }
+
+ @Override
+ public Future 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 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 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> parseFileList() {
+ Promise> 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 list = new ArrayList<>();
+ list.add(fileInfo);
+ listPromise.complete(list);
+ })
+ .onFailure(listPromise::fail);
+ }
+ })
+ .onFailure(t -> listPromise.fail("获取匿名会话失败: " + t.getMessage()));
+
+ return listPromise.future();
+ }
+
+ /**
+ * 分页获取文件夹所有可下载文件
+ */
+ private Future> listFolderAll(String tenant, String folderToken,
+ String pageLabel) {
+ Promise> p = Promise.promise();
+
+ listFolderPage(tenant, folderToken, pageLabel).onSuccess(pageResult -> {
+ List 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 listFolderPage(String tenant, String folderToken,
+ String pageLabel) {
+ Promise 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=50&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 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 ignored) {
+ }
+
+ 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 probeSingleFile(String tenant, String token,
+ String referer) {
+ Promise 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 = Pattern.compile("filename\\*=UTF-8''(.+?)(?:;|$)").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 = Pattern.compile("filename=\"?([^\";]+)\"?").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 = Pattern.compile("/(\\d+)").matcher(cr);
+ if (m.find()) {
+ fileInfo.setSize(Long.parseLong(m.group(1)));
+ }
+ }
+ }
+
+ private String extractCookiesFromResponse(
+ io.vertx.ext.web.client.HttpResponse> response) {
+ List 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 items, boolean hasMore,
+ String nextLabel) {
+ }
+}
diff --git a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java
index c3ecd42..1257717 100644
--- a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java
+++ b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java
@@ -250,6 +250,72 @@ public class PanDomainTemplateTest {
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
public void verifyDuplicates() {