Compare commits

..

3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
e79478c421 refactor: address code review - extract constants, improve logging
- Extract Pattern constants as static final fields
- Extract PAGE_SIZE constant for API pagination
- Add logging for NumberFormatException in file size parsing

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>
2026-04-18 08:46:06 +00:00
copilot-swe-agent[bot]
c401a84eb8 feat: add Feishu cloud disk share parser (file + folder support)
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>
2026-04-18 08:43:26 +00:00
copilot-swe-agent[bot]
a9978a6202 Initial plan 2026-04-18 08:36:17 +00:00
5 changed files with 513 additions and 355 deletions

View File

@@ -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 | 不限大小 |
# 打包部署

View File

@@ -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)/(?<KEY>[A-Za-z0-9_-]+)(\\?.*)?"),
"https://feishu.cn/file/{shareKey}",
"https://www.feishu.cn/",
FsTool.class),
// =====================音乐类解析 分享链接标志->MxxS (单歌曲/普通音质)==========================
// http://163cn.tv/xxx
MNES("网易云音乐分享",

View File

@@ -0,0 +1,437 @@
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;
/**
* <a href="https://www.feishu.cn/">飞书云盘</a>
* <p>
* 支持飞书公开分享文件和文件夹的解析。
* <ul>
* <li>文件链接: https://xxx.feishu.cn/file/{token}</li>
* <li>文件夹链接: https://xxx.feishu.cn/drive/folder/{token}</li>
* </ul>
* 飞书下载需要先获取匿名会话Cookie然后使用Cookie请求下载接口。
* </p>
*/
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
};
/** 每页返回条目数 */
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) {
super(shareLinkInfo);
}
@Override
public Future<String> 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<String, String> 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<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 {
probeSingleFile(tenant, token, shareUrl)
.onSuccess(fileInfo -> {
List<FileInfo> list = new ArrayList<>();
list.add(fileInfo);
listPromise.complete(list);
})
.onFailure(listPromise::fail);
}
})
.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) {
}
}

View File

@@ -1,355 +0,0 @@
#!/usr/bin/env python3
"""
飞书公开分享 直链解析 + 批量下载 (aria2/Motrix)
支持: 单文件链接 / 文件夹链接(递归子目录)
用法:
python feishu_dl.py <链接> # 推送到 Motrix
python feishu_dl.py <链接> -d D:/Downloads # 指定下载目录
python feishu_dl.py <链接> --list # 仅列出文件,不下载
python feishu_dl.py <链接> --aria2c # 输出 aria2c 命令行
"""
import sys, os, re, json, uuid, ssl, gzip, argparse
import http.cookiejar
import urllib.request, urllib.error
from urllib.parse import unquote, quote
# ─── Motrix aria2 RPC 默认配置 ──────────────────────────
ARIA2_RPC_URL = "http://localhost:16800/jsonrpc"
ARIA2_SECRET = "motrix"
# ────────────────────────────────────────────────────────
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 上传文件可下载, type=0 文件夹可递归)
OBJ_TYPES = {
0: "📁 文件夹", 2: "📝 旧版文档", 3: "📊 表格", 8: "🧠 思维导图",
11: "📽 幻灯片", 12: "📄 文件", 22: "📝 新版文档", 30: "📋 画板",
44: "📊 多维表格", 84: "📑 知识库", 123: "❓ 未知", 124: "❓ 未知",
}
# v3 列表 API 支持的 obj_type
LIST_OBJ_TYPES = [0, 2, 22, 44, 3, 30, 8, 11, 12, 84, 123, 124]
# ─── 网络工具 ────────────────────────────────────────────
def _ctx():
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def make_opener(jar):
return urllib.request.build_opener(
urllib.request.HTTPSHandler(context=_ctx()),
urllib.request.HTTPCookieProcessor(jar),
)
def decode_body(resp):
data = resp.read()
if resp.headers.get("Content-Encoding") == "gzip":
data = gzip.decompress(data)
return data.decode("utf-8", errors="replace")
def cookie_string(jar):
return "; ".join(f"{c.name}={c.value}" for c in jar)
def human_size(n):
for u in ("B", "KB", "MB", "GB"):
if n < 1024: return f"{n:.1f} {u}"
n /= 1024
return f"{n:.1f} TB"
# ─── 飞书核心 API ────────────────────────────────────────
def parse_share_url(url):
"""返回 (tenant, token, link_type:'file'|'folder')"""
m = re.match(r'https://([^.]+)\.feishu\.cn/file/([A-Za-z0-9_-]+)', url)
if m: return m.group(1), m.group(2), "file"
m = re.match(r'https://([^.]+)\.feishu\.cn/drive/folder/([A-Za-z0-9_-]+)', url)
if m: return m.group(1), m.group(2), "folder"
return None, None, None
def fetch_session(share_url):
"""访问分享页拿匿名 session cookie"""
jar = http.cookiejar.CookieJar()
opener = make_opener(jar)
req = urllib.request.Request(share_url)
req.add_header("User-Agent", UA)
req.add_header("Accept", "text/html,*/*")
opener.open(req, timeout=15).read()
return jar
def list_folder(tenant, folder_token, jar, page_label=""):
"""
v3 API 列出文件夹内容 (单页)
GET /space/api/explorer/v3/children/list/?token=xxx&length=50&...
"""
base = f"https://{tenant}.feishu.cn"
params = ["length=50", "asc=1", "rank=5", f"token={folder_token}"]
for t in LIST_OBJ_TYPES:
params.append(f"obj_type={t}")
if page_label:
params.append(f"last_label={quote(page_label, safe='')}")
url = f"{base}/space/api/explorer/v3/children/list/?{'&'.join(params)}"
opener = make_opener(jar)
req = urllib.request.Request(url)
req.add_header("User-Agent", UA)
req.add_header("Accept", "application/json, text/plain, */*")
req.add_header("Referer", f"{base}/drive/folder/{folder_token}")
resp = opener.open(req, timeout=15)
data = json.loads(decode_body(resp))
if data.get("code") != 0:
raise RuntimeError(f"API error: {data.get('msg')}")
d = data["data"]
nodes = d.get("entities", {}).get("nodes", {})
node_list = d.get("node_list", [])
items = []
for nid in node_list:
node = nodes.get(nid, {})
obj_type = node.get("type", -1)
obj_token = node.get("obj_token", "")
name = node.get("name", "unknown")
extra = node.get("extra", {})
try: size = int(extra.get("size", "0"))
except: size = 0
items.append({
"name": name, "obj_token": obj_token, "type": obj_type,
"size": size, "url": node.get("url", ""),
"is_folder": obj_type == 0,
"type_name": OBJ_TYPES.get(obj_type, f"❓ type={obj_type}"),
})
# 排除文件夹自身节点
items = [it for it in items if it["obj_token"] != folder_token]
return items, d.get("has_more", False), d.get("last_label", "")
def list_folder_all(tenant, folder_token, jar):
"""分页获取文件夹全部内容"""
all_items, label = [], ""
while True:
items, has_more, label = list_folder(tenant, folder_token, jar, label)
all_items.extend(items)
if not has_more: break
return all_items
def walk_folder(tenant, folder_token, jar, prefix="", depth=0):
"""递归遍历, 返回扁平列表 [{..., path:"a/b/file.txt"}]"""
if depth > 10: # 防止无限递归
return []
items = list_folder_all(tenant, folder_token, jar)
result = []
for it in items:
if it["is_folder"]:
sub = walk_folder(tenant, it["obj_token"], jar,
prefix=f"{prefix}{it['name']}/", depth=depth+1)
result.extend(sub)
else:
it["path"] = f"{prefix}{it['name']}"
result.append(it)
return result
def probe_file(tenant, obj_token, jar, referer):
"""Range 探测文件名 + 大小 (只取1字节)"""
dl_url = f"https://{tenant}.feishu.cn/space/api/box/stream/download/all/{obj_token}"
opener = make_opener(jar)
req = urllib.request.Request(dl_url)
req.add_header("User-Agent", UA)
req.add_header("Referer", referer)
req.add_header("Range", "bytes=0-0")
resp = opener.open(req, timeout=15)
cd = resp.headers.get("Content-Disposition", "")
cr = resp.headers.get("Content-Range", "")
resp.read()
filename = ""
m = re.search(r"filename\*=UTF-8''(.+?)(?:;|$)", cd)
if m: filename = unquote(m.group(1).strip())
if not filename:
m = re.search(r'filename="?([^";]+)"?', cd)
if m: filename = unquote(m.group(1).strip())
total = 0
m = re.search(r'/(\d+)', cr)
if m: total = int(m.group(1))
return filename, total
# ─── aria2 RPC ───────────────────────────────────────────
def aria2_add(dl_url, cs, referer, filename, out_dir=None):
opts = {"header": [f"Cookie: {cs}", f"Referer: {referer}", f"User-Agent: {UA}"]}
if filename: opts["out"] = filename
if out_dir: opts["dir"] = out_dir
payload = json.dumps({
"jsonrpc": "2.0", "id": str(uuid.uuid4()),
"method": "aria2.addUri",
"params": [f"token:{ARIA2_SECRET}", [dl_url], opts],
}).encode()
req = urllib.request.Request(ARIA2_RPC_URL, data=payload,
headers={"Content-Type": "application/json"})
resp = urllib.request.urlopen(req, timeout=10)
return json.loads(resp.read().decode()).get("result", "")
def push_one(dl_url, cs, referer, filename, out_dir, quiet=False):
try:
gid = aria2_add(dl_url, cs, referer, filename, out_dir)
if gid:
if not quiet: print(f" [✓] GID={gid} {filename}")
return True
except urllib.error.URLError:
if not quiet:
print(f" [✗] Motrix 未启动, RPC: {ARIA2_RPC_URL}")
except Exception as e:
if not quiet: print(f" [✗] {e}")
return False
def print_aria2c(dl_url, cs, referer, filename, out_dir):
print(f'aria2c --header="Cookie: {cs}" \\')
print(f' --header="Referer: {referer}" \\')
print(f' --header="User-Agent: {UA}" \\')
if filename: print(f' -o "{filename}" \\')
if out_dir: print(f' -d "{out_dir}" \\')
print(f' "{dl_url}"')
# ─── 主流程 ──────────────────────────────────────────────
def handle_file(tenant, token, jar, args):
share_url = f"https://{tenant}.feishu.cn/file/{token}"
dl_url = f"https://{tenant}.feishu.cn/space/api/box/stream/download/all/{token}"
cs = cookie_string(jar)
print(f"[2/3] 探测文件 ...")
filename, size = probe_file(tenant, token, jar, share_url)
print(f" {filename} ({human_size(size)})")
if args.list: return
if args.aria2c:
print_aria2c(dl_url, cs, share_url, filename, args.dir); return
print(f"[3/3] 推送到 Motrix ...")
if not push_one(dl_url, cs, share_url, filename, args.dir):
print(f"\n 降级输出 aria2c 命令:\n")
print_aria2c(dl_url, cs, share_url, filename, args.dir)
def handle_folder(tenant, token, jar, args):
base = f"https://{tenant}.feishu.cn"
cs = cookie_string(jar)
print(f"[2/4] 递归扫描文件夹 ...")
all_files = walk_folder(tenant, token, jar)
downloadable = [f for f in all_files if f["type"] == 12]
skipped = [f for f in all_files if f["type"] != 12]
print(f"{len(all_files)} 项: "
f"{len(downloadable)} 可下载, {len(skipped)} 在线文档(跳过)")
if skipped:
print(f"\n ⏭ 跳过的在线文档:")
for f in skipped:
print(f" {f['type_name']} {f['path']}")
if not downloadable:
print("\n 没有可下载的文件"); return
# 探测真实文件名和大小
print(f"\n[3/4] 探测文件信息 ...")
total_size = 0
for f in downloadable:
referer = f.get("url", f"{base}/drive/folder/{token}")
try:
real_name, size = probe_file(tenant, f["obj_token"], jar, referer)
f["real_name"] = real_name or f["name"]
f["size"] = size or f["size"]
except:
f["real_name"] = f["name"]
total_size += f["size"]
# 打印文件列表
print(f"\n {''*60}")
print(f" {'#':>3} {'文件名':<35} {'大小':>10} 路径")
print(f" {''*60}")
for i, f in enumerate(downloadable):
sz = human_size(f["size"]) if f["size"] else " ?"
print(f" {i+1:>3} {f['real_name']:<35} {sz:>10} {f['path']}")
print(f" {''*60}")
print(f" 合计: {len(downloadable)} 文件, {human_size(total_size)}")
if args.list: return
# 下载
print(f"\n[4/4] {'aria2c 命令' if args.aria2c else '推送 Motrix'} ...")
ok = 0
for f in downloadable:
dl_url = f"{base}/space/api/box/stream/download/all/{f['obj_token']}"
sub = os.path.dirname(f["path"])
out_dir = os.path.join(args.dir, sub) if args.dir and sub else (args.dir or None)
if args.aria2c:
print_aria2c(dl_url, cs, base, f["real_name"], out_dir)
print()
ok += 1
else:
if push_one(dl_url, cs, base, f["real_name"], out_dir, quiet=True):
ok += 1
print(f" [✓] {f['real_name']}")
else:
print(f" [✗] {f['real_name']} — Motrix 未响应")
print(f" 降级 aria2c:\n")
print_aria2c(dl_url, cs, base, f["real_name"], out_dir)
return # Motrix 挂了就不继续了
print(f"\n 完成! {ok}/{len(downloadable)}")
# ─── 入口 ────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="飞书分享解析下载器 v2")
ap.add_argument("url", help="飞书分享链接 (文件/文件夹)")
ap.add_argument("-d", "--dir", default=None, help="下载目录")
ap.add_argument("--list", action="store_true", help="仅列出,不下载")
ap.add_argument("--aria2c", action="store_true", help="输出 aria2c 命令")
args = ap.parse_args()
tenant, token, lt = parse_share_url(args.url)
if not token:
print(f"[✗] 不支持的链接: {args.url}")
print(f" 格式: https://xxx.feishu.cn/file/xxxToken")
print(f" https://xxx.feishu.cn/drive/folder/xxxToken")
sys.exit(1)
print(f"\n{'📄' if lt=='file' else '📁'} 飞书分享解析 [{lt}] {tenant}/{token}")
print(f"[1/{3 if lt=='file' else 4}] 获取匿名会话 ...")
jar = fetch_session(args.url)
print(f" {sum(1 for _ in jar)} cookies")
if lt == "file":
handle_file(tenant, token, jar, args)
else:
handle_folder(tenant, token, jar, args)
print()
if __name__ == "__main__":
main()

View File

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