From c401a84eb85edd4ad73f544cd2cec78afe1b1d43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:43:26 +0000 Subject: [PATCH] feat: add Feishu cloud disk share parser (file + folder support) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FsTool parser for Feishu (飞书) cloud disk share links. Supports both file and folder share URL formats: - File: https://xxx.feishu.cn/file/{token} - Folder: https://xxx.feishu.cn/drive/folder/{token} The parser: - Fetches anonymous session cookies from share page - Uses Range probe to detect filename and size - Returns download URL with required headers (Cookie, Referer) - Supports folder listing via v3 API with pagination - Updates README with Feishu in supported cloud disk list Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/56418d09-a396-40cf-a080-c71e4a69c323 Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com> --- README.md | 2 + .../cn/qaiu/parser/PanDomainTemplate.java | 8 + .../main/java/cn/qaiu/parser/impl/FsTool.java | 420 ++++++++++++++++++ .../cn/qaiu/parser/PanDomainTemplateTest.java | 66 +++ 4 files changed, 496 insertions(+) create mode 100644 parser/src/main/java/cn/qaiu/parser/impl/FsTool.java 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; + +/** + * 飞书云盘 + *

+ * 支持飞书公开分享文件和文件夹的解析。 + *

+ * 飞书下载需要先获取匿名会话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() {