Compare commits

..

22 Commits

Author SHA1 Message Date
qaiu c18b8d80e1 更新 README.md 2026-08-07 09:36:35 +08:00
qaiu 06ba0aff52 Merge pull request #211 from qaiu/copilot/update-readme-to-latest-version
docs: update download URL to latest release v0.4.2
2026-08-05 21:42:39 +08:00
copilot-swe-agent[bot] 74e401d99c docs: fix download URL and zip filename for v0.4.2 linux-amd64
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-08-05 13:28:37 +00:00
copilot-swe-agent[bot] 4995bca901 docs: update download URL to latest release v0.4.2
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2026-08-05 13:18:11 +00:00
qaiu 7299fd8762 Merge pull request #207 from qaiu/cursor/ghsa-997r-ssrf-verify-2b8b
fix(security): GHSA-997r SSRF verification + residual hardening
2026-07-26 21:10:39 +08:00
Cursor Agent efbadde4ed fix(security): harden GHSA-997r Cloudreve SSRF residual paths
Disable redirect following on CE/Ce4 attacker-controlled requests and stop
echoing upstream response bodies in client-facing JSON errors. Add
assertPublicHost regression coverage for the advisory PoC hosts.

Co-authored-by: qaiu <qaiu@vip.qq.com>
2026-07-26 12:05:16 +00:00
q d1fa787bef fix(uc/qk): improve directory download with cookie, transfer fallback and URL-safe param encoding
- Preserve download cookie for UC/Quark needDownloader flows and disable browser/copy when required
- Quark: share-link first, transfer only on size limit (23018), reuse savedFileCache/search_exit
- Propagate auth to subdirectory parser URLs; switch path params to URL-safe Base64 (no double encode)
- Bump version to 0.4.2

Fixes #205

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:16:51 +08:00
q c282fcb109 Merge branch 'main' into feature/ysepan-parser 2026-07-26 13:20:16 +08:00
q 4995173de2 feat: add Yongshuo E-pan (ysepan) share parser
Support space shares like https://xxx.ysepan.com/ with password auth,
directory/file listing, and direct download URL construction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 12:37:38 +08:00
qaiu 1c1068beba Merge pull request #206 from qaiu/dependabot/maven/maven-858658338e
chore(deps): bump the maven group across 1 directory with 2 updates
2026-07-26 09:04:56 +08:00
qaiu 4a5240fcbc Merge pull request #203 from qaiu/dependabot/npm_and_yarn/web-front/npm_and_yarn-c42958dede
build(deps): bump axios from 1.16.1 to 1.18.0 in /web-front in the npm_and_yarn group across 1 directory
2026-07-26 08:47:25 +08:00
dependabot[bot] 0f34672e33 chore(deps): bump the maven group across 1 directory with 2 updates
Bumps the maven group with 2 updates in the / directory: [ch.qos.logback:logback-core](https://github.com/qos-ch/logback) and [com.fasterxml.jackson.core:jackson-databind](https://github.com/FasterXML/jackson).


Updates `ch.qos.logback:logback-core` from 1.5.33 to 1.5.34
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.5.33...v_1.5.34)

Updates `com.fasterxml.jackson.core:jackson-databind` from 2.18.6 to 2.18.9
- [Commits](https://github.com/FasterXML/jackson/commits)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-core
  dependency-version: 1.5.34
  dependency-type: direct:production
  dependency-group: maven
- dependency-name: com.fasterxml.jackson.core:jackson-databind
  dependency-version: 2.18.9
  dependency-type: direct:production
  dependency-group: maven
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 00:46:44 +00:00
qaiu ecd365767a Merge pull request #199 from qaiu/dependabot/maven/maven-6421fd6730
chore(deps): bump ch.qos.logback:logback-core from 1.5.32 to 1.5.33 in the maven group across 1 directory
2026-07-26 08:46:01 +08:00
qaiu f358140974 Update README.md 2026-07-25 11:14:19 +08:00
qaiu 541c21f963 Merge pull request #204 from newbie000652/fix/uc-subdirectory-stoken
fix: UC subdirectory listing fails due to missing stoken parameter
2026-07-25 07:58:14 +08:00
Tra bdd253383d docs: add stoken parameter to /v2/getFileList OpenAPI spec 2026-07-24 20:43:04 +08:00
Rune 0fb53d5159 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-24 19:54:22 +08:00
Tra b9ff408bdd fix: UC subdirectory listing fails due to missing stoken parameter
When the frontend requests subdirectory contents for UC drive shares, the
stoken query parameter is mishandled in two places, breaking directory browsing:

1. URLParamUtil.handleTruncatedUrl() does not exclude stoken from the URL
   construction loop. As a result, the stoken value gets appended to the share
   URL (e.g. https://drive.uc.cn/s/xxx?stoken=yyy). The extra query string
   breaks the UC URL regex match in PanDomainTemplate, causing the parser to
   fall back to the default IPanTool.parseFileList() which returns
   "Not implemented yet".

2. ParserApi.getFileList() does not accept stoken as a method parameter and
   does not forward it to ShareLinkInfo.otherParam. Even when the stoken is
   present in the request URL, UcTool.parseFileList() cannot find it and must
   re-authenticate against the UC API — which fails without proper auth cookies.

The fix:
- URLParamUtil: add stoken to the param exclusion list
- ParserApi: add String stoken parameter and put it into otherParam

Both first-level and nested directory listing work correctly after this fix.
2026-07-24 18:04:52 +08:00
qaiu aeb4394cf8 fix GHSA-997r-7xx2-p9x6 2026-07-22 10:25:10 +08:00
dependabot[bot] 8d419d3265 build(deps): bump axios
Bumps the npm_and_yarn group with 1 update in the /web-front directory: [axios](https://github.com/axios/axios).


Updates `axios` from 1.16.1 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.16.1...v1.18.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 07:29:38 +00:00
q b70e30796f ci: package fuller jre for native builds 2026-07-19 09:57:31 +08:00
dependabot[bot] 363c603bbb chore(deps): bump ch.qos.logback:logback-core
Bumps the maven group with 1 update in the / directory: [ch.qos.logback:logback-core](https://github.com/qos-ch/logback).


Updates `ch.qos.logback:logback-core` from 1.5.32 to 1.5.33
- [Release notes](https://github.com/qos-ch/logback/releases)
- [Commits](https://github.com/qos-ch/logback/compare/v_1.5.32...v_1.5.33)

---
updated-dependencies:
- dependency-name: ch.qos.logback:logback-core
  dependency-version: 1.5.33
  dependency-type: direct:production
  dependency-group: maven
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 02:09:01 +00:00
26 changed files with 2794 additions and 316 deletions
+11 -26
View File
@@ -158,37 +158,19 @@ jobs:
path: web-service/target/package
# ============================================================
# jdeps 分析 → 确定所需 JDK 模块
# 选择 JDK 模块
# ============================================================
- name: 分析所需 JDK 模块jdeps
- name: 选择完整 JDK 模块集合
run: |
MAIN_JAR="web-service/target/package/netdisk-fast-download.jar"
LIB_DIR="web-service/target/package/lib"
CP=""
for jar in "$LIB_DIR"/*.jar; do
CP="$CP${CP:+:}$jar"
done
RAW_MODULES=$(jdeps --print-module-deps --ignore-missing-deps --multi-release 17 \
--class-path "$CP" "$MAIN_JAR" 2>/dev/null | head -n 1 | tr -d '\r\n' || true)
if [ -z "$RAW_MODULES" ] || [[ "$RAW_MODULES" == *"Missing"* ]] || [[ "$RAW_MODULES" == *"Error"* ]]; then
# ⚠️ 回退列表:若项目新增了需要 java.* / jdk.* 模块的依赖,需同步更新此处
RAW_MODULES="java.base,java.logging,java.sql,java.naming,java.management,java.xml,java.scripting,jdk.unsupported,java.net.http,java.instrument,java.security.jgss,java.security.sasl,java.desktop,jdk.crypto.ec"
echo "jdeps 分析失败,使用回退模块列表"
else
# 补上 jdeps 无法检测的反射/SPI依赖
RAW_MODULES="$RAW_MODULES,java.desktop,java.scripting,jdk.crypto.ec"
fi
echo "detected modules: $RAW_MODULES"
printf 'JDK_MODULES=%s\n' "$RAW_MODULES" >> $GITHUB_ENV
# 123 网盘/自定义 JS 解析器会通过 javax.script、Nashorn 和 SPI/反射加载模块,
# jdeps 无法稳定识别这些运行时依赖。这里优先保证原生包可运行,使用完整
# JDK 模块集合生成运行时,避免 Windows 精简 JRE 缺 java.scripting/jdk.dynalink 等模块。
printf 'JDK_MODULES=ALL-MODULE-PATH\n' >> $GITHUB_ENV
# ============================================================
# jlink 生成精简 JRE
# jlink 生成完整模块 JRE
# ============================================================
- name: 生成精简 JREjlink
- name: 生成完整模块 JREjlink
run: |
jlink \
--module-path "$JAVA_HOME/jmods" \
@@ -215,7 +197,10 @@ jobs:
ls -la "$JRE_BIN"/*.dll 2>/dev/null || echo "(无 .dll 文件)"
fi
echo "=== 关键 JDK 模块校验 ==="
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^java.scripting@'
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.dynalink@'
"native-package/netdisk-fast-download/jre/bin/java" --list-modules | grep -q '^jdk.unsupported@'
# ============================================================
# 组装包目录
+31 -9
View File
@@ -20,7 +20,7 @@ QQ交流群:1017480890
## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
[官方文档](https://nfd-parser.github.io/)
[API接入](https://nfdparser.apifox.cn/)
@@ -76,6 +76,7 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
- [QQ邮箱云盘-qqw](https://mail.qq.com/)
- [QQ闪传-qqsc](https://nutty.qq.com/nutty/ssr/26797.html)
- [城通网盘-ct](https://www.ctfile.com)
- [永硕E盘-ys](https://www.ysepan.com/)(空间分享,如 `https://xxx.ysepan.com/`,密码为空间访问密码;多文件请用文件列表接口)
- [网易云音乐分享链接-mnes](https://music.163.com)
- [酷狗音乐分享链接-mkgs](https://www.kugou.com)
- [酷我音乐分享链接-mkws](https://kuwo.cn)
@@ -309,9 +310,12 @@ json返回数据格式示例:
"timestamp": 1736489219402
}
```
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机网盘)
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
/v2/getFileList?url=分享链接&pwd=分享密码
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
```json
{
"code": 200,
@@ -367,6 +371,7 @@ json返回数据格式示例:
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录) |
| 文叔叔 | √ | √ | 10G | 5GB |
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 |
@@ -450,8 +455,8 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell
cd ~
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v3.0.2/netdisk-fast-download-bin.zip
unzip netdisk-fast-download-bin.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.2/netdisk-fast-download-linux-amd64.zip
unzip netdisk-fast-download.zip
cd netdisk-fast-download
bash service-install.sh
```
@@ -531,13 +536,30 @@ Core模块集成Vert.x实现类似spring的注解式路由API
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=qaiu/netdisk-fast-download&type=Date)](https://star-history.com/#qaiu/netdisk-fast-download&Date)
<a href="https://www.star-history.com/?repos=qaiu%2Fnetdisk-fast-download&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&theme=dark&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=qaiu/netdisk-fast-download&type=date&legend=bottom-right&sealed_token=dfQO_dJcTqcPkEnM7SfxRyHoFbV5Ah4LxoEhdlheMn4T2YLEV_WETxFZexeAbWN5OmNyYuycWan2d42PAFbw0CuU4oCTKgehfErFJ9eVl2CyVpP_4xrdQw" />
</picture>
</a>
## **免责声明**
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
## 支持该项目
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请作者喝杯咖啡, 感谢支持
## **声明**
- 本项目**仅供个人学习与技术交流使用**,请勿用于商业用途或大规模滥用。
- 所用接口均来自各网盘**官方开放平台**、官方公开接口及**已知开源项目**,仅解析用户主动提供的分享链接,**不涉及破解或绕过版权保护**。
- 本项目**相当于自动化程序代替用户发起请求**,**不会提升或绕过任何会员权限**,也**不会突破网盘的限速策略**;我们**鼓励用户开通官方 VIP/SVIP**以获得更好的下载与加速体验。
- 使用者应遵守各网盘服务商的**用户协议与使用条款**,以及所在地区的**法律法规**,违反所产生的后果自行承担。
- **高频调用可能触发网盘风控**,导致**账号限流、封禁**或**来源 IP 被屏蔽**,请合理控制请求频率,避免高并发与批量请求。
- 网盘接口调整、风控策略变更等因素可能导致功能失效,本项目**不对可用性与稳定性作任何担保**。
- 开发者**不对**使用本项目产生的**任何直接或间接损失**(账号封禁、数据丢失、法律纠纷等)**承担责任**。
- 开发者保留**随时修改本免责声明**的权利,恕不另行通知。
- **下载、部署或使用本项目,即视为您已阅读并接受上述全部条款。**
## 赞助该项目
开源不易,用爱发电,本项目长期维护如果觉得有帮助, 可以请开发者[喝杯咖啡](https://blog.qaiu.top/archives/da-shang-zhuan-yong), 感谢支持。
本项目的服务器由林枫云提供赞助<br>
</a>
+1 -1
View File
@@ -65,7 +65,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
<version>42.7.13</version>
</dependency>
</dependencies>
@@ -20,6 +20,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
@@ -201,6 +204,76 @@ public abstract class PanBase implements IPanTool, Closeable {
}
}
/**
* SSRF 防护: 校验通用自定义域名解析器 (CE/Ce4/Kd/Other 等) 即将请求的目标主机,
* 拒绝解析到回环/内网/链路本地/组播等非公网地址的域名, 阻止攻击者通过可控 DNS
* 记录 (或直接填写内网域名) 让服务端向内网/云元数据接口发起请求。
* <p>
* 必须在子类 parse() 中构造出 baseUrl/发起任何 clientSession 请求之前调用。
*
* @param url 从 shareLinkInfo.getShareUrl() 解析出的 URL
* @throws IOException 当主机无法解析或解析结果落入禁止的地址段时抛出
*/
protected static void assertPublicHost(URL url) throws IOException {
String host = url.getHost();
InetAddress[] addresses;
try {
addresses = InetAddress.getAllByName(host);
} catch (UnknownHostException e) {
throw new IOException("无法解析目标主机: " + host, e);
}
if (addresses.length == 0) {
throw new IOException("无法解析目标主机: " + host);
}
for (InetAddress addr : addresses) {
if (isDisallowedAddress(addr)) {
throw new IOException("目标地址不允许访问(内网/回环/链路本地/组播): "
+ host + " -> " + addr.getHostAddress());
}
}
}
/**
* 判断地址是否落入禁止访问的范围: 0.0.0.0/8, 10/8, 127/8, 169.254/16(含云元数据
* 169.254.169.254), 172.16/12, 192.168/16, 100.64/10(CGNAT), 224/4~255/4(组播/保留),
* 以及对应的 IPv6 回环/链路本地/唯一本地地址(fc00::/7)/组播地址; IPv4-映射的 IPv6
* 地址(::ffff:a.b.c.d) 会先还原为 IPv4 再判断,避免绕过。
*/
private static boolean isDisallowedAddress(InetAddress addr) {
byte[] bytes = addr.getAddress();
if (bytes.length == 16 && isIPv4Mapped(bytes)) {
byte[] v4 = new byte[4];
System.arraycopy(bytes, 12, v4, 0, 4);
bytes = v4;
}
if (bytes.length == 4) {
int b0 = bytes[0] & 0xFF;
int b1 = bytes[1] & 0xFF;
if (b0 == 0) return true; // 0.0.0.0/8
if (b0 == 10) return true; // 10.0.0.0/8
if (b0 == 127) return true; // 127.0.0.0/8 loopback
if (b0 == 169 && b1 == 254) return true; // 169.254.0.0/16 (含云元数据 169.254.169.254)
if (b0 == 172 && b1 >= 16 && b1 <= 31) return true; // 172.16.0.0/12
if (b0 == 192 && b1 == 168) return true; // 192.168.0.0/16
if (b0 == 100 && b1 >= 64 && b1 <= 127) return true;// 100.64.0.0/10 CGNAT
return b0 >= 224; // 224.0.0.0/4 组播 + 240.0.0.0/4 保留
}
// IPv6
if (addr.isAnyLocalAddress() || addr.isLoopbackAddress()
|| addr.isLinkLocalAddress() || addr.isSiteLocalAddress()
|| addr.isMulticastAddress()) {
return true;
}
return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7 unique local
}
private static boolean isIPv4Mapped(byte[] b) {
for (int i = 0; i < 10; i++) {
if (b[i] != 0) return false;
}
return (b[10] & 0xFF) == 0xFF && (b[11] & 0xFF) == 0xFF;
}
protected String baseMsg() {
if (shareLinkInfo.getShareUrl() != null) {
return shareLinkInfo.getPanName() + "-" + shareLinkInfo.getType() + ": url=" + shareLinkInfo.getShareUrl();
@@ -309,9 +382,10 @@ public abstract class PanBase implements IPanTool, Closeable {
log.error("响应gzip解压或JSON解析失败: {}", e.getMessage());
fail("响应gzip解压或JSON解析失败: {}", e.getMessage());
} else {
// 上游响应体可能来自内网探测目标,仅写日志,避免经 HTTP 500 回传给调用方
String bodyPreview = responseBodyPreview(res);
log.error("解析失败: json格式异常: {}", bodyPreview);
fail("解析失败: json格式异常: {}", bodyPreview);
fail("解析失败: json格式异常");
}
return JsonObject.of();
}
@@ -341,7 +415,18 @@ public abstract class PanBase implements IPanTool, Closeable {
protected void completeWithMeta(String url, Map<String, String> headers) {
shareLinkInfo.getOtherParam().put("downloadUrl", url);
if (headers != null && !headers.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", headers);
// 过滤 null/空值,避免 cookie:null 覆盖入口参数或污染 curl 命令
Map<String, String> clean = new HashMap<>();
headers.forEach((k, v) -> {
if (k != null && v != null && !v.isBlank()) {
clean.put(k, v);
}
});
if (!clean.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", clean);
// UC/夸克等需带 cookie 的直链,标记前端走下载器
shareLinkInfo.getOtherParam().put("needDownloader", true);
}
}
promise.complete(url);
}
@@ -449,6 +534,34 @@ public abstract class PanBase implements IPanTool, Closeable {
return shareLinkInfo.getOtherParam().getOrDefault("domainName", "").toString();
}
/**
* 将入口请求中的加密 auth 透传到子目录/下载链接,避免进入子目录后丢失认证。
* otherParam 中的 key 为 {@code _authQuery}(由 web 层写入)。
*/
protected String appendAuthQuery(String url) {
if (StringUtils.isBlank(url) || shareLinkInfo == null || shareLinkInfo.getOtherParam() == null) {
return url;
}
Object authObj = shareLinkInfo.getOtherParam().get("_authQuery");
if (authObj == null) {
return url;
}
String auth = authObj.toString();
if (StringUtils.isBlank(auth)) {
return url;
}
// 已带 auth 则不再追加
if (url.contains("auth=")) {
return url;
}
try {
String encoded = java.net.URLEncoder.encode(auth, StandardCharsets.UTF_8);
return url + (url.contains("?") ? "&" : "?") + "auth=" + encoded;
} catch (Exception e) {
return url + (url.contains("?") ? "&" : "?") + "auth=" + auth;
}
}
@Override
public ShareLinkInfo getShareLinkInfo() {
return shareLinkInfo;
@@ -377,6 +377,16 @@ public enum PanDomainTemplate {
MmgTool.class),
// =====================私有盘解析==========================
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
// 主域名 ysepan.com / ys168.com;备用 cccpan.com / ysupan.com / uupan.net / ysok.net
YS("永硕E盘",
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\."
+ "(?:ysepan\\.com|ys168\\.com|cccpan\\.com|ysupan\\.com|uupan\\.net|ysok\\.net)"
+ "/?(?:\\?.*)?"),
"https://{shareKey}.ysepan.com/",
"https://www.ysepan.com/",
YsTool.class),
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
// http(s)://pan.huang1111.cn/s/xxx
@@ -1,53 +1,149 @@
package cn.qaiu.parser;
import org.apache.commons.lang3.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Parser token cache keyed by parser type and account identity.
* 解析器 Token/Cookie 缓存 — 支持多账号隔离。
* <p>
* 以 (diskType + "#" + accountKey) 作为缓存 key,不同账号的 token 互不覆盖。
* accountKey 优先使用 _configId,其次使用 username、cookie 前16位等可区分标识。
* </p>
*/
public final class TokenCache {
private static final Map<String, String> TOKENS = new ConcurrentHashMap<>();
private static final Map<String, Long> EXPIRES = new ConcurrentHashMap<>();
private TokenCache() {}
private TokenCache() {
/** token 缓存 */
private static final ConcurrentHashMap<String, String> tokenMap = new ConcurrentHashMap<>();
/** 过期时间缓存(毫秒时间戳) */
private static final ConcurrentHashMap<String, Long> expireMap = new ConcurrentHashMap<>();
/** 同一 key 下的额外字符串缓存(如 userId) */
private static final ConcurrentHashMap<String, String> extraMap = new ConcurrentHashMap<>();
/** 布尔标记缓存(如 authFlag */
private static final ConcurrentHashMap<String, Boolean> flagMap = new ConcurrentHashMap<>();
// ============ key 构造 ============
public static String key(String diskType, String accountKey) {
return diskType + "#" + (accountKey == null ? "_default" : accountKey);
}
public static String key(String type, String accountId) {
return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId);
// ============ token ============
public static String getToken(String cacheKey) {
return tokenMap.get(cacheKey);
}
public static void putToken(String key, String token) {
if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) {
return;
public static void putToken(String cacheKey, String token) {
if (token == null) {
tokenMap.remove(cacheKey);
} else {
tokenMap.put(cacheKey, token);
}
TOKENS.put(key, token);
}
public static String getToken(String key) {
if (StringUtils.isBlank(key)) {
return null;
}
if (isExpired(key)) {
TOKENS.remove(key);
EXPIRES.remove(key);
return null;
}
return TOKENS.get(key);
// ============ expire ============
public static long getExpire(String cacheKey) {
return expireMap.getOrDefault(cacheKey, 0L);
}
public static void putExpire(String key, long expireTimeMillis) {
if (StringUtils.isBlank(key)) {
return;
}
EXPIRES.put(key, expireTimeMillis);
public static void putExpire(String cacheKey, long expireMs) {
expireMap.put(cacheKey, expireMs);
}
public static boolean isExpired(String key) {
Long expireTimeMillis = EXPIRES.get(key);
return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis;
public static boolean isExpired(String cacheKey) {
long exp = getExpire(cacheKey);
return exp <= 0 || System.currentTimeMillis() > exp;
}
// ============ extra (userId 等) ============
public static String getExtra(String cacheKey) {
return extraMap.get(cacheKey);
}
public static void putExtra(String cacheKey, String value) {
if (value == null) {
extraMap.remove(cacheKey);
} else {
extraMap.put(cacheKey, value);
}
}
// ============ flag (authFlag 等) ============
public static boolean getFlag(String cacheKey, boolean defaultValue) {
return flagMap.getOrDefault(cacheKey, defaultValue);
}
public static void putFlag(String cacheKey, boolean value) {
flagMap.put(cacheKey, value);
}
// ============ 清除 ============
public static void remove(String cacheKey) {
tokenMap.remove(cacheKey);
expireMap.remove(cacheKey);
extraMap.remove(cacheKey);
flagMap.remove(cacheKey);
}
/**
* 清除指定网盘类型的所有缓存(精准清除,不影响其他网盘类型)
*/
public static void removeByDiskType(String diskType) {
String prefix = diskType + "#";
tokenMap.keySet().removeIf(k -> k.startsWith(prefix));
expireMap.keySet().removeIf(k -> k.startsWith(prefix));
extraMap.keySet().removeIf(k -> k.startsWith(prefix));
flagMap.keySet().removeIf(k -> k.startsWith(prefix));
}
public static void clear() {
tokenMap.clear();
expireMap.clear();
extraMap.clear();
flagMap.clear();
}
// ============ Token 持久化队列 ============
/** 待持久化的 cachedToken 数据 (cacheKey -> [token, expireMs]) */
private static final ConcurrentHashMap<String, String[]> persistQueue = new ConcurrentHashMap<>();
/** 待回写的凭据更新 (cacheKey -> newCredential),如 PaliTool refresh_token 轮换 */
private static final ConcurrentHashMap<String, String> credentialUpdateQueue = new ConcurrentHashMap<>();
/**
* 解析器登录成功后,将 token 加入持久化队列(下次 recordConfigUsage 回写 DB
*/
public static void queueCachedTokenPersist(String cacheKey, String token, long expireMs) {
if (cacheKey != null && token != null) {
persistQueue.put(cacheKey, new String[]{token, String.valueOf(expireMs)});
}
}
/**
* 凭据本身被替换(如 PaliTool refresh_token 轮换),加入回写队列
*/
public static void queueCredentialUpdate(String cacheKey, String newCredential) {
if (cacheKey != null && newCredential != null) {
credentialUpdateQueue.put(cacheKey, newCredential);
}
}
/**
* 消费持久化队列:返回 [token, expireMs] 并移除;无数据返回 null
*/
public static String[] pollCachedTokenPersist(String cacheKey) {
return cacheKey == null ? null : persistQueue.remove(cacheKey);
}
/**
* 消费凭据更新队列:返回新凭据并移除;无数据返回 null
*/
public static String pollCredentialUpdate(String cacheKey) {
return cacheKey == null ? null : credentialUpdateQueue.remove(cacheKey);
}
}
@@ -37,6 +37,7 @@ public class Ce4Tool extends PanBase {
try {
URL url = new URL(shareLinkInfo.getShareUrl());
assertPublicHost(url);
String baseUrl = url.getProtocol() + "://" + url.getHost();
// 如果有端口,拼接上端口
if (url.getPort() != -1) {
@@ -110,7 +111,8 @@ public class Ce4Tool extends PanBase {
private void requestShareDetail(String baseUrl, String key, String pwd, String path) {
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
// 禁止跟随重定向:防止公网 host 302 到内网/元数据绕过 assertPublicHost
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd);
}
@@ -231,7 +233,7 @@ public class Ce4Tool extends PanBase {
.put("uris", new JsonArray().add(filePath))
.put("download", true);
clientSession.postAbs(fileUrlApi)
clientNoRedirects.postAbs(fileUrlApi)
.putHeader("Content-Type", "application/json")
.sendJsonObject(requestBody)
.onSuccess(res -> {
@@ -43,6 +43,7 @@ public class CeTool extends PanBase {
String pwd = shareLinkInfo.getSharePassword();
try {
URL url = new URL(shareLinkInfo.getShareUrl());
assertPublicHost(url);
String baseUrl = url.getProtocol() + "://" + url.getHost();
// 如果有端口,拼接上端口
if (url.getPort() != -1) {
@@ -77,7 +78,8 @@ public class CeTool extends PanBase {
private void tryV4Ping(String baseUrl, String key, String pwd) {
String pingUrlV4 = baseUrl + PING_API_V4_PATH;
clientSession.getAbs(pingUrlV4).send().onSuccess(res -> {
// 禁止跟随重定向:assertPublicHost 只校验初始 host,自动 30x 会绕过 SSRF 防护
clientNoRedirects.getAbs(pingUrlV4).send().onSuccess(res -> {
if (res.statusCode() == 200) {
try {
JsonObject json = asJson(res);
@@ -107,7 +109,7 @@ public class CeTool extends PanBase {
private void tryV3Ping(String baseUrl, String key, String pwd) {
String pingUrlV3 = baseUrl + PING_API_V3_PATH;
clientSession.getAbs(pingUrlV3).send().onSuccess(res -> {
clientNoRedirects.getAbs(pingUrlV3).send().onSuccess(res -> {
if (res.statusCode() == 200) {
try {
JsonObject json = asJson(res);
@@ -138,7 +140,7 @@ public class CeTool extends PanBase {
*/
private void verifyV3AndParse(String baseUrl, String key, String pwd) {
String shareApiUrl = baseUrl + SHARE_API_PATH + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd);
}
@@ -174,7 +176,7 @@ public class CeTool extends PanBase {
*/
private void tryV4ShareApi(String baseUrl, String key, String pwd) {
String shareApiUrl = baseUrl + "/api/v4/share/info/" + key;
HttpRequest<Buffer> httpRequest = clientSession.getAbs(shareApiUrl);
HttpRequest<Buffer> httpRequest = clientNoRedirects.getAbs(shareApiUrl);
if (pwd != null && !pwd.isEmpty()) {
httpRequest.addQueryParam("password", pwd);
}
@@ -290,7 +292,8 @@ public class CeTool extends PanBase {
}
private void getDownURL(String shareApiUrl) {
clientSession.putAbs(shareApiUrl)
// PUT 默认不跟随重定向,但仍统一使用 no-redirect 客户端避免配置漂移
clientNoRedirects.putAbs(shareApiUrl)
.putHeader("Referer", shareLinkInfo.getShareUrl())
.send().onSuccess(res -> {
JsonObject jsonObject = asJson(res);
File diff suppressed because it is too large Load Diff
@@ -265,13 +265,8 @@ public class UcTool extends PanBase {
return;
}
String downloadUrl = dataList.getJsonObject(0).getString("download_url");
// UC网盘需要配合aria2下载,保存下载请求头
Map<String, String> downloadHeaders = new HashMap<>();
// 将header转换为Map 只需要包含cookie,user-agent,referer
downloadHeaders.put(HttpHeaders.COOKIE.toString(), header.get(HttpHeaders.COOKIE));
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
completeWithMeta(downloadUrl, downloadHeaders);
// UC 需配合下载器(带 cookie,保存下载请求头
completeWithMeta(downloadUrl, buildDownloadHeaders(null));
} catch (Exception e) {
fail("解析 UC 下载链接失败: " + e.getMessage());
}
@@ -466,29 +461,40 @@ public class UcTool extends PanBase {
if (shareFidToken != null) {
extParams.put("share_fid_token", shareFidToken);
}
extParams.put("needDownloader", true);
Map<String, String> dlHeaders = new HashMap<>();
String listCookie = header.get(HttpHeaders.COOKIE);
if (listCookie != null && !listCookie.isEmpty()) {
dlHeaders.put(HttpHeaders.COOKIE.toString(), listCookie);
}
dlHeaders.put(HttpHeaders.USER_AGENT.toString(), header.get(HttpHeaders.USER_AGENT));
dlHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
extParams.put("downloadHeaders", dlHeaders);
fileInfo.setExtParameters(extParams);
// 设置解析URL(用于下载)
JsonObject paramJson = new JsonObject(extParams);
paramJson.put("fileName", fileName);
String param = CommonUtils.urlBase64Encode(paramJson.encode());
fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param));
// 透传 auth,避免下载/转存时变成 guest
fileInfo.setParserUrl(appendAuthQuery(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), param)));
} else {
// 文件夹
fileInfo.setFileType("folder");
fileInfo.setSize(0L);
fileInfo.setSizeStr("0B");
// 设置目录解析URL(用于递归解析子目录)
// 对 URL 参数进行编码,确保特殊字符正确传递
// 递归子目录须透传 auth,否则会丢失认证
try {
String encodedUrl = URLEncoder.encode(shareLinkInfo.getShareUrl(), StandardCharsets.UTF_8.toString());
String encodedDirId = URLEncoder.encode(fid, StandardCharsets.UTF_8.toString());
String encodedStoken = URLEncoder.encode(stoken, StandardCharsets.UTF_8.toString());
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken));
fileInfo.setParserUrl(appendAuthQuery(String.format(
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), encodedUrl, encodedDirId, encodedStoken)));
} catch (Exception e) {
// 如果编码失败,使用原始值
fileInfo.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken));
fileInfo.setParserUrl(appendAuthQuery(String.format(
"%s/v2/getFileList?url=%s&dirId=%s&stoken=%s",
getDomainName(), shareLinkInfo.getShareUrl(), fid, stoken)));
}
}
@@ -510,6 +516,9 @@ public class UcTool extends PanBase {
promise.fail("缺少必要的参数");
return promise.future();
}
// 会话无 cookie 时,回退使用入口参数中已带的 cookie
ensureCookieFromParam(paramJson);
String fid = paramJson.getString("fid");
String pwdId = paramJson.getString("pwd_id");
@@ -554,6 +563,11 @@ public class UcTool extends PanBase {
promise.fail("未找到下载链接");
return;
}
// 存储下载请求头,供目录解析 getFileDownInfo 接口使用
// 优先用当前会话 cookie;缺失时回退入口参数中已带的 cookie
Map<String, String> downloadHeaders = buildDownloadHeaders(paramJson);
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
shareLinkInfo.getOtherParam().put("fileName", paramJson.getString("fileName", ""));
promise.complete(downloadUrl);
} catch (Exception e) {
promise.fail("解析 UC 下载链接失败: " + e.getMessage());
@@ -564,6 +578,53 @@ public class UcTool extends PanBase {
return promise.future();
}
/**
* 会话无 cookie 时,从入口参数 downloadHeaders 回填到请求头。
*/
private void ensureCookieFromParam(JsonObject paramJson) {
String cookie = header.get(HttpHeaders.COOKIE);
if (cookie != null && !cookie.isEmpty()) {
return;
}
String paramCookie = extractCookieFromParam(paramJson);
if (paramCookie != null && !paramCookie.isEmpty()) {
header.set(HttpHeaders.COOKIE, CookieUtils.filterUcQuarkCookie(paramCookie));
}
}
private static String extractCookieFromParam(JsonObject paramJson) {
if (paramJson == null) {
return null;
}
JsonObject paramHeaders = paramJson.getJsonObject("downloadHeaders");
if (paramHeaders == null) {
return null;
}
String cookie = paramHeaders.getString("cookie");
return cookie != null ? cookie : paramHeaders.getString("Cookie");
}
/**
* 构建下载请求头:会话 cookie 优先,缺失时回退入口参数中的 downloadHeaders。
*/
private Map<String, String> buildDownloadHeaders(JsonObject paramJson) {
Map<String, String> downloadHeaders = new HashMap<>();
String cookie = header.get(HttpHeaders.COOKIE);
if (cookie == null || cookie.isEmpty()) {
cookie = extractCookieFromParam(paramJson);
}
if (cookie != null && !cookie.isEmpty()) {
downloadHeaders.put(HttpHeaders.COOKIE.toString(), cookie);
}
String ua = header.get(HttpHeaders.USER_AGENT);
if (ua == null || ua.isEmpty()) {
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
}
downloadHeaders.put(HttpHeaders.USER_AGENT.toString(), ua);
downloadHeaders.put(HttpHeaders.REFERER.toString(), "https://fast.uc.cn/");
return downloadHeaders;
}
// public static void main(String[] args) {
// // https://drive.uc.cn/s/12450d1694844?public=1
// new UcTool(ShareLinkInfo.newBuilder().shareKey("12450d1694844").build()).parse().onSuccess(
@@ -0,0 +1,694 @@
package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.FileSizeConverter;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.HttpRequest;
import io.vertx.ext.web.client.HttpResponse;
import org.apache.commons.lang3.StringUtils;
import java.net.URLEncoder;
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;
/**
* 永硕E盘(主 ysepan.com / ys168.com,备 cccpan.com / ysupan.com / uupan.net / ysok.net
* <p>
* 空间分享形如 https://{space}.ysepan.com/ ,需空间访问密码时通过 sharePassword 传入。
*/
public class YsTool extends PanBase {
private static final String BROWSER_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
private static final Pattern ANTIFORGERY_PATTERN =
Pattern.compile("RequestVerificationToken'\\s*:\\s*'([^']+)'");
private static final Pattern HTXX_PATTERN =
Pattern.compile("window\\.htxx\\s*=\\s*(\\{.*?});", Pattern.DOTALL);
private static final Pattern JWT_COOKIE_PATTERN =
Pattern.compile("jwttk_[^=]+=([^;\\s]+)");
private static final String PARAM_DIR_ID = "dirId";
/** 永硕目录内的子目录名(API 字段 zml),用于二级层级 */
private static final String PARAM_ZML = "zml";
public YsTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
}
@Override
public Future<String> parse() {
ensureSession().onSuccess(session -> {
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
int dirId = Integer.parseInt(dirIdObj.toString());
fetchFiles(session, dirId).onSuccess(filesResp -> {
List<JsonObject> files = downloadableFiles(filesResp);
if (files.isEmpty()) {
fail("目录内没有可下载文件");
return;
}
completeDownload(session, filesResp, files.get(0));
}).onFailure(err -> fail(err, err.getMessage()));
return;
}
fetchDirectories(session).compose(dirs -> collectDownloadableFiles(session, dirs))
.onSuccess(collected -> {
if (collected.isEmpty()) {
fail("空间内没有可下载文件,请检查密码或目录权限");
return;
}
if (collected.size() > 1) {
fail("空间包含多个文件(共{}个),请使用文件列表接口后再按文件解析", collected.size());
return;
}
CollectedFile only = collected.get(0);
completeDownload(session, only.filesResp, only.file);
}).onFailure(err -> fail(err, err.getMessage()));
}).onFailure(err -> fail(err, err.getMessage()));
return promise.future();
}
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> listPromise = Promise.promise();
ensureSession().onSuccess(session -> {
Object dirIdObj = shareLinkInfo.getOtherParam().get(PARAM_DIR_ID);
if (dirIdObj != null && StringUtils.isNotBlank(dirIdObj.toString())) {
int dirId = Integer.parseInt(dirIdObj.toString());
String zmlFilter = currentZmlFilter();
fetchFiles(session, dirId).onSuccess(filesResp -> {
try {
listPromise.complete(mapFiles(session, dirId, filesResp, zmlFilter));
} catch (Exception e) {
listPromise.fail(baseMsg() + " - 解析文件列表失败: " + e.getMessage());
}
}).onFailure(listPromise::fail);
return;
}
fetchDirectories(session).onSuccess(dirs -> {
try {
listPromise.complete(mapDirectories(session, dirs));
} catch (Exception e) {
listPromise.fail(baseMsg() + " - 解析目录列表失败: " + e.getMessage());
}
}).onFailure(listPromise::fail);
}).onFailure(listPromise::fail);
return listPromise.future();
}
@Override
public Future<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
if (paramJson == null) {
fail("缺少 paramJson 参数");
return promise.future();
}
String downloadUrl = paramJson.getString("downloadUrl");
if (StringUtils.isNotBlank(downloadUrl)) {
completeWithMeta(downloadUrl, downloadHeaders(paramJson.getString("referer")));
return promise.future();
}
String space = paramJson.getString("space", spaceName());
String xzpz = paramJson.getString("xzpz");
String pz = paramJson.getString("pz");
String fwq = paramJson.getString("fwq");
String fileName = paramJson.getString("fileName");
if (StringUtils.isAnyBlank(space, xzpz, pz, fwq, fileName)) {
fail("下载参数不完整: {}", paramJson);
return promise.future();
}
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName);
completeWithMeta(url, downloadHeaders(paramJson.getString("referer", spaceOrigin())));
return promise.future();
}
private Future<Session> ensureSession() {
Promise<Session> p = Promise.promise();
String space = spaceName();
if (StringUtils.isBlank(space)) {
p.fail(baseMsg() + " - 空间名为空");
return p.future();
}
String origin = spaceOrigin();
clientSession.getAbs(origin + "/")
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.send()
.compose(res -> handleSpaceHome(origin, space, res))
.onSuccess(p::complete)
.onFailure(p::fail);
return p.future();
}
private Future<Session> handleSpaceHome(String origin, String space, HttpResponse<Buffer> res) {
String html = res.bodyAsString() != null ? res.bodyAsString() : "";
String jwt = extractJwt(res);
JsonObject htxx = extractHtxx(html);
// 已进入空间
if (htxx != null && StringUtils.isNotBlank(htxx.getString("qqdz"))) {
if (StringUtils.isBlank(jwt)) {
return Future.failedFuture(baseMsg() + " - 无法获取会话令牌");
}
return Future.succeededFuture(toSession(origin, space, jwt, htxx));
}
// 需要空间密码
if (html.contains("VerifyPassword") || html.contains("loginPopup")) {
String pwd = shareLinkInfo.getSharePassword();
if (StringUtils.isBlank(pwd)) {
return Future.failedFuture(baseMsg() + " - 空间需要访问密码");
}
String antiforgery = extractAntiforgery(html);
if (StringUtils.isBlank(antiforgery)) {
return Future.failedFuture(baseMsg() + " - 无法获取防伪令牌");
}
return verifyPassword(origin, space, pwd, antiforgery)
.compose(verifiedJwt -> reloadSpace(origin, space, verifiedJwt));
}
return Future.failedFuture(baseMsg() + " - 无法解析空间信息");
}
private Future<String> verifyPassword(String origin, String space, String pwd, String antiforgery) {
Promise<String> p = Promise.promise();
JsonObject body = new JsonObject()
.put("password", pwd)
.put("dlmc", space)
.put("remember", false);
clientSession.postAbs(origin + "/?handler=VerifyPassword")
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Content-Type", "application/json")
.putHeader("Origin", origin)
.putHeader("Referer", origin + "/")
.putHeader("RequestVerificationToken", antiforgery)
.sendJsonObject(body)
.onSuccess(res -> {
try {
JsonObject json = res.bodyAsJsonObject();
if (json == null || !Boolean.TRUE.equals(json.getBoolean("success"))) {
String msg = json != null ? json.getString("message", "密码错误") : "密码验证失败";
p.fail(baseMsg() + " - " + msg);
return;
}
String jwt = extractJwt(res);
if (StringUtils.isBlank(jwt)) {
p.fail(baseMsg() + " - 密码验证成功但未返回会话令牌");
return;
}
p.complete(jwt);
} catch (Exception e) {
p.fail(baseMsg() + " - 密码验证响应异常: " + e.getMessage());
}
})
.onFailure(t -> p.fail(baseMsg() + " - 密码验证请求失败: " + t.getMessage()));
return p.future();
}
private Future<Session> reloadSpace(String origin, String space, String verifiedJwt) {
Promise<Session> p = Promise.promise();
clientSession.getAbs(origin + "/")
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.send()
.onSuccess(res -> {
String html = res.bodyAsString() != null ? res.bodyAsString() : "";
JsonObject htxx = extractHtxx(html);
String jwt = StringUtils.defaultIfBlank(extractJwt(res), verifiedJwt);
if (htxx == null || StringUtils.isBlank(htxx.getString("qqdz"))) {
p.fail(baseMsg() + " - 密码验证后仍无法进入空间");
return;
}
if (StringUtils.isBlank(jwt)) {
p.fail(baseMsg() + " - 密码验证后无法获取会话令牌");
return;
}
p.complete(toSession(origin, space, jwt, htxx));
})
.onFailure(t -> p.fail(baseMsg() + " - 重新加载空间失败: " + t.getMessage()));
return p.future();
}
private Session toSession(String origin, String space, String jwt, JsonObject htxx) {
String apiBase = htxx.getString("qqdz");
if (apiBase != null && !apiBase.endsWith("/")) {
apiBase = apiBase + "/";
}
String dlmc = htxx.getString("dlmc", space);
return new Session(origin, dlmc, jwt, apiBase);
}
private Future<JsonArray> fetchDirectories(Session session) {
Promise<JsonArray> p = Promise.promise();
apiPost(session, "ml/mldq", new JsonObject())
.onSuccess(json -> {
JsonArray lb = json.getJsonArray("lb");
p.complete(lb != null ? lb : new JsonArray());
})
.onFailure(p::fail);
return p.future();
}
private Future<JsonObject> fetchFiles(Session session, int dirId) {
Promise<JsonObject> p = Promise.promise();
JsonObject body = new JsonObject()
.put("mlbh", dirId)
.put("kqmm", "")
.put("wjbh", 0)
.put("ip1", "");
apiPost(session, "wj/wjdq", body)
.onSuccess(p::complete)
.onFailure(p::fail);
return p.future();
}
private Future<JsonObject> apiPost(Session session, String path, JsonObject body) {
Promise<JsonObject> p = Promise.promise();
String url = session.apiBase + path;
HttpRequest<Buffer> req = clientSession.postAbs(url)
.putHeader("User-Agent", BROWSER_UA)
.putHeader("Accept", "application/json, text/plain, */*")
.putHeader("Content-Type", "application/json")
.putHeader("Origin", session.origin)
.putHeader("Referer", session.origin + "/")
.putHeader("Authorization", "Bearer " + session.jwt);
req.sendJsonObject(body)
.onSuccess(res -> {
try {
if (res.statusCode() >= 400) {
p.fail(baseMsg() + " - API " + path + " HTTP " + res.statusCode());
return;
}
JsonObject json = asJson(res);
if (json == null || json.isEmpty()) {
p.fail(baseMsg() + " - API " + path + " 返回空响应");
return;
}
p.complete(json);
} catch (Exception e) {
p.fail(baseMsg() + " - API " + path + " 响应异常: " + e.getMessage());
}
})
.onFailure(t -> p.fail(baseMsg() + " - API " + path + " 请求失败: " + t.getMessage()));
return p.future();
}
private Future<List<CollectedFile>> collectDownloadableFiles(Session session, JsonArray dirs) {
Promise<List<CollectedFile>> p = Promise.promise();
List<CollectedFile> collected = new ArrayList<>();
List<Integer> dirIds = new ArrayList<>();
for (int i = 0; i < dirs.size(); i++) {
JsonObject dir = dirs.getJsonObject(i);
if (isAccessibleDirectory(dir)) {
dirIds.add(dir.getInteger("bh"));
}
}
if (dirIds.isEmpty()) {
p.complete(collected);
return p.future();
}
fetchNextDirFiles(session, dirIds, 0, collected, p);
return p.future();
}
private void fetchNextDirFiles(Session session, List<Integer> dirIds, int index,
List<CollectedFile> collected, Promise<List<CollectedFile>> promise) {
if (index >= dirIds.size()) {
promise.complete(collected);
return;
}
int dirId = dirIds.get(index);
fetchFiles(session, dirId).onSuccess(filesResp -> {
for (JsonObject file : downloadableFiles(filesResp)) {
collected.add(new CollectedFile(filesResp, file));
}
fetchNextDirFiles(session, dirIds, index + 1, collected, promise);
}).onFailure(promise::fail);
}
private List<FileInfo> mapDirectories(Session session, JsonArray dirs) {
List<FileInfo> result = new ArrayList<>();
for (int i = 0; i < dirs.size(); i++) {
JsonObject dir = dirs.getJsonObject(i);
if (!isAccessibleDirectory(dir)) {
continue;
}
Integer bh = dir.getInteger("bh");
if (bh == null) {
continue;
}
String title = StringUtils.defaultIfBlank(dir.getString("bt"), "目录" + bh);
FileInfo info = new FileInfo()
.setFileName(title)
.setFileId(bh.toString())
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setDescription(dir.getString("sm", ""))
.setCreateTime(normalizeTime(dir.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&pwd=%s",
getDomainName(),
urlEncode(shareLinkInfo.getShareUrl()),
bh,
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword()))));
result.add(info);
}
return result;
}
/**
* 将目录内文件按 zml(子目录)分层。
* <ul>
* <li>zmlFilter 为空:返回子目录(folder+ 根级文件</li>
* <li>zmlFilter 非空:仅返回该子目录下的文件/链接</li>
* </ul>
*/
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp, String zmlFilter) {
List<FileInfo> result = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
boolean listingSubdir = StringUtils.isNotBlank(zmlFilter);
// 未进入子目录时,先按出现顺序收集 zml 作为二级文件夹
if (!listingSubdir) {
java.util.LinkedHashSet<String> subdirs = new java.util.LinkedHashSet<>();
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String zml = StringUtils.defaultString(item.getString("zml")).trim();
if (StringUtils.isNotBlank(zml)) {
subdirs.add(zml);
}
}
for (String zml : subdirs) {
result.add(new FileInfo()
.setFileName(zml)
.setFileId(dirId + ":" + zml)
.setFileType("folder")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(zml)
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/getFileList?url=%s&dirId=%s&zml=%s&pwd=%s",
getDomainName(),
urlEncode(shareLinkInfo.getShareUrl()),
dirId,
urlEncode(zml),
urlEncode(StringUtils.defaultString(shareLinkInfo.getSharePassword())))));
}
}
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String itemZml = StringUtils.defaultString(item.getString("zml")).trim();
if (listingSubdir) {
if (!zmlFilter.equals(itemZml)) {
continue;
}
} else if (StringUtils.isNotBlank(itemZml)) {
// 根级列表只展示无 zml 的条目,有 zml 的归入子目录
continue;
}
Integer bh = item.getInteger("bh");
if (bh == null) {
continue;
}
String wjlx = item.getString("wjlx", "");
// URL / 公告条目
if ("url".equalsIgnoreCase(wjlx)) {
String link = StringUtils.defaultString(item.getString("wjm")).trim();
String title = StringUtils.defaultString(item.getString("bt")).trim();
// 空占位(标题和链接都空)跳过,与官网展示一致
if (StringUtils.isAllBlank(title, link)) {
continue;
}
if (StringUtils.isBlank(title)) {
title = StringUtils.defaultIfBlank(link, "链接");
}
FileInfo urlInfo = new FileInfo()
.setFileName(title)
.setFileId(bh.toString())
.setFileType("url")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setPreviewUrl(link)
.setDescription(link);
// parserUrl 置空,避免前端误走下载;打开走 previewUrl
result.add(urlInfo);
continue;
}
String fileName = item.getString("wjm");
String fwq = item.getString("fwq");
String pz = item.getString("pz");
if (StringUtils.isAnyBlank(fileName, fwq, pz, xzpz)) {
continue;
}
long size = item.getLong("dx", 0L);
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName);
JsonObject param = new JsonObject()
.put("space", session.space)
.put("xzpz", xzpz)
.put("pz", pz)
.put("fwq", fwq)
.put("fileName", fileName)
.put("mlbh", dirId)
.put("fileId", bh)
.put("downloadUrl", downloadUrl)
.put("referer", session.origin + "/");
String paramEncoded = CommonUtils.urlBase64Encode(param.encode());
FileInfo fileInfo = new FileInfo()
.setFileName(fileName)
.setFileId(bh.toString())
.setFileType("file")
.setSize(size)
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
.setFilePath(itemZml)
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), paramEncoded))
.setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s",
getDomainName(), shareLinkInfo.getType(), paramEncoded));
result.add(fileInfo);
}
return result;
}
private String currentZmlFilter() {
Object zml = shareLinkInfo.getOtherParam().get(PARAM_ZML);
if (zml == null) {
return "";
}
try {
return java.net.URLDecoder.decode(zml.toString(), StandardCharsets.UTF_8).trim();
} catch (Exception e) {
return zml.toString().trim();
}
}
private List<JsonObject> downloadableFiles(JsonObject filesResp) {
List<JsonObject> files = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
if (StringUtils.isBlank(xzpz)) {
return files;
}
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
if ("url".equalsIgnoreCase(item.getString("wjlx", ""))) {
continue;
}
if (StringUtils.isAnyBlank(item.getString("wjm"), item.getString("fwq"), item.getString("pz"))) {
continue;
}
files.add(item);
}
return files;
}
private void completeDownload(Session session, JsonObject filesResp, JsonObject file) {
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz");
String url = buildDownloadUrl(session.space, xzpz, file.getString("pz"),
file.getString("fwq"), file.getString("wjm"));
FileInfo fileInfo = new FileInfo()
.setFileName(file.getString("wjm"))
.setFileId(String.valueOf(file.getInteger("bh")))
.setSize(file.getLong("dx", 0L))
.setSizeStr(FileSizeConverter.convertToReadableSize(file.getLong("dx", 0L)))
.setFileType("file")
.setFilePath(file.getString("zml", ""))
.setCreateTime(normalizeTime(file.getString("sj")))
.setPanType(shareLinkInfo.getType());
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
completeWithMeta(url, downloadHeaders(session.origin + "/"));
}
/**
* 拼装直链。注意:不要在 xzpz 前加 "_",官方页面直链无此前缀,加了会 404。
*/
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq, String fileName) {
String host = "X".equalsIgnoreCase(fwq)
? "y.ys168.com:8000"
: "ys-" + fwq.toLowerCase() + ".ysepan.com";
return "https://" + host + "/wap/"
+ encodePathSegment(space) + "/"
+ encodePathSegment(xzpz) + "/"
+ encodePathSegment(pz) + "/"
+ encodePathSegment(fileName);
}
private static String encodePathSegment(String value) {
// 保留永硕 token 中常见的 . , _ - 等字符,仅编码必须转义的部分
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20")
.replace("%2E", ".")
.replace("%2C", ",")
.replace("%5F", "_")
.replace("%2D", "-");
}
private Map<String, String> downloadHeaders(String referer) {
Map<String, String> headers = new HashMap<>();
headers.put("User-Agent", BROWSER_UA);
if (StringUtils.isNotBlank(referer)) {
headers.put("Referer", referer);
}
return headers;
}
private boolean isAccessibleDirectory(JsonObject dir) {
if (dir == null) {
return false;
}
// 无可下载权限或需特殊打开方式的目录忽略
if (!Boolean.TRUE.equals(dir.getBoolean("qxz", true))) {
return false;
}
Integer kqfs = dir.getInteger("kqfs", 0);
return kqfs == null || kqfs == 0;
}
private String spaceName() {
String key = shareLinkInfo.getShareKey();
if (StringUtils.isBlank(key)) {
return null;
}
try {
return java.net.URLDecoder.decode(key, StandardCharsets.UTF_8);
} catch (Exception e) {
return key;
}
}
private String spaceOrigin() {
String shareUrl = shareLinkInfo.getShareUrl();
if (StringUtils.isNotBlank(shareUrl)) {
try {
java.net.URI uri = java.net.URI.create(shareUrl);
String scheme = uri.getScheme() != null ? uri.getScheme() : "https";
return scheme + "://" + uri.getHost();
} catch (Exception ignored) {
}
}
return "https://" + spaceName() + ".ysepan.com";
}
private static String extractAntiforgery(String html) {
Matcher m = ANTIFORGERY_PATTERN.matcher(html);
return m.find() ? m.group(1) : null;
}
private static JsonObject extractHtxx(String html) {
Matcher m = HTXX_PATTERN.matcher(html);
if (!m.find()) {
return null;
}
try {
return new JsonObject(m.group(1));
} catch (Exception e) {
return null;
}
}
private static String extractJwt(HttpResponse<Buffer> res) {
List<String> setCookies = res.cookies();
if (setCookies != null) {
for (String c : setCookies) {
Matcher m = JWT_COOKIE_PATTERN.matcher(c);
if (m.find()) {
return m.group(1);
}
}
}
String raw = res.getHeader("Set-Cookie");
if (raw != null) {
Matcher m = JWT_COOKIE_PATTERN.matcher(raw);
if (m.find()) {
return m.group(1);
}
}
return null;
}
private static String normalizeTime(String sj) {
if (StringUtils.isBlank(sj)) {
return sj;
}
// 2024-10-26T10:12:54.98 -> 2024-10-26 10:12:54
String t = sj.replace('T', ' ');
int dot = t.indexOf('.');
if (dot > 0) {
t = t.substring(0, dot);
}
return t;
}
private static String urlEncode(String value) {
return URLEncoder.encode(StringUtils.defaultString(value), StandardCharsets.UTF_8);
}
private record Session(String origin, String space, String jwt, String apiBase) {
}
private record CollectedFile(JsonObject filesResp, JsonObject file) {
}
}
@@ -77,32 +77,57 @@ public class CommonUtils {
}
/**
* urlEncode -> deBase64 -> string
* @param encoded 编码后的字符串
* @return 解码后的字符串
* 解码路径参数中的 Base64。
* <p>优先按 URL-Safe Base64 解;兼容历史「标准 Base64 + URLEncode」以及重复 encode。</p>
*/
public static String urlBase64Decode(String encoded) {
try {
String urlDecoded = java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
byte[] base64DecodedBytes = java.util.Base64.getDecoder().decode(urlDecoded);
return new String(base64DecodedBytes, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("URL Base64 解码失败", e);
if (encoded == null || encoded.isEmpty()) {
throw new RuntimeException("URL Base64 解码失败: empty");
}
String s = encoded.trim().replace(' ', '+');
// 兼容历史 URLEncode / 误二次 encode:有 % 则解到不再变化
for (int i = 0; i < 3 && s.contains("%"); i++) {
try {
String next = java.net.URLDecoder.decode(s, StandardCharsets.UTF_8);
if (next.equals(s)) {
break;
}
s = next;
} catch (Exception e) {
break;
}
}
Exception last = null;
for (String candidate : new String[]{s, padBase64(s)}) {
try {
return new String(java.util.Base64.getUrlDecoder().decode(candidate), StandardCharsets.UTF_8);
} catch (Exception e) {
last = e;
}
try {
return new String(java.util.Base64.getDecoder().decode(candidate), StandardCharsets.UTF_8);
} catch (Exception e) {
last = e;
}
}
throw new RuntimeException("URL Base64 解码失败", last);
}
/**
* string -> base64Encode -> urlEncode
* @param str 原始字符串
* @return 编码后的字符串
* 编码为可直接放进 URL path 的 Base64URL-Safe,无 padding)。
* <p>不再做 URLEncoder,避免前端/代理再 encode 时变成 %253D。</p>
*/
public static String urlBase64Encode(String str) {
try {
byte[] base64EncodedBytes = java.util.Base64.getEncoder().encode(str.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String base64Encoded = new String(base64EncodedBytes, java.nio.charset.StandardCharsets.UTF_8);
return java.net.URLEncoder.encode(base64Encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("URL Base64 编码失败", e);
return java.util.Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(str.getBytes(StandardCharsets.UTF_8));
}
private static String padBase64(String s) {
int mod = s.length() % 4;
if (mod == 0) {
return s;
}
return s + "====".substring(mod);
}
}
@@ -0,0 +1,44 @@
package cn.qaiu.parser;
import org.junit.Test;
import java.io.IOException;
import java.net.URL;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
/**
* GHSA-997r-7xx2-p9x6 regression: Cloudreve generic parser must reject
* hosts that resolve to loopback / private / link-local / metadata ranges
* before any outbound request.
*/
public class AssertPublicHostTest {
@Test
public void rejectsLoopbackAndPrivateHosts() throws Exception {
String[] blocked = {
"http://127.0.0.1.nip.io/s/poc",
"http://localhost/s/poc",
"http://10.0.0.1/s/poc",
"http://192.168.1.1/s/poc",
"http://172.16.0.1/s/poc",
"http://169.254.169.254/s/poc",
"http://[::1]/s/poc"
};
for (String raw : blocked) {
try {
PanBase.assertPublicHost(new URL(raw));
fail("expected block for " + raw);
} catch (IOException expected) {
assertTrue(expected.getMessage().contains("不允许访问")
|| expected.getMessage().contains("无法解析"));
}
}
}
@Test
public void allowsPublicHost() throws Exception {
PanBase.assertPublicHost(new URL("https://example.com/s/demo"));
}
}
@@ -323,6 +323,63 @@ public class PanDomainTemplateTest {
fsPattern.matcher("https://xxx.feishu.cn/docs/abc123").matches());
}
@Test
public void testYsPatternMatching() {
Pattern ysPattern = PanDomainTemplate.YS.getPattern();
// 主域名
Matcher m1 = ysPattern.matcher("https://qaiu.ysepan.com/");
assertTrue("YS should match ysepan.com", m1.matches());
assertEquals("qaiu", m1.group("KEY"));
Matcher m2 = ysPattern.matcher("http://sohehe4.ys168.com");
assertTrue("YS should match ys168.com", m2.matches());
assertEquals("sohehe4", m2.group("KEY"));
// 备用域名
Matcher m3 = ysPattern.matcher("https://demo.cccpan.com/");
assertTrue("YS should match cccpan.com", m3.matches());
assertEquals("demo", m3.group("KEY"));
Matcher m4 = ysPattern.matcher("https://space.ysupan.com");
assertTrue("YS should match ysupan.com", m4.matches());
assertEquals("space", m4.group("KEY"));
Matcher m5 = ysPattern.matcher("https://user.uupan.net/");
assertTrue("YS should match uupan.net", m5.matches());
assertEquals("user", m5.group("KEY"));
Matcher m6 = ysPattern.matcher("https://ok.ysok.net");
assertTrue("YS should match ysok.net", m6.matches());
assertEquals("ok", m6.group("KEY"));
// 非空间子域 / 非白名单域名
assertFalse("YS should NOT match www.ysepan.com",
ysPattern.matcher("https://www.ysepan.com/").matches());
assertFalse("YS should NOT match api host c6.ysepan.com",
ysPattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
assertFalse("YS should NOT match CDN ys-c.ysepan.com",
ysPattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x").matches());
assertFalse("YS should NOT match unrelated domain",
ysPattern.matcher("https://qaiu.evil.com/").matches());
assertFalse("YS should NOT match ysepan.com without space subdomain",
ysPattern.matcher("https://ysepan.com/").matches());
}
@Test
public void testYsFromShareUrl() {
ParserCreate parserCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
ShareLinkInfo info = parserCreate.getShareLinkInfo();
assertNotNull(info);
assertEquals("ys", info.getType());
assertEquals("永硕E盘", info.getPanName());
assertEquals("qaiu", info.getShareKey());
ParserCreate backup = ParserCreate.fromShareUrl("https://demo.cccpan.com/");
assertEquals("ys", backup.getShareLinkInfo().getType());
assertEquals("demo", backup.getShareLinkInfo().getShareKey());
}
@Test
public void testFsFromShareUrl() {
// 测试文件链接解析
@@ -0,0 +1,312 @@
package cn.qaiu.parser.impl;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.util.CommonUtils;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.client.WebClient;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.Assert.*;
/**
* 永硕E盘解析测试(含示例空间联调 + 真实下载校验)
*/
public class YsToolTest {
private static Vertx vertx;
private static WebClient webClient;
@BeforeClass
public static void setUpClass() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
webClient = WebClient.create(vertx);
}
@AfterClass
public static void tearDownClass() {
if (webClient != null) {
webClient.close();
}
if (vertx != null) {
vertx.close();
}
}
@Test
public void testPatternMatching() {
Pattern pattern = PanDomainTemplate.YS.getPattern();
Matcher m1 = pattern.matcher("https://qaiu.ysepan.com/");
assertTrue(m1.matches());
assertEquals("qaiu", m1.group("KEY"));
Matcher m2 = pattern.matcher("http://sohehe4.ysepan.com");
assertTrue(m2.matches());
assertEquals("sohehe4", m2.group("KEY"));
Matcher m3 = pattern.matcher("https://demo.ys168.com/");
assertTrue(m3.matches());
assertEquals("demo", m3.group("KEY"));
assertTrue(pattern.matcher("https://a.cccpan.com/").matches());
assertTrue(pattern.matcher("https://a.ysupan.com").matches());
assertTrue(pattern.matcher("https://a.uupan.net/").matches());
assertTrue(pattern.matcher("https://a.ysok.net").matches());
assertFalse(pattern.matcher("https://www.ysepan.com/").matches());
assertFalse(pattern.matcher("https://c6.ysepan.com/api/ml/mldq").matches());
assertFalse(pattern.matcher("https://ys-c.ysepan.com/wap/qaiu/x/y/z").matches());
assertFalse(pattern.matcher("https://zy.ysepan.com/assets/index.js").matches());
assertFalse(pattern.matcher("https://qaiu.evil.com/").matches());
}
@Test
public void testIdentifyShareUrl() {
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
assertEquals("ys", create.getShareLinkInfo().getType());
assertEquals("永硕E盘", create.getShareLinkInfo().getPanName());
assertEquals("qaiu", create.getShareLinkInfo().getShareKey());
}
@Test
public void testBuildDownloadUrl() {
String url = YsTool.buildDownloadUrl(
"yssl",
"A95UIe495EkSKE",
"Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC",
"L",
"lu20.jpg");
// 与官方一致:xzpz 前不加 "_"
assertEquals(
"https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg",
url);
assertFalse("force-download 前缀会导致 404", url.contains("/_"));
}
@Test
public void testOfficialSampleUrlRealDownload() throws Exception {
String official = "https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
String withForcePrefix = "https://ys-l.ysepan.com/wap/yssl/_A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg";
Buffer ok = download(official, "https://yssl.ysepan.com/");
assertTrue("官方直链应能下载到 JPEG", ok.length() > 1000);
assertEquals((byte) 0xFF, ok.getByte(0));
assertEquals((byte) 0xD8, ok.getByte(1));
int forceStatus = webClient.getAbs(withForcePrefix)
.putHeader("User-Agent", "Mozilla/5.0")
.putHeader("Referer", "https://yssl.ysepan.com/")
.send()
.toCompletionStage().toCompletableFuture()
.get(30, TimeUnit.SECONDS)
.statusCode();
assertNotEquals("带 _ 前缀的直链应失败(文件不存在)", 200, forceStatus);
}
@Test
public void testQaiuSpaceFileListAndRealDownload() throws Exception {
ParserCreate create = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
create.getShareLinkInfo().setSharePassword("qaiuys168");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> dirs = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(dirs);
assertFalse("目录列表不应为空", dirs.isEmpty());
assertEquals("folder", dirs.get(0).getFileType());
String dirId = dirs.get(0).getFileId();
assertNotNull(dirId);
ParserCreate filesCreate = ParserCreate.fromShareUrl("https://qaiu.ysepan.com/");
filesCreate.getShareLinkInfo().setSharePassword("qaiuys168");
filesCreate.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
filesCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
List<FileInfo> files = filesCreate.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(files);
FileInfo zip = files.stream()
.filter(f -> "file".equals(f.getFileType()))
.filter(f -> f.getFileName() != null && f.getFileName().contains("Pycharm"))
.findFirst()
.orElse(null);
assertNotNull("应能列出 Pycharm 压缩包", zip);
assertTrue(zip.getSize() > 0);
assertNotNull(zip.getParserUrl());
String param = zip.getParserUrl().substring(zip.getParserUrl().lastIndexOf('/') + 1);
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
assertFalse("downloadUrl 不应含 force 前缀",
paramJson.getString("downloadUrl", "").contains("/_"));
ParserCreate byId = ParserCreate.fromType("ys").shareKey("qaiu");
byId.getShareLinkInfo().setSharePassword("qaiuys168");
byId.getShareLinkInfo().getOtherParam().put("paramJson", paramJson);
String downloadUrl = byId.createTool().parseById()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(downloadUrl);
assertFalse("解析直链不应含 _xzpz 前缀", downloadUrl.matches(".*/_[^/]+/.*"));
assertTrue(downloadUrl.contains("ysepan.com"));
Buffer body = download(downloadUrl, "https://qaiu.ysepan.com/");
assertEquals("真实下载大小应与列表一致", zip.getSize().longValue(), body.length());
// ZIP magic: PK
assertEquals('P', (char) body.getByte(0));
assertEquals('K', (char) body.getByte(1));
System.out.println("qaiu real download ok, url=" + downloadUrl + ", size=" + body.length());
}
@Test
public void testFufu1ZmlHierarchyAndUrlItems() throws Exception {
// https://fufu1.ysepan.com/ 无密码;游戏3 下应按 zml 展示子目录,再进子目录才是夸克/百度链接
ParserCreate create = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> roots = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(roots);
FileInfo game3 = roots.stream()
.filter(f -> "folder".equals(f.getFileType()))
.filter(f -> "游戏3".equals(f.getFileName()))
.findFirst()
.orElse(null);
assertNotNull("应有目录 游戏3", game3);
ParserCreate level2 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
level2.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
level2.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
List<FileInfo> subdirs = level2.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(subdirs);
assertTrue("游戏3 下应是子目录列表", subdirs.size() > 10);
assertTrue("游戏3 下列表应全是 folder(zml 子目录)",
subdirs.stream().allMatch(f -> "folder".equals(f.getFileType())));
FileInfo sample = subdirs.stream()
.filter(f -> f.getFileName() != null && f.getFileName().contains("我是未来"))
.findFirst()
.orElse(subdirs.get(0));
// 从 parserUrl 提取 zml,或直接用 fileName
ParserCreate level3 = ParserCreate.fromShareUrl("https://fufu1.ysepan.com/");
level3.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
level3.getShareLinkInfo().getOtherParam().put("dirId", game3.getFileId());
level3.getShareLinkInfo().getOtherParam().put("zml", sample.getFileName());
List<FileInfo> links = level3.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(links);
assertFalse(links.isEmpty());
assertTrue("子目录内应有 url 类型",
links.stream().anyMatch(f -> "url".equals(f.getFileType())));
assertTrue("应包含夸克/百度链接名",
links.stream().anyMatch(f -> "夸克".equals(f.getFileName()) || "百度".equals(f.getFileName())));
assertFalse("空占位 URL 不应出现",
links.stream().anyMatch(f -> f.getFileName() == null || f.getFileName().isBlank()));
assertTrue("URL 条目应带 previewUrl",
links.stream().filter(f -> "url".equals(f.getFileType()))
.allMatch(f -> f.getPreviewUrl() != null && f.getPreviewUrl().startsWith("http")));
System.out.println("fufu1 hierarchy ok: 游戏3 -> " + sample.getFileName()
+ " -> " + links.stream().map(FileInfo::getFileName).toList());
}
@Test
public void testSohehe4SpaceDirectories() throws Exception {
ParserCreate create = ParserCreate.fromShareUrl("https://sohehe4.ysepan.com/");
create.getShareLinkInfo().setSharePassword("1234");
create.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
List<FileInfo> dirs = create.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(dirs);
assertTrue("sohehe4 应有多个目录", dirs.size() >= 3);
assertTrue(dirs.stream().allMatch(d -> "folder".equals(d.getFileType())));
String dirId = dirs.stream()
.filter(d -> d.getFileName() != null && d.getFileName().contains("留言"))
.map(FileInfo::getFileId)
.findFirst()
.orElse(dirs.get(0).getFileId());
ParserCreate filesCreate = ParserCreate.fromShareUrl("https://sohehe4.ysepan.com/");
filesCreate.getShareLinkInfo().setSharePassword("1234");
filesCreate.getShareLinkInfo().getOtherParam().put("domainName", "http://localhost");
filesCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
List<FileInfo> files = filesCreate.createTool().parseFileList()
.toCompletionStage().toCompletableFuture()
.get(60, TimeUnit.SECONDS);
assertNotNull(files);
assertFalse(files.isEmpty());
FileInfo file = files.stream()
.filter(f -> "file".equals(f.getFileType()))
.filter(f -> f.getSize() != null && f.getSize() > 0 && f.getSize() < 5_000_000)
.findFirst()
.orElse(null);
if (file != null) {
String param = file.getParserUrl().substring(file.getParserUrl().lastIndexOf('/') + 1);
JsonObject paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
String downloadUrl = paramJson.getString("downloadUrl");
assertNotNull(downloadUrl);
assertFalse(downloadUrl.contains("/_"));
Buffer body = download(downloadUrl, "https://sohehe4.ysepan.com/");
assertEquals(file.getSize().longValue(), body.length());
System.out.println("sohehe4 real download ok, file=" + file.getFileName()
+ ", size=" + body.length());
} else {
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size()
+ " (no small file for real download sample)");
}
}
private static Buffer download(String url, String referer) throws Exception {
return webClient.getAbs(url)
.putHeader("User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
.putHeader("Referer", referer)
.send()
.toCompletionStage().toCompletableFuture()
.thenApply(res -> {
assertEquals("下载 HTTP 状态码应为 200: " + url, 200, res.statusCode());
Buffer body = res.body();
assertNotNull(body);
assertTrue("下载内容为空: " + url, body.length() > 0);
String ct = res.getHeader("Content-Type");
assertFalse("不应返回 HTML 错误页: " + url,
ct != null && ct.toLowerCase().contains("text/html"));
return body;
})
.get(60, TimeUnit.SECONDS);
}
}
+3 -3
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.4.0</revision>
<revision>0.4.2</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
@@ -33,9 +33,9 @@
<commons-lang3.version>3.18.0</commons-lang3.version>
<commons-beanutils2.version>2.0.0</commons-beanutils2.version>
<parserVersion>10.2.5</parserVersion>
<jackson.version>2.18.6</jackson.version>
<jackson.version>2.18.9</jackson.version>
<!-- Logback 最新稳定版 -->
<logback.version>1.5.32</logback.version>
<logback.version>1.5.34</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.4.0",
"version": "0.4.2",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@@ -13,7 +13,7 @@
"@element-plus/icons-vue": "^2.3.1",
"@monaco-editor/loader": "^1.4.0",
"@vueuse/core": "^11.2.0",
"axios": "1.16.1",
"axios": "1.18.0",
"clipboard": "^2.0.11",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
+194 -56
View File
@@ -24,7 +24,7 @@
:class="[getFileTypeClass(file), { 'selected': batchMode && isFileSelected(file) }]"
@click="batchMode ? onBatchClick(file) : handleFileClick(file)"
>
<div v-if="batchMode && file.fileType !== 'folder'" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
<div v-if="batchMode && isDownloadableFile(file)" class="batch-checkbox" @click.stop="toggleFileSelect(file)">
<i :class="isFileSelected(file) ? 'fas fa-check-square' : 'far fa-square'"
:style="{ color: isFileSelected(file) ? '#409eff' : '#c0c4cc' }"></i>
</div>
@@ -52,9 +52,10 @@
<div class="batch-right">
<el-button
type="primary" size="small"
:disabled="selectedFiles.length === 0"
:disabled="selectedFiles.length === 0 || batchBrowserDownloadDisabled"
:loading="batchDownloading"
@click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 浏览器下载
</el-button>
@@ -127,9 +128,10 @@
<el-button
type="primary"
size="small"
:disabled="selectedFiles.length === 0 || batchDownloading"
:disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled"
:loading="batchDownloading"
@click="batchBrowserDownload"
:title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''"
>
浏览器下载
</el-button>
@@ -166,25 +168,41 @@
</div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
<p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
链接: {{ selectedNode.previewUrl }}
</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div>
<div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
<el-button
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
type="primary" size="small"
@click="openExternalLink(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开链接
</el-button>
<el-button
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
size="small"
@click="previewFile(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="success" size="small"
@click="handleDownload(selectedNode)"
:loading="downloadLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 下载
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading"
@@ -192,10 +210,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="selectedNode.parserUrl"
v-if="isDownloadableFile(selectedNode)"
size="small"
@click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
<i class="fas fa-link"></i> 复制直链
</el-button>
@@ -243,25 +263,41 @@
</div>
<h4 class="file-detail-name">{{ selectedNode.fileName }}</h4>
<div v-if="selectedNode.fileType !== 'folder'" class="file-detail-meta">
<p>类型: {{ getFileTypeClass(selectedNode) }}</p>
<p>大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p>类型: {{ selectedNode.fileType === 'url' ? '超链接' : getFileTypeClass(selectedNode) }}</p>
<p v-if="selectedNode.fileType !== 'url'">大小: {{ selectedNode.sizeStr || '0B' }}</p>
<p v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl" class="file-detail-link">
链接: {{ selectedNode.previewUrl }}
</p>
<p v-if="formatDate(selectedNode.createTime)">创建时间: {{ formatDate(selectedNode.createTime) }}</p>
<p v-if="formatDate(selectedNode.updateTime)">更新时间: {{ formatDate(selectedNode.updateTime) }}</p>
</div>
<div class="file-detail-actions">
<el-button v-if="selectedNode.parserUrl" size="small" @click="previewFile(selectedNode)">
<el-button
v-if="selectedNode.fileType === 'url' && selectedNode.previewUrl"
type="primary" size="small"
@click="openExternalLink(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开链接
</el-button>
<el-button
v-else-if="selectedNode.parserUrl || selectedNode.previewUrl"
size="small"
@click="previewFile(selectedNode)"
>
<i class="fas fa-external-link-alt"></i> 打开
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="success" size="small"
@click="handleDownload(selectedNode)"
:loading="downloadLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器下载' : ''"
>
<i class="fas fa-download"></i> 下载
</el-button>
<el-button
v-if="selectedNode.parserUrl && selectedNode.fileType !== 'folder'"
v-if="isDownloadableFile(selectedNode)"
type="primary" size="small"
@click="sendSingleToDownloader(selectedNode)"
:loading="singleSendLoading"
@@ -269,10 +305,12 @@
<i class="fas fa-paper-plane"></i> 发送到下载器
</el-button>
<el-button
v-if="selectedNode.parserUrl"
v-if="isDownloadableFile(selectedNode)"
size="small"
@click="copyDirectLink(selectedNode)"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedNode)"
:title="needsDownloader(selectedNode) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
<i class="fas fa-link"></i> 复制直链
</el-button>
@@ -290,7 +328,7 @@
<div v-if="batchMode" class="mobile-batch-footer">
<span class="tree-sidebar-count">已勾选 {{ selectedFiles.length }} 个文件</span>
<div class="tree-sidebar-actions">
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchBrowserDownload">浏览器下载</el-button>
<el-button type="primary" size="small" :disabled="selectedFiles.length === 0 || batchDownloading || batchBrowserDownloadDisabled" :loading="batchDownloading" @click="batchBrowserDownload" :title="batchBrowserDownloadDisabled ? '所选文件需使用下载器下载' : ''">浏览器下载</el-button>
<el-button type="success" size="small" :disabled="selectedFiles.length === 0 || batchDownloading" :loading="batchDownloading" @click="batchSendToDownloader">发送到下载器</el-button>
<el-button size="small" @click="toggleBatchMode">取消</el-button>
</div>
@@ -323,19 +361,22 @@
</div>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="previewFile(selectedFile)">打开</el-button>
<!-- 弹窗下载按钮 -->
<el-button type="primary" @click="previewFile(selectedFile)">
{{ selectedFile?.fileType === 'url' ? '打开链接' : '打开' }}
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
type="success"
@click="handleDownload(selectedFile)"
style="margin-left: 8px;"
:loading="downloadLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器下载' : ''"
>
下载
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
type="primary"
@click="sendSingleToDownloader(selectedFile)"
style="margin-left: 8px;"
@@ -344,10 +385,12 @@
发送到下载器
</el-button>
<el-button
v-if="selectedFile && selectedFile.parserUrl"
v-if="isDownloadableFile(selectedFile)"
@click="copyDirectLink(selectedFile)"
style="margin-left: 8px;"
:loading="copyLinkLoading"
:disabled="needsDownloader(selectedFile)"
:title="needsDownloader(selectedFile) ? '该网盘需使用下载器,无法直接复制直链' : ''"
>
复制直链
</el-button>
@@ -394,6 +437,11 @@ export default {
type: String,
default: ''
},
// 加密 auth,用于子目录/下载请求透传(后端未带上时前端补齐)
auth: {
type: String,
default: ''
},
viewMode: {
type: String,
default: 'pane' // 'pane' or 'tree'
@@ -460,6 +508,11 @@ export default {
lines.push(`更新时间: ${updateTime}`)
}
return lines
},
// 所选文件全部需要下载器时,禁用浏览器批量下载
batchBrowserDownloadDisabled() {
return this.selectedFiles.length > 0
&& this.selectedFiles.every(f => this.needsDownloader(f))
}
},
watch: {
@@ -493,6 +546,13 @@ export default {
}
return headers
},
// 子目录/下载链接透传 auth,避免进入子目录变成 guest
// 注意:auth 可能已 encodeURIComponent,直接拼接,避免 searchParams 再编一层
withAuth(url) {
if (!url || !this.auth) return url
if (/[?&]auth=/.test(url)) return url
return url + (url.includes('?') ? '&' : '?') + 'auth=' + this.auth
},
buildApiUrl() {
const baseUrl = `${window.location.origin}/v2/getFileList`
const params = new URLSearchParams({
@@ -501,14 +561,14 @@ export default {
if (this.password) {
params.append('pwd', this.password)
}
return `${baseUrl}?${params.toString()}`
return this.withAuth(`${baseUrl}?${params.toString()}`)
},
// 懒加载子节点
loadNode(node, resolve) {
if (node.level === 0) {
resolve(this.treeData[0].children)
} else if (node.data.fileType === 'folder' && node.data.parserUrl) {
axios.get(node.data.parserUrl, { headers: this.apiKeyHeaders() }).then(res => {
axios.get(this.withAuth(node.data.parserUrl), { headers: this.apiKeyHeaders() }).then(res => {
if (res.data.code === 200) {
const children = (res.data.data || []).map(item => ({
...item,
@@ -535,6 +595,8 @@ export default {
handleFileClick(file) {
if (file.fileType === 'folder') {
this.enterFolder(file)
} else if (file.fileType === 'url') {
this.openExternalLink(file)
} else if (this.viewMode === 'pane') {
this.selectedFile = file
this.fileDialogVisible = true
@@ -548,10 +610,11 @@ export default {
}
try {
this.loading = true
const response = await axios.get(folder.parserUrl, { headers: this.apiKeyHeaders() })
const folderUrl = this.withAuth(folder.parserUrl)
const response = await axios.get(folderUrl, { headers: this.apiKeyHeaders() })
if (response.data.code === 200) {
const newDir = {
url: folder.parserUrl,
url: folderUrl,
name: folder.fileName || '未命名文件夹'
}
this.pathStack.push(newDir)
@@ -585,7 +648,7 @@ export default {
}
try {
this.loading = true
const response = await axios.get(currentDir.url, { headers: this.apiKeyHeaders() })
const response = await axios.get(this.withAuth(currentDir.url), { headers: this.apiKeyHeaders() })
if (response.data.code === 200) {
this.currentFileList = response.data.data || []
} else {
@@ -599,8 +662,32 @@ export default {
this.loading = false
}
},
isDownloadableFile(file) {
return !!(file && file.parserUrl && file.fileType !== 'folder' && file.fileType !== 'url')
},
// 需要下载器(带 cookie 等特殊头)时,浏览器直连/复制直链不可用
// UC/夸克目录文件始终走下载器
needsDownloader(file) {
if (!file) return false
if (file.extParameters && file.extParameters.needDownloader) return true
const pan = (file.panType || '').toLowerCase()
return pan === 'uc' || pan === 'qk'
},
openExternalLink(file) {
const link = file?.previewUrl || file?.description
if (!link) {
this.$message.warning('该条目暂无外链')
return
}
window.open(link, '_blank', 'noopener,noreferrer')
this.closeFileDialog()
},
// 预览文件
previewFile(file) {
if (file?.fileType === 'url') {
this.openExternalLink(file)
return
}
if (file?.previewUrl || file?.parserUrl) {
this.previewUrl = this.appendToken(file.previewUrl || file.parserUrl)
this.isPreviewing = true
@@ -650,25 +737,18 @@ export default {
// 需要下载器,调用 getFileDownInfo 接口获取下载信息
this.downloadLoading = true
try {
// 从 parserUrl 提取 type 和 param
// parserUrl 格式: /v2/redirectUrl/{type}/{param} 或 完整URL
const url = new URL(file.parserUrl, window.location.origin)
const pathParts = url.pathname.split('/')
// 找到 redirectUrl 后面的部分
const redirectIdx = pathParts.indexOf('redirectUrl')
if (redirectIdx === -1 || redirectIdx + 2 >= pathParts.length) {
const tp = this.extractTypeParam(file)
if (!tp) {
this.$message.error('无法解析下载参数')
return
}
const type = pathParts[redirectIdx + 1]
const param = pathParts[redirectIdx + 2]
const headers = {}
const apiKey = localStorage.getItem('nfd_user_api_key')
if (apiKey) {
headers['X-API-Key'] = apiKey
}
const response = await axios.get(`${window.location.origin}/v2/getFileDownInfo/${type}/${param}`, { headers })
const response = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
if (response.data.code === 200 && response.data.data) {
const info = response.data.data
if (info.needDownloader) {
@@ -759,10 +839,7 @@ export default {
const tp = this.extractTypeParam(file)
if (tp && file.extParameters && file.extParameters.needDownloader) {
const headers = this.apiKeyHeaders()
const resp = await axios.get(
`${window.location.origin}/v2/getFileDownInfo/${tp.type}/${tp.param}`,
{ headers }
)
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers })
const info = resp.data.data || resp.data
if (info && info.downloadUrl) {
await addDownload(info.downloadUrl, info.downloadHeaders || {}, file.fileName)
@@ -870,7 +947,9 @@ export default {
},
fileMetaText(file) {
const parts = []
if (file.fileType !== 'folder') {
if (file.fileType === 'url') {
parts.push('超链接')
} else if (file.fileType !== 'folder') {
parts.push(file.sizeStr || '0B')
}
const timeText = this.formatDate(file.createTime)
@@ -916,7 +995,7 @@ export default {
this.toggleFileSelect(file)
},
selectAll() {
this.selectedFiles = this.currentFileList.filter(f => f.fileType !== 'folder' && f.parserUrl)
this.selectedFiles = this.currentFileList.filter(f => this.isDownloadableFile(f))
},
deselectAll() {
this.selectedFiles = []
@@ -924,25 +1003,41 @@ export default {
onTreeCheckChange() {
if (!this.$refs.fileTree) return
const checked = this.$refs.fileTree.getCheckedNodes()
this.selectedFiles = checked.filter(n => n.fileType !== 'folder' && n.parserUrl)
this.selectedFiles = checked.filter(n => this.isDownloadableFile(n))
},
extractTypeParam(file) {
if (!file.parserUrl) return null
try {
// pathname 已是解码后的 path;后端现用 URL-Safe Base64(无 %),可直接使用
const url = new URL(file.parserUrl, window.location.origin)
const parts = url.pathname.split('/')
const idx = parts.indexOf('redirectUrl')
if (idx === -1 || idx + 2 >= parts.length) return null
return { type: parts[idx + 1], param: parts[idx + 2] }
const m = url.pathname.match(/\/redirectUrl\/([^/]+)\/(.+)$/)
if (!m) return null
let param = m[2]
// 兼容历史「标准 Base64 + URLEncode」旧链接
if (/%[0-9A-Fa-f]{2}/.test(param)) {
try { param = decodeURIComponent(param) } catch { /* ignore */ }
}
return { type: m[1], param }
} catch {
return null
}
},
// URL-Safe Base64 本身可进 pathencodeURIComponent 对 - _ 无影响,只 encode 一次
buildFileDownInfoUrl(type, param) {
return this.withAuth(
`${window.location.origin}/v2/getFileDownInfo/${type}/${encodeURIComponent(param)}`
)
},
async batchBrowserDownload() {
if (this.selectedFiles.length === 0) return
const files = this.selectedFiles.filter(f => !this.needsDownloader(f))
if (files.length === 0) {
this.$message.warning('所选文件需使用「发送到下载器」下载')
return
}
this.batchDownloading = true
this.batchProgress = { current: 0, total: this.selectedFiles.length, failed: 0 }
for (const file of this.selectedFiles) {
this.batchProgress = { current: 0, total: files.length, failed: 0 }
for (const file of files) {
try {
const a = document.createElement('a')
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
@@ -975,13 +1070,32 @@ export default {
const total = this.selectedFiles.length
this.batchProgress = { current: 0, total, failed: 0 }
// 所有文件统一发 parserUrl(302) + downloadHeaders 给下载器
// needDownloader 文件走 getFileDownInfo 拿直链+cookie;其余发 parserUrl + headers
const downloadTasks = []
const apiHeaders = this.apiKeyHeaders()
for (const file of this.selectedFiles) {
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
const url = this.appendToken(rawUrl)
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {}
downloadTasks.push({ url, headers, fileName: file.fileName })
try {
if (this.needsDownloader(file)) {
const tp = this.extractTypeParam(file)
if (!tp) throw new Error('无法解析下载参数')
const resp = await axios.get(this.buildFileDownInfoUrl(tp.type, tp.param), { headers: apiHeaders })
const info = resp.data.data || resp.data
if (!info?.downloadUrl) throw new Error('获取下载信息失败')
downloadTasks.push({
url: info.downloadUrl,
headers: info.downloadHeaders || {},
fileName: file.fileName
})
} else {
const rawUrl = file.parserUrl.startsWith('http') ? file.parserUrl : (window.location.origin + file.parserUrl)
const url = this.appendToken(rawUrl)
const headers = (file.extParameters && file.extParameters.downloadHeaders) || {}
downloadTasks.push({ url, headers, fileName: file.fileName })
}
} catch (e) {
console.error('准备下载任务失败:', file.fileName, e)
this.batchProgress.failed++
}
this.batchProgress.current++
}
@@ -1005,14 +1119,18 @@ export default {
},
renderContent(h, { node, data, store }) {
const isFolder = data.fileType === 'folder'
const isUrl = data.fileType === 'url'
return h('div', {
class: 'custom-tree-node'
}, [
h('i', {
class: [this.getFileIcon(data), { 'folder-icon': isFolder, 'file-icon': !isFolder }]
class: [
this.getFileIcon(data),
{ 'folder-icon': isFolder, 'url-icon': isUrl, 'file-icon': !isFolder && !isUrl }
]
}),
h('span', {
class: ['node-label', { 'folder-text': isFolder, 'file-text': !isFolder }]
class: ['node-label', { 'folder-text': isFolder, 'url-text': isUrl, 'file-text': !isFolder && !isUrl }]
}, node.label)
])
}
@@ -1193,6 +1311,10 @@ html, body, #app, .main-container, .directory-tree, .content-card {
color: #27ae60;
}
.url .file-icon {
color: #1a73e8;
}
.file-name {
font-weight: 500;
font-size: 0.85rem;
@@ -1438,6 +1560,22 @@ html, body, #app, .main-container, .directory-tree, .content-card {
color: #4a9eff !important;
}
.custom-tree-node .url-icon {
color: #1a73e8 !important;
}
.dark-theme .custom-tree-node .url-icon {
color: #8ab4f8 !important;
}
.custom-tree-node .url-text {
color: #1a73e8 !important;
}
.dark-theme .custom-tree-node .url-text {
color: #8ab4f8 !important;
}
.custom-tree-node .folder-text {
color: #409eff !important;
font-weight: 500;
+7
View File
@@ -356,6 +356,13 @@
host: /(fast|drive)\.uc\.cn/,
name: 'UC网盘'
},
// 永硕E盘:主 ysepan.com/ys168.com,备 cccpan.com/ysupan.com/uupan.net/ysok.net
ysepan: {
reg: /https?:\/\/(?!(?:www|zy|ht|api|c\d+|ys-[a-zA-Z0-9]+)\.)[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)\/?/,
host: /[a-zA-Z\d-]+\.(?:ysepan\.com|ys168\.com|cccpan\.com|ysupan\.com|uupan\.net|ysok\.net)/,
name: '永硕E盘',
storage: 'hash'
},
other: {
reg: /https:\/\/([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}\/s\/.+/,
+3
View File
@@ -5,6 +5,7 @@ const fileTypeUtils = {
},
getFileTypeClass(file) {
if (file.fileType === 'folder') return 'folder'
if (file.fileType === 'url') return 'url'
const ext = this.getFileExtension(file.fileName)
const fileTypes = {
'image': ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'],
@@ -23,6 +24,8 @@ const fileTypeUtils = {
},
getFileIcon(file) {
if (file.fileType === 'folder') return 'fas fa-folder'
// 永硕等网盘的外链/公告条目
if (file.fileType === 'url') return 'fas fa-link'
const ext = this.getFileExtension(file.fileName)
const iconMap = {
'jpg': 'fas fa-file-image', 'jpeg': 'fas fa-file-image', 'png': 'fas fa-file-image',
+46 -15
View File
@@ -131,10 +131,15 @@
<div style="display: flex; align-items: center; justify-content: space-between;">
<span>下载链接</span>
<div style="display: flex; gap: 8px;">
<el-button @click="openUrl(downloadUrl)" type="primary" size="small">
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
</el-button>
<el-button @click="openUrl(getPreviewLink())" type="default" size="small">
<el-tooltip :disabled="!needsDownloader"
content="该网盘需使用下载器下载" placement="top">
<el-button @click="openUrl(downloadUrl)" type="primary" size="small"
:disabled="needsDownloader">
<el-icon style="margin-right: 4px;"><Download /></el-icon> 下载
</el-button>
</el-tooltip>
<el-button @click="openUrl(getPreviewLink())" type="default" size="small"
:disabled="needsDownloader">
<el-icon style="margin-right: 4px;"><View /></el-icon> 预览
</el-button>
<el-tooltip :disabled="aria2Connected"
@@ -150,10 +155,14 @@
</template>
<el-input :value="downloadUrl" readonly>
<template #append>
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
v-clipboard:error="onError" style="padding: 0 14px;">
<el-icon><CopyDocument/></el-icon>
</el-button>
<el-tooltip :disabled="!needsDownloader"
content="该网盘需使用下载器,无法直接复制直链" placement="top">
<el-button v-clipboard:copy="downloadUrl" v-clipboard:success="onCopy"
v-clipboard:error="onError" style="padding: 0 14px;"
:disabled="needsDownloader">
<el-icon><CopyDocument/></el-icon>
</el-button>
</el-tooltip>
</template>
</el-input>
<!-- 文件元信息 -->
@@ -417,6 +426,7 @@
:file-list="directoryData"
:share-url="link"
:password="password"
:auth="directoryAuth"
:view-mode="directoryViewMode"
@file-click="handleFileClick"
/>
@@ -649,6 +659,7 @@ export default {
// 目录树
showDirectoryTree: false,
directoryData: [],
directoryAuth: '', // 目录解析时的加密 auth,透传给子目录/下载
// 统计信息
node1Info: {},
@@ -760,6 +771,16 @@ export default {
thunder: '迅雷'
}
return map[this.aria2ConfigForm.downloaderType] || 'Aria2'
},
// 需要下载器(带 cookie 等特殊头)时禁用浏览器下载/复制直链;UC/夸克始终需要
needsDownloader() {
const pan = (this.getCurrentPanType() || '').toLowerCase()
if (pan === 'uc' || pan === 'qk') return true
const data = this.parseResult?.data
if (!data) return false
if (data.needDownloader || data.otherParam?.needDownloader) return true
const headers = data.downloadHeaders || data.otherParam?.downloadHeaders
return !!(headers && (headers.cookie || headers.Cookie))
}
},
methods: {
@@ -1168,6 +1189,7 @@ export default {
this.statisticsData = {}
this.showDirectoryTree = false
this.directoryData = []
this.directoryAuth = ''
},
// 统一API调用(自动添加认证参数)
@@ -1176,10 +1198,12 @@ export default {
this.errorBadgeVisible = false
try {
this.isLoading = true
// 添加认证参数(异步获取
const authParam = await this.generateAuthParam()
if (authParam) {
params.auth = authParam
// 添加认证参数(已有则不覆盖,便于目录树透传同一份 auth
if (!params.auth) {
const authParam = await this.generateAuthParam()
if (authParam) {
params.auth = authParam
}
}
const response = await axios.get(`${this.baseAPI}${endpoint}`, { params })
@@ -1267,11 +1291,13 @@ export default {
// 更新智能直链(包含认证参数)
this.updateDirectLink()
// 如果需要下载器(含特殊头),弹出下载器对话框
if (result.data?.needDownloader) {
const needDownloader = !!(result.data?.needDownloader || otherParam.needDownloader
|| otherParam.downloadHeaders?.cookie || otherParam.downloadHeaders?.Cookie)
if (needDownloader) {
this.downloadDialogInfo = {
downloadUrl: result.data.directLink,
fileName: result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || {},
fileName: result.data.fileInfo?.fileName || result.data.fileName || '',
downloadHeaders: result.data.downloadHeaders || otherParam.downloadHeaders || {},
aria2Command: this.aria2Command,
curlCommand: this.curlCommand,
aria2JsonRpc: this.aria2JsonRpc,
@@ -1291,6 +1317,11 @@ export default {
this.validateInput()
const params = { url: this.link }
if (this.password) params.pwd = this.password
// 预先生成 auth,既给本次请求用,也透传给 DirectoryTree 子目录/下载
this.directoryAuth = await this.generateAuthParam()
if (this.directoryAuth) {
params.auth = this.directoryAuth
}
// 直接调用 getFileList,让后端返回错误(不做客户端类型检查)
const directoryResult = await this.callAPI('/v2/getFileList', params)
+10
View File
@@ -386,6 +386,16 @@
"type": "string",
"example": "uuid123"
}
},
{
"name": "stoken",
"in": "query",
"required": false,
"description": "分享 token,用于子目录解析时复用认证状态",
"schema": {
"type": "string",
"example": "OASBe5qM0pg2VvvyLM..."
}
}
],
"responses": {
@@ -22,6 +22,8 @@ public class ParserAuthUtil {
public static final String SKIP_CLIENT_LINKS = "_skipClientLinks";
public static final String TEMP_AUTH_ADDED = "__TEMP_AUTH_ADDED";
public static final String DONATED_ACCOUNT_TOKEN = "__AUTO_DONATED_ACCOUNT_TOKEN";
/** 原始加密 auth 查询串,供目录子链透传(避免进入子目录丢失认证) */
public static final String AUTH_QUERY = "_authQuery";
private ParserAuthUtil() {
}
@@ -41,6 +43,8 @@ public class ParserAuthUtil {
}
if (StringUtils.isNotBlank(auth)) {
// 保留原始 auth,供 getFileList 子目录 parserUrl 透传
otherParam.put(AUTH_QUERY, auth);
AuthParam authParam = AuthParamCodec.decode(auth);
if (authParam != null && authParam.hasValidAuth()) {
otherParam.put("authType", authParam.getAuthType());
@@ -68,9 +68,10 @@ public class URLParamUtil {
boolean firstParam = !decodedUrl.contains("?");
for (String paramName : params.names()) {
// 忽略 "url", "pwd", "dirId", "uuid", "auth" 参数(这些参数单独处理,不应拼接到分享URL中
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth")) {
// 忽略单独处理的参数,不应拼接到分享URL中
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth")
&& !paramName.equals("stoken") && !paramName.equals("zml")) {
if (firstParam) {
urlBuilder.append("?");
firstParam = false;
@@ -16,7 +16,9 @@ import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.PanDomainTemplate;
import cn.qaiu.parser.IPanTool;
import cn.qaiu.parser.ParserCreate;
import cn.qaiu.parser.clientlink.ClientLinkGeneratorFactory;
import cn.qaiu.parser.clientlink.ClientLinkType;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.vx.core.annotaions.RouteHandler;
import cn.qaiu.vx.core.annotaions.RouteMapping;
import cn.qaiu.vx.core.enums.RouteMethod;
@@ -161,7 +163,7 @@ public class ParserApi {
@RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
String auth) {
String stoken, String zml, String auth) {
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate;
try {
@@ -176,9 +178,15 @@ public class ParserApi {
if (StringUtils.isNotBlank(dirId)) {
parserCreate.getShareLinkInfo().getOtherParam().put("dirId", dirId);
}
if (StringUtils.isNotBlank(stoken)) {
parserCreate.getShareLinkInfo().getOtherParam().put("stoken", stoken);
}
if (StringUtils.isNotBlank(uuid)) {
parserCreate.getShareLinkInfo().getOtherParam().put("uuid", uuid);
}
if (StringUtils.isNotBlank(zml)) {
parserCreate.getShareLinkInfo().getOtherParam().put("zml", zml);
}
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
@@ -204,7 +212,14 @@ public class ParserApi {
return promise.future();
}
String paramStr = new String(Base64.getDecoder().decode(param));
final String paramStr;
try {
paramStr = CommonUtils.urlBase64Decode(param);
} catch (Exception e) {
Promise<String> promise = Promise.promise();
promise.fail("下载参数解码失败: " + e.getMessage());
return promise.future();
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", new JsonObject(paramStr));
@@ -239,6 +254,131 @@ public class ParserApi {
return promise.future();
}
/**
* 目录文件下载信息(供前端下载器使用):返回直链、请求头及命令行
*/
@RouteMapping("/getFileDownInfo/:type/:param")
public Future<JsonObject> getFileDownInfo(HttpServerRequest request, String type, String param, String auth) {
ParserCreate parserCreate;
try {
parserCreate = ParserCreate.fromType(type).shareKey("-").setShareLinkInfoPwd("-");
} catch (Exception e) {
return Future.failedFuture(e);
}
if (param == null || param.isEmpty()) {
return Future.failedFuture("下载参数为空");
}
final JsonObject paramJson;
try {
paramJson = new JsonObject(CommonUtils.urlBase64Decode(param));
} catch (Exception e) {
return Future.failedFuture("下载参数解码失败: " + e.getMessage());
}
ShareLinkInfo shareLinkInfo = parserCreate.getShareLinkInfo();
shareLinkInfo.getOtherParam().put("paramJson", paramJson);
String linkPrefix = getLinkPrefix(request);
JsonObject otherParam = ParserAuthUtil.buildOtherParam(request, auth, linkPrefix);
shareLinkInfo.getOtherParam().put("domainName", linkPrefix);
shareLinkInfo.getOtherParam().put("_requestOrigin", linkPrefix);
return ParserAuthUtil.applyAuthParamsAndDonatedFallback(parserCreate, otherParam, dbService)
.compose(v -> {
URLParamUtil.addParam(parserCreate);
IPanTool tool = parserCreate.createTool();
return IPanTool.closeAfter(tool, tool::parseById)
.onFailure(t -> {
ParserAuthUtil.recordDonatedAccountFailureIfNeeded(dbService, otherParam, t);
ParserAuthUtil.recordAutoDonatedFailureIfNeeded(dbService,
parserCreate.getShareLinkInfo(), t);
})
.map(downloadUrl -> buildFileDownInfo(shareLinkInfo, paramJson, downloadUrl));
});
}
@SuppressWarnings("unchecked")
private static JsonObject buildFileDownInfo(ShareLinkInfo shareLinkInfo, JsonObject paramJson, String downloadUrl) {
Map<String, String> downloadHeaders = new HashMap<>();
// 入口参数里可能已带 cookie(目录解析时写入),先作为底稿
mergeDownloadHeaders(downloadHeaders, paramJson.getJsonObject("downloadHeaders"));
// 解析器运行时生成的请求头优先覆盖(如刷新后的 cookie),但跳过 null
Object headersObj = shareLinkInfo.getOtherParam().get("downloadHeaders");
if (headersObj instanceof Map) {
mergeDownloadHeaders(downloadHeaders, (Map<?, ?>) headersObj);
}
String fileName = paramJson.getString("fileName", "");
if (StringUtils.isBlank(fileName)) {
Object fn = shareLinkInfo.getOtherParam().get("fileName");
if (fn != null) {
fileName = fn.toString();
}
}
boolean needDownloader = Boolean.TRUE.equals(paramJson.getBoolean("needDownloader"))
|| !downloadHeaders.isEmpty();
shareLinkInfo.getOtherParam().put("downloadUrl", downloadUrl);
if (!downloadHeaders.isEmpty()) {
shareLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
}
JsonObject result = new JsonObject()
.put("downloadUrl", downloadUrl)
.put("fileName", fileName)
.put("needDownloader", needDownloader)
.put("downloadHeaders", downloadHeaders);
try {
Map<ClientLinkType, String> clientLinks = ClientLinkGeneratorFactory.generateAll(shareLinkInfo);
if (clientLinks.containsKey(ClientLinkType.CURL)) {
result.put("curlCommand", clientLinks.get(ClientLinkType.CURL));
}
if (clientLinks.containsKey(ClientLinkType.ARIA2)) {
result.put("aria2Command", clientLinks.get(ClientLinkType.ARIA2));
}
if (clientLinks.containsKey(ClientLinkType.THUNDER)) {
result.put("thunderLink", clientLinks.get(ClientLinkType.THUNDER));
}
} catch (Exception e) {
log.warn("生成下载命令失败: {}", e.getMessage());
}
return result;
}
/**
* 合并下载请求头,忽略 null/空值,避免运行时 null 覆盖入口参数中的 cookie。
*/
private static void mergeDownloadHeaders(Map<String, String> target, JsonObject source) {
if (source == null || source.isEmpty()) {
return;
}
for (String key : source.fieldNames()) {
Object val = source.getValue(key);
if (val != null && StringUtils.isNotBlank(val.toString())) {
target.put(key, val.toString());
}
}
}
private static void mergeDownloadHeaders(Map<String, String> target, Map<?, ?> source) {
if (source == null || source.isEmpty()) {
return;
}
for (Map.Entry<?, ?> e : source.entrySet()) {
if (e.getKey() == null || e.getValue() == null) {
continue;
}
String val = e.getValue().toString();
if (StringUtils.isNotBlank(val)) {
target.put(e.getKey().toString(), val);
}
}
}
/**
* 预览媒体文件
@@ -186,6 +186,11 @@ public class CacheServiceImpl implements CacheService {
// 传递 downloadHeaders 到两个对象
cacheLinkInfo.getOtherParam().put("downloadHeaders", downloadHeaders);
result.getOtherParam().put("downloadHeaders", downloadHeaders);
// 有特殊下载头时标记需要下载器(浏览器无法带 cookie 直连)
if (!downloadHeaders.isEmpty()) {
cacheLinkInfo.getOtherParam().put("needDownloader", true);
result.getOtherParam().put("needDownloader", true);
}
// 使用已有的工具类生成下载命令
generateCommandsFromShareLinkInfo(shareLinkInfo, cacheLinkInfo, result);