mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-04-18 22:56:56 +00:00
Compare commits
15 Commits
copilot/an
...
copilot/vs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbe16a07d | ||
|
|
cc9d0a4b30 | ||
|
|
696ef832f8 | ||
|
|
442f9d1d2e | ||
|
|
1a949725f3 | ||
|
|
7c14f3437b | ||
|
|
bc402da365 | ||
|
|
b95b474660 | ||
|
|
691a3770d9 | ||
|
|
49ec54a3b5 | ||
|
|
2fc15f437e | ||
|
|
190f6ca7ab | ||
|
|
c683fd27d4 | ||
|
|
d815cc1010 | ||
|
|
fd84ff1200 |
42
README.md
42
README.md
@@ -1,3 +1,15 @@
|
|||||||
|
# 一款网盘分享链接云解析快速下载服务
|
||||||
|
QQ交流群:1017480890
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
||||||
|
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
||||||
|
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
|
||||||
|
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
||||||
|
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div align="center" style="display:flex; justify-content:center; gap:10px; align-items:flex-start;">
|
<div align="center" style="display:flex; justify-content:center; gap:10px; align-items:flex-start;">
|
||||||
<img
|
<img
|
||||||
src="https://github.com/user-attachments/assets/bf266d0a-aaf8-4772-9231-e38a4b7bb6cb"
|
src="https://github.com/user-attachments/assets/bf266d0a-aaf8-4772-9231-e38a4b7bb6cb"
|
||||||
@@ -10,21 +22,14 @@
|
|||||||
style="width:300px; max-width:300px; flex:none;"
|
style="width:300px; max-width:300px; flex:none;"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p align="center">
|
|
||||||
<a href="https://trendshift.io/repositories/12101" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12101" alt="qaiu%2Fnetdisk-fast-download | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
||||||
<a href="https://github.com/qaiu/netdisk-fast-download/actions/workflows/maven.yml"><img src="https://img.shields.io/github/actions/workflow/status/qaiu/netdisk-fast-download/maven.yml?branch=v0.1.9b8a&style=flat"></a>
|
|
||||||
<a href="https://www.oracle.com/cn/java/technologies/downloads"><img src="https://img.shields.io/badge/jdk-%3E%3D17-blue"></a>
|
|
||||||
<a href="https://vertx-china.github.io"><img src="https://img.shields.io/badge/vert.x-4.5.22-blue?style=flat"></a>
|
|
||||||
<a href="https://raw.githubusercontent.com/qaiu/netdisk-fast-download/master/LICENSE"><img src="https://img.shields.io/github/license/qaiu/netdisk-fast-download?style=flat"></a>
|
|
||||||
<a href="https://github.com/qaiu/netdisk-fast-download/releases/"><img src="https://img.shields.io/github/v/release/qaiu/netdisk-fast-download?style=flat"></a>
|
|
||||||
|
|
||||||
# netdisk-fast-download 网盘分享链接云解析服务
|
[官方文档](https://nfd-parser.github.io/)
|
||||||
QQ交流群:1017480890
|
[API接入](https://nfdparser.apifox.cn/)
|
||||||
|
[公益解析,lz站](https://lz.qaiu.top)
|
||||||
netdisk-fast-download网盘直链云解析(nfd云解析)能把网盘分享下载链接转化为直链,支持多款云盘,已支持蓝奏云/蓝奏云优享/奶牛快传/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
|
[公益解析,lz0站](https://lz0.qaiu.top)
|
||||||
|
[专业版189站,注册体验](https://189.qaiu.top)
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
命令行下载分享文件:
|
命令行下载分享文件:
|
||||||
@@ -50,15 +55,10 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
|
|||||||
|
|
||||||
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
**Playground功能:** [JS解析器演练场密码保护说明](web-service/doc/PLAYGROUND_PASSWORD_PROTECTION.md)
|
||||||
|
|
||||||
## 体验地址
|
|
||||||
[公益解析1](https://lz.qaiu.top)
|
|
||||||
[公益解析2](https://lz0.qaiu.top)
|
|
||||||
[大文件解析专属版,限时开放,注册体验](https://189.qaiu.top)
|
|
||||||
|
|
||||||
main分支依赖JDK17, 提供了JDK11分支[main-jdk11](https://github.com/qaiu/netdisk-fast-download/tree/main-jdk11)
|
**注意⚠️小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
||||||
**0.1.8及以上版本json接口格式有调整 参考json返回数据格式示例**
|
**注意⚠️收到很多用户反馈,小飞机近期封号频繁,请尽可能选择其他网盘分享**
|
||||||
**小飞机解析有IP限制,多数云服务商的大陆IP会被拦截(可以自行配置代理),和本程序无关**
|
**注意⚠️请不要过度依赖 lz.qaiu.top,建议本地搭建或者云服务器自行搭建。请求量过多的话服务器可能会被云盘厂商限制,遇到解析失败的分享链接不要着急提issues,请先检查分享是否有效,** [lz站](https://lz.qaiu.top) 和 [lz0](https://lz0.qaiu.top) 不支持大文件,请使用 [189站](https://189.qaiu.top) 注册体验。
|
||||||
**注意: 请不要过度依赖lz.qaiu.top预览地址服务,建议本地搭建或者云服务器自行搭建。解析次数过多IP会被部分网盘厂商限制,不推荐做公共解析。**
|
|
||||||
|
|
||||||
## 网盘支持情况:
|
## 网盘支持情况:
|
||||||
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
> 20230905 奶牛云直链做了防盗链,需加入请求头:Referer: https://cowtransfer.com/
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ public enum PanDomainTemplate {
|
|||||||
t-is.cn
|
t-is.cn
|
||||||
*/
|
*/
|
||||||
LZ("蓝奏云",
|
LZ("蓝奏云",
|
||||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?(" +
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?(?:" +
|
||||||
"lanzoul|" +
|
"(?:lanzoul|" +
|
||||||
"lanzouh|" +
|
"lanzouh|" +
|
||||||
"lanosso|" +
|
"lanosso|" +
|
||||||
"lanpv|" +
|
"lanpv|" +
|
||||||
@@ -95,14 +95,16 @@ public enum PanDomainTemplate {
|
|||||||
"lanzv|" +
|
"lanzv|" +
|
||||||
"dmpdmp|" +
|
"dmpdmp|" +
|
||||||
"lanrar|" +
|
"lanrar|" +
|
||||||
|
"webgetstore|" +
|
||||||
"lanzb|" +
|
"lanzb|" +
|
||||||
"lanzoux|" +
|
"lanzoux|" +
|
||||||
"lanzout|" +
|
"lanzout|" +
|
||||||
"lanzouc|" +
|
"lanzouc|" +
|
||||||
"lanzoui|" +
|
"lanzoui|" +
|
||||||
"lanzoug|" +
|
"lanzoug|" +
|
||||||
"lanzoum" +
|
"lanzoum)\\.com" +
|
||||||
")\\.com/(?<KEY>.+)"),
|
"|t-is\\.cn" +
|
||||||
|
")/(?<KEY>.+)"),
|
||||||
"https://w1.lanzn.com/{shareKey}",
|
"https://w1.lanzn.com/{shareKey}",
|
||||||
LzTool.class),
|
LzTool.class),
|
||||||
|
|
||||||
@@ -115,7 +117,7 @@ public enum PanDomainTemplate {
|
|||||||
|
|
||||||
// https://lecloud.lenovo.com/share/
|
// https://lecloud.lenovo.com/share/
|
||||||
LE("联想乐云",
|
LE("联想乐云",
|
||||||
compile("https://lecloud?\\.lenovo\\.com/share/(?<KEY>.+)"),
|
compile("https://lecloud\\.lenovo\\.com/share/(?<KEY>.+)"),
|
||||||
"https://lecloud.lenovo.com/share/{shareKey}",
|
"https://lecloud.lenovo.com/share/{shareKey}",
|
||||||
LeTool.class),
|
LeTool.class),
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ public enum PanDomainTemplate {
|
|||||||
EcTool.class),
|
EcTool.class),
|
||||||
// https://cowtransfer.com/s/
|
// https://cowtransfer.com/s/
|
||||||
COW("奶牛快传",
|
COW("奶牛快传",
|
||||||
compile("https://(.*)cowtransfer\\.com/s/(?<KEY>.+)"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?cowtransfer\\.com/s/(?<KEY>.+)"),
|
||||||
"https://cowtransfer.com/s/{shareKey}",
|
"https://cowtransfer.com/s/{shareKey}",
|
||||||
CowTool.class),
|
CowTool.class),
|
||||||
CT("城通网盘",
|
CT("城通网盘",
|
||||||
@@ -264,7 +266,7 @@ public enum PanDomainTemplate {
|
|||||||
PodTool.class),
|
PodTool.class),
|
||||||
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
// 404网盘 https://drive.google.com/file/d/xxx/view?usp=sharing
|
||||||
PGD("GoogleDrive",
|
PGD("GoogleDrive",
|
||||||
compile("https://drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?"),
|
||||||
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
"https://drive.google.com/file/d/{shareKey}/view?usp=sharing",
|
||||||
PgdTool.class),
|
PgdTool.class),
|
||||||
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
// iCloud https://www.icloud.com.cn/iclouddrive/xxx#fonts
|
||||||
@@ -274,11 +276,11 @@ public enum PanDomainTemplate {
|
|||||||
PicTool.class),
|
PicTool.class),
|
||||||
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
|
// https://www.dropbox.com/scl/fi/cwnbms1yn8u6rcatzyta7/emqx-5.0.26-el7-amd64.tar.gz?rlkey=3uoi4bxz5mv93jmlaws0nlol1&e=8&st=fe0lclc2&dl=0
|
||||||
PDB("dropbox",
|
PDB("dropbox",
|
||||||
compile("https://www.dropbox.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
compile("https://www\\.dropbox\\.com/scl/fi/(?<KEY>\\w+)/.+?rlkey=(?<PWD>\\w+).*"),
|
||||||
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
"https://www.dropbox.com/scl/fi/{shareKey}/?rlkey={pwd}&dl=0",
|
||||||
PdbTool.class),
|
PdbTool.class),
|
||||||
P115("115网盘",
|
P115("115网盘",
|
||||||
compile("https://(115|anxia).com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
compile("https://(115|anxia)\\.com/s/(?<KEY>\\w+)(\\?password=(?<PWD>\\w+))?([&#].*)?"),
|
||||||
"https://115.com/s/{shareKey}?password={pwd}",
|
"https://115.com/s/{shareKey}?password={pwd}",
|
||||||
P115Tool.class),
|
P115Tool.class),
|
||||||
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
// 链接:https://www.yunpan.com/surl_yD7wz4VgU9v(提取码:fc70)
|
||||||
@@ -319,7 +321,7 @@ public enum PanDomainTemplate {
|
|||||||
MnesTool.class),
|
MnesTool.class),
|
||||||
// https://music.163.com/#/song?id=xxx
|
// https://music.163.com/#/song?id=xxx
|
||||||
MNE("网易云音乐歌曲详情",
|
MNE("网易云音乐歌曲详情",
|
||||||
compile("https://(y.)?music\\.163\\.com/(#|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
compile("https://(y\\.)?music\\.163\\.com/(?:#/|m/)?song\\?id=(?<KEY>.+)(&.*)?"),
|
||||||
"https://music.163.com/#/song?id={shareKey}",
|
"https://music.163.com/#/song?id={shareKey}",
|
||||||
MnesTool.MneTool.class),
|
MnesTool.MneTool.class),
|
||||||
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
// https://c6.y.qq.com/base/fcgi-bin/u?__=xxx
|
||||||
@@ -340,7 +342,7 @@ public enum PanDomainTemplate {
|
|||||||
MkgsTool.class),
|
MkgsTool.class),
|
||||||
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
// https://www.kugou.com/share/2bi8Fe9CSV3.html?id=2bi8Fe9CSV3#6ed9gna4"
|
||||||
MKGS2("酷狗音乐分享2",
|
MKGS2("酷狗音乐分享2",
|
||||||
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+).html.*"),
|
compile("https://(?:[a-zA-Z\\d-]+\\.)?kugou\\.com/share/(?<KEY>.+)\\.html.*"),
|
||||||
"https://www.kugou.com/share/{shareKey}.html",
|
"https://www.kugou.com/share/{shareKey}.html",
|
||||||
MkgsTool.Mkgs2Tool.class),
|
MkgsTool.Mkgs2Tool.class),
|
||||||
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
// https://www.kugou.com/mixsong/2bi8Fe9CSV3
|
||||||
|
|||||||
355
parser/src/main/resources/py/feishu-dl.py
Normal file
355
parser/src/main/resources/py/feishu-dl.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
#!/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()
|
||||||
@@ -129,15 +129,130 @@ public class PanDomainTemplateTest {
|
|||||||
wsPattern.matcher("https://www.evil.com/f/abc123").matches());
|
wsPattern.matcher("https://www.evil.com/f/abc123").matches());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLzPatternWebgetstore() {
|
||||||
|
Pattern lzPattern = PanDomainTemplate.LZ.getPattern();
|
||||||
|
|
||||||
|
// webgetstore.com 以前遗漏,现已补入
|
||||||
|
Matcher m1 = lzPattern.matcher("https://webgetstore.com/somekey");
|
||||||
|
assertTrue("LZ should match webgetstore.com", m1.find());
|
||||||
|
assertEquals("somekey", m1.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m2 = lzPattern.matcher("https://www.webgetstore.com/somekey");
|
||||||
|
assertTrue("LZ should match www.webgetstore.com", m2.find());
|
||||||
|
assertEquals("somekey", m2.group("KEY"));
|
||||||
|
|
||||||
|
// t-is.cn 以前遗漏,现已补入
|
||||||
|
Matcher m3 = lzPattern.matcher("https://t-is.cn/somekey");
|
||||||
|
assertTrue("LZ should match t-is.cn", m3.find());
|
||||||
|
assertEquals("somekey", m3.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m4 = lzPattern.matcher("https://www.t-is.cn/somekey");
|
||||||
|
assertTrue("LZ should match www.t-is.cn", m4.find());
|
||||||
|
assertEquals("somekey", m4.group("KEY"));
|
||||||
|
|
||||||
|
// 已有域名仍然正常匹配
|
||||||
|
Matcher m5 = lzPattern.matcher("https://www.lanzoul.com/somekey");
|
||||||
|
assertTrue("LZ should match existing domain lanzoul.com", m5.find());
|
||||||
|
assertEquals("somekey", m5.group("KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLePatternFix() {
|
||||||
|
Pattern lePattern = PanDomainTemplate.LE.getPattern();
|
||||||
|
|
||||||
|
// lecloud.lenovo.com 应匹配
|
||||||
|
Matcher m1 = lePattern.matcher("https://lecloud.lenovo.com/share/abc123");
|
||||||
|
assertTrue("LE should match lecloud.lenovo.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
// leclou.lenovo.com (去掉'd') 不应匹配(原 lecloud? 的 bug)
|
||||||
|
assertFalse("LE should NOT match leclou.lenovo.com",
|
||||||
|
lePattern.matcher("https://leclou.lenovo.com/share/abc123").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testCowPatternFix() {
|
||||||
|
Pattern cowPattern = PanDomainTemplate.COW.getPattern();
|
||||||
|
|
||||||
|
// 正常域名
|
||||||
|
Matcher m1 = cowPattern.matcher("https://cowtransfer.com/s/abc123");
|
||||||
|
assertTrue("COW should match cowtransfer.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m2 = cowPattern.matcher("https://share.cowtransfer.com/s/abc123");
|
||||||
|
assertTrue("COW should match share.cowtransfer.com", m2.find());
|
||||||
|
assertEquals("abc123", m2.group("KEY"));
|
||||||
|
|
||||||
|
// 潜在的URL注入:`(.*)` 是贪婪捕获组,可匹配 `evil.com/redirect/` 等前缀,
|
||||||
|
// 使形如 `https://evil.com/redirect/cowtransfer.com/s/key` 的 URL 被误识别。
|
||||||
|
// 修复后改为 `(?:[a-zA-Z\d-]+\.)?` 仅匹配一级合法子域名(可选),消除误匹配。
|
||||||
|
assertFalse("COW should NOT match redirect URLs containing cowtransfer.com in path",
|
||||||
|
cowPattern.matcher("https://evil.com/redirect/cowtransfer.com/s/abc").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testMnePatternFix() {
|
||||||
|
Pattern mnePattern = PanDomainTemplate.MNE.getPattern();
|
||||||
|
|
||||||
|
// 带 #/ 前缀的完整网页链接(修复前因 (y.) 未转义而存在 bug)
|
||||||
|
Matcher m1 = mnePattern.matcher("https://music.163.com/#/song?id=12345");
|
||||||
|
assertTrue("MNE should match #/song format", m1.find());
|
||||||
|
assertEquals("12345", m1.group("KEY"));
|
||||||
|
|
||||||
|
// 带 m/ 前缀的移动端链接
|
||||||
|
Matcher m2 = mnePattern.matcher("https://music.163.com/m/song?id=12345");
|
||||||
|
assertTrue("MNE should match m/song format", m2.find());
|
||||||
|
assertEquals("12345", m2.group("KEY"));
|
||||||
|
|
||||||
|
// y.music.163.com 子域名
|
||||||
|
Matcher m3 = mnePattern.matcher("https://y.music.163.com/song?id=12345");
|
||||||
|
assertTrue("MNE should match y.music.163.com", m3.find());
|
||||||
|
assertEquals("12345", m3.group("KEY"));
|
||||||
|
|
||||||
|
// 原 (y.) 中 `.` 未转义(`.` 匹配任意字符):对于 `yXmusic.163.com`,
|
||||||
|
// `(y.)` 会消费 `yX`(y + 任意字符),剩余 `music.163.com` 再被 `music\.163\.com` 匹配,导致误匹配。
|
||||||
|
// 修复后 `(y\.)` 要求字面 `.`,`yX` 中 X ≠ `.` 无法匹配,不再误匹配。
|
||||||
|
assertFalse("MNE should NOT match yXmusic.163.com (old (y.) could erroneously match via backtracking)",
|
||||||
|
mnePattern.matcher("https://yXmusic.163.com/song?id=12345").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testP115PatternFix() {
|
||||||
|
Pattern p115Pattern = PanDomainTemplate.P115.getPattern();
|
||||||
|
|
||||||
|
// 正常匹配
|
||||||
|
Matcher m1 = p115Pattern.matcher("https://115.com/s/abc123");
|
||||||
|
assertTrue("P115 should match 115.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
Matcher m2 = p115Pattern.matcher("https://anxia.com/s/abc123");
|
||||||
|
assertTrue("P115 should match anxia.com", m2.find());
|
||||||
|
assertEquals("abc123", m2.group("KEY"));
|
||||||
|
|
||||||
|
// 原 .com 未转义时 115Xcom 会被误匹配(现已修复)
|
||||||
|
assertFalse("P115 should NOT match 115Xcom",
|
||||||
|
p115Pattern.matcher("https://115Xcom/s/abc123").find());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPgdSubdomain() {
|
||||||
|
Pattern pgdPattern = PanDomainTemplate.PGD.getPattern();
|
||||||
|
|
||||||
|
// 标准链接
|
||||||
|
Matcher m1 = pgdPattern.matcher("https://drive.google.com/file/d/abc123/view?usp=sharing");
|
||||||
|
assertTrue("PGD should match standard drive.google.com", m1.find());
|
||||||
|
assertEquals("abc123", m1.group("KEY"));
|
||||||
|
|
||||||
|
// 带子域名的链接(修复后支持)
|
||||||
|
Matcher m2 = pgdPattern.matcher("https://adsd.drive.google.com/file/d/151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz/view");
|
||||||
|
assertTrue("PGD should match subdomain.drive.google.com", m2.find());
|
||||||
|
assertEquals("151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz", m2.group("KEY"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void verifyDuplicates() {
|
public void verifyDuplicates() {
|
||||||
|
|
||||||
Matcher matcher = compile("https://(?:[a-zA-Z\\d-]+\\.)?drive\\.google\\.com/file/d/(?<KEY>.+)/view(\\?usp=(sharing|drive_link))?")
|
|
||||||
.matcher("https://adsd.drive.google.com/file/d/151bR-nk-tOBm9QAFaozJIVt2WYyCMkoz/view");
|
|
||||||
if (matcher.find()) {
|
|
||||||
System.out.println(matcher.group());
|
|
||||||
System.out.println(matcher.group("KEY"));
|
|
||||||
}
|
|
||||||
// 校验重复
|
// 校验重复
|
||||||
Set<String> collect =
|
Set<String> collect =
|
||||||
Arrays.stream(PanDomainTemplate.values()).map(PanDomainTemplate::getRegex).collect(Collectors.toSet());
|
Arrays.stream(PanDomainTemplate.values()).map(PanDomainTemplate::getRegex).collect(Collectors.toSet());
|
||||||
|
|||||||
Reference in New Issue
Block a user