diff --git a/README.md b/README.md index cfdc05e..3755670 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu. - ~[微雨云存储-pvvy](https://www.vyuyun.com/)~ - [超星云盘(需要referer: https://pan-yz.chaoxing.com)-pcx](https://pan-yz.chaoxing.com) - [WPS云文档-pwps](https://www.kdocs.cn/) +- [飞书云盘-fs](https://www.feishu.cn/) - [汽水音乐-qishui_music](https://music.douyin.com/qishui/) - [咪咕音乐-migu](https://music.migu.cn/) - [一刻相册-baidu_photo](https://photo.baidu.com/) diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index d804202..ab6d282 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -312,6 +312,13 @@ public enum PanDomainTemplate { compile("https://pan\\.quark\\.cn/s/(?\\w+)([&#].*)?"), "https://pan.quark.cn/s/{shareKey}", QkTool.class), + // https://kcncuknojm60.feishu.cn/file/VnCxbt35KoowKoxldO3c3C7VnMc?from=from_copylink + // https://kcncuknojm60.feishu.cn/drive/folder/RQSKf8EQ4l7dMedqzHucpMbancg?from=from_copylink + FS("飞书云盘", + compile("https://[a-zA-Z\\d]+\\.feishu\\.cn/(file|drive/folder)/(?[a-zA-Z\\d_-]+)(\\?.*)?"), + "https://feishu.cn/file/{shareKey}", + "https://www.feishu.cn/", + FsTool.class), // =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)========================== // http://163cn.tv/xxx 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..78ccceb --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/impl/FsTool.java @@ -0,0 +1,100 @@ +package cn.qaiu.parser.impl; + +import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.parser.PanBase; +import io.vertx.core.Future; +import io.vertx.core.json.JsonObject; + +import java.net.URL; + +/** + * 飞书云盘(fs) + * 分享格式: + * + * ?from=from_copylink 是可选参数,没有分享密码 + */ +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) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + + public FsTool(ShareLinkInfo shareLinkInfo) { + super(shareLinkInfo); + } + + @Override + public Future parse() { + String shareUrl = shareLinkInfo.getShareUrl(); + try { + // 去除查询参数以获取干净的URL + 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/")) { + // 文件分享 - 获取下载直链 + getDownloadUrl(host, token); + } else if (path.contains("/drive/folder/")) { + fail("飞书文件夹分享暂不支持直接下载,请使用文件分享链接"); + } else { + fail("不支持的飞书链接格式: {}", path); + } + } catch (Exception e) { + fail(e, "解析飞书分享链接失败"); + } + return promise.future(); + } + + /** + * 通过飞书下载API获取文件直链 + * + * @param host 飞书域名 (如 kcncuknojm60.feishu.cn) + * @param token 文件token + */ + private void getDownloadUrl(String host, String token) { + String downloadApiUrl = "https://" + host + DOWNLOAD_API_PATH + token + "?mount_point=explorer"; + + clientNoRedirects.getAbs(downloadApiUrl) + .putHeader("User-Agent", UA) + .putHeader("Referer", "https://" + host + "/") + .send() + .onSuccess(res -> { + int statusCode = res.statusCode(); + if (statusCode == 302 || statusCode == 301) { + String location = res.getHeader("Location"); + if (location != null && !location.isEmpty()) { + log.info("飞书文件解析成功: token={}", token); + complete(location); + } else { + fail("飞书下载API返回{}但没有Location头", statusCode); + } + } else if (statusCode == 200) { + // 部分情况下API返回JSON格式的下载链接 + try { + JsonObject json = asJson(res); + if (json.containsKey("code") && json.getInteger("code") == 0) { + String downloadUrl = json.getString("url"); + if (downloadUrl != null && !downloadUrl.isEmpty()) { + log.info("飞书文件解析成功(JSON): token={}", token); + complete(downloadUrl); + return; + } + } + } catch (Exception ignored) { + } + // 如果返回200且不是JSON,则API地址本身可能就是下载地址 + complete(downloadApiUrl); + } else { + fail("飞书下载API返回非预期状态码: {}, body: {}", statusCode, + res.bodyAsString()); + } + }) + .onFailure(handleFail("请求飞书下载API")); + } +} diff --git a/parser/src/main/resources/py/feishu-dl.py b/parser/src/main/resources/py/feishu-dl.py new file mode 100644 index 0000000..6722c49 --- /dev/null +++ b/parser/src/main/resources/py/feishu-dl.py @@ -0,0 +1,117 @@ +""" +飞书云盘分享链接解析工具 +支持格式: + 文件: 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() diff --git a/parser/src/test/java/cn/qaiu/parser/impl/FsToolTest.java b/parser/src/test/java/cn/qaiu/parser/impl/FsToolTest.java new file mode 100644 index 0000000..565a715 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/FsToolTest.java @@ -0,0 +1,135 @@ +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()); + } +}