mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-18 22:56:56 +00:00
Compare commits
1 Commits
copilot/im
...
copilot/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbe16a07d |
@@ -87,7 +87,6 @@ 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/)
|
||||
|
||||
@@ -312,13 +312,6 @@ 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
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
飞书云盘分享链接解析工具
|
||||
支持格式:
|
||||
文件: https://xxx.feishu.cn/file/TOKEN?from=from_copylink
|
||||
文件夹: https://xxx.feishu.cn/drive/folder/TOKEN?from=from_copylink
|
||||
?from=from_copylink 是可选参数,没有分享密码
|
||||
飞书公开分享 直链解析 + 批量下载 (aria2/Motrix)
|
||||
支持: 单文件链接 / 文件夹链接(递归子目录)
|
||||
|
||||
用法: python feishu-dl.py <飞书分享链接>
|
||||
用法:
|
||||
python feishu_dl.py <链接> # 推送到 Motrix
|
||||
python feishu_dl.py <链接> -d D:/Downloads # 指定下载目录
|
||||
python feishu_dl.py <链接> --list # 仅列出文件,不下载
|
||||
python feishu_dl.py <链接> --aria2c # 输出 aria2c 命令行
|
||||
"""
|
||||
|
||||
import requests
|
||||
import sys
|
||||
import re
|
||||
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 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
|
||||
def _ctx():
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
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"
|
||||
def make_opener(jar):
|
||||
return urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=_ctx()),
|
||||
urllib.request.HTTPCookieProcessor(jar),
|
||||
)
|
||||
|
||||
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}/',
|
||||
}
|
||||
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")
|
||||
|
||||
print(f"\n请求下载API: {download_api_url}")
|
||||
def cookie_string(jar):
|
||||
return "; ".join(f"{c.name}={c.value}" for c in jar)
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
download_api_url,
|
||||
headers=headers,
|
||||
allow_redirects=False,
|
||||
timeout=30
|
||||
)
|
||||
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"
|
||||
|
||||
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
|
||||
|
||||
# ─── 飞书核心 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:
|
||||
print(f"非预期状态码: {response.status_code}")
|
||||
print(f"响应: {response.text[:500]}")
|
||||
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:
|
||||
print(f"请求失败: {e}")
|
||||
if not quiet: print(f" [✗] {e}")
|
||||
return False
|
||||
|
||||
return None
|
||||
|
||||
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():
|
||||
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")
|
||||
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)
|
||||
|
||||
url = sys.argv[1]
|
||||
download_feishu_file(url)
|
||||
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__":
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user