Compare commits

...

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
803e0a1cb3 feat: add Feishu cloud drive share parsing support (FS)
- Add FsTool.java parser implementation for Feishu cloud drive
- Add FS entry to PanDomainTemplate with regex for file/folder URLs
- Add feishu-dl.py Python reference implementation
- Add FsToolTest.java with 8 test cases for regex and parser flow
- Update README.md to include Feishu in supported disk list

Agent-Logs-Url: https://github.com/qaiu/netdisk-fast-download/sessions/10b5c877-4690-4e35-aab8-bfd26a0810d1

Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-04-18 08:52:24 +00:00
copilot-swe-agent[bot]
51b2d3119b Initial plan 2026-04-18 08:40:30 +00:00
5 changed files with 360 additions and 0 deletions

View File

@@ -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/)

View File

@@ -312,6 +312,13 @@ public enum PanDomainTemplate {
compile("https://pan\\.quark\\.cn/s/(?<KEY>\\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)/(?<KEY>[a-zA-Z\\d_-]+)(\\?.*)?"),
"https://feishu.cn/file/{shareKey}",
"https://www.feishu.cn/",
FsTool.class),
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx

View File

@@ -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;
/**
* <a href="https://www.feishu.cn/">飞书云盘(fs)</a>
* 分享格式:
* <ul>
* <li>文件: https://xxx.feishu.cn/file/TOKEN?from=from_copylink</li>
* <li>文件夹: https://xxx.feishu.cn/drive/folder/TOKEN?from=from_copylink</li>
* </ul>
* ?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<String> 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"));
}
}

View File

@@ -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()

View File

@@ -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());
}
}