Compare commits

..

9 Commits

Author SHA1 Message Date
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
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
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
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
10 changed files with 28 additions and 814 deletions
+9 -8
View File
@@ -20,7 +20,7 @@ QQ交流群:1017480890
## 介绍
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/永硕E盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
> netdisk-fast-download网盘直链解析可以把云盘分享链接转为直链,可广泛应用于各类下载站,资源站,个人博客,图床,APP下载更新,视频点播等领域。支持市面各大主流云盘的文件分享以及文件夹分享链接,已支持蓝奏云/蓝奏云优享/移动云云空间/小飞机盘/亿方云/123云盘/Cloudreve等,支持加密分享,以及部分网盘文件夹分享。
[官方文档](https://nfd-parser.github.io/)
[API接入](https://nfdparser.apifox.cn/)
@@ -76,7 +76,6 @@ 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)
@@ -310,12 +309,9 @@ json返回数据格式示例:
"timestamp": 1736489219402
}
```
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机/永硕E盘等)
#### 3. 文件夹解析(支持蓝奏云/蓝奏优享/小飞机网盘)
/v2/getFileList?url=分享链接&pwd=分享密码
永硕E盘(`ys`)空间链接先返回目录列表,再带 `dirId` 获取目录内文件:
`/v2/getFileList?url=https://xxx.ysepan.com/&pwd=空间密码&dirId=目录编号`
```json
{
"code": 200,
@@ -371,7 +367,6 @@ json返回数据格式示例:
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G>100M需要登录) |
| 文叔叔 | √ | √ | 10G | 5GB |
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| UC网盘 | x | √ | 10G | 不限大小 |
@@ -536,7 +531,13 @@ 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>
## **免责声明**
- 用户在使用本项目时,应自行承担风险,并确保其行为符合当地法律法规。开发者不对用户因使用本项目而导致的任何后果负责。
+1 -1
View File
@@ -65,7 +65,7 @@
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.11</version>
<version>42.7.12</version>
</dependency>
</dependencies>
@@ -377,13 +377,6 @@ public enum PanDomainTemplate {
MmgTool.class),
// =====================私有盘解析==========================
// 永硕E盘空间分享:https://qaiu.ysepan.com/ (空间名即 shareKey,密码为空间访问密码)
YS("永硕E盘",
compile("https?://(?!(?:www|zy|ht|api|c\\d+|ys-[a-zA-Z0-9]+)\\.)(?<KEY>[a-zA-Z\\d-]+)\\.(?:ysepan|ys168)\\.com/?(?:\\?.*)?"),
"https://{shareKey}.ysepan.com/",
"https://www.ysepan.com/",
YsTool.class),
// Cloudreve自定义域名解析, 解析器CeTool兜底策略, 即任意域名如果匹配不到对应的规则, 则由CeTool统一处理,
// 如果不属于Cloudreve盘 则调用下一个自定义域名解析器, 若都处理不了则抛出异常, 这种匹配模式类似责任链
// http(s)://pan.huang1111.cn/s/xxx
@@ -1,620 +0,0 @@
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)
* <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";
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());
fetchFiles(session, dirId).onSuccess(filesResp -> {
try {
listPromise.complete(mapFiles(session, dirId, filesResp));
} 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, true);
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;
}
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp) {
List<FileInfo> result = new ArrayList<>();
String xzpz = filesResp.getJsonObject("ml", new JsonObject()).getString("xzpz", "");
JsonArray lb = filesResp.getJsonArray("lb", new JsonArray());
for (int i = 0; i < lb.size(); i++) {
JsonObject item = lb.getJsonObject(i);
if (item == null) {
continue;
}
String wjlx = item.getString("wjlx", "");
Integer bh = item.getInteger("bh");
if (bh == null) {
continue;
}
// URL / 公告条目
if ("url".equalsIgnoreCase(wjlx)) {
String title = StringUtils.defaultIfBlank(item.getString("bt"), item.getString("wjm", "链接"));
String link = item.getString("wjm", "");
FileInfo urlInfo = new FileInfo()
.setFileName(title)
.setFileId(bh.toString())
.setFileType("url")
.setSize(0L)
.setSizeStr("0B")
.setFilePath(item.getString("zml", ""))
.setCreateTime(normalizeTime(item.getString("sj")))
.setPanType(shareLinkInfo.getType())
.setPreviewUrl(link)
.setDescription(link);
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, true);
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(item.getString("zml", ""))
.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 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"), true);
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 + "/"));
}
static String buildDownloadUrl(String space, String xzpz, String pz, String fwq,
String fileName, boolean forceDownload) {
String token = forceDownload ? "_" + xzpz : xzpz;
String host = "X".equalsIgnoreCase(fwq)
? "y.ys168.com:8000"
: "ys-" + fwq.toLowerCase() + ".ysepan.com";
return "https://" + host + "/wap/"
+ encodePathSegment(space) + "/"
+ encodePathSegment(token) + "/"
+ 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) {
}
}
@@ -1,173 +0,0 @@
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.json.JsonObject;
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;
@BeforeClass
public static void setUpClass() {
vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
}
@AfterClass
public static void tearDownClass() {
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"));
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());
}
@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(
"qaiu",
"UOkGHeA9O9hFJHG",
"rEBaljD.Ba69AMzTBmAb9AC9CPvC2E",
"C",
"Pycharm2023.1激活.zip",
true);
assertEquals(
"https://ys-c.ysepan.com/wap/qaiu/_UOkGHeA9O9hFJHG/rEBaljD.Ba69AMzTBmAb9AC9CPvC2E/Pycharm2023.1%E6%BF%80%E6%B4%BB.zip",
url);
}
@Test
public void testQaiuSpaceFileListAndDownload() 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);
String decoded = CommonUtils.urlBase64Decode(param);
JsonObject paramJson = new JsonObject(decoded);
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);
assertTrue(downloadUrl.contains("ys-c.ysepan.com") || downloadUrl.contains("ysepan.com"));
assertTrue(downloadUrl.contains("Pycharm") || downloadUrl.contains("%E6%BF%80%E6%B4%BB"));
System.out.println("qaiu downloadUrl=" + downloadUrl);
}
@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());
assertTrue("应包含文件或URL条目",
files.stream().anyMatch(f -> "file".equals(f.getFileType()) || "url".equals(f.getFileType())));
System.out.println("sohehe4 dir=" + dirId + " entries=" + files.size());
}
}
+1 -1
View File
@@ -35,7 +35,7 @@
<parserVersion>10.2.5</parserVersion>
<jackson.version>2.18.6</jackson.version>
<!-- Logback 最新稳定版 -->
<logback.version>1.5.32</logback.version>
<logback.version>1.5.33</logback.version>
<junit.version>4.13.2</junit.version>
</properties>
+1 -1
View File
@@ -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",
+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": {
@@ -68,9 +68,9 @@ public class URLParamUtil {
boolean firstParam = !decodedUrl.contains("?");
for (String paramName : params.names()) {
// 忽略 "url", "pwd", "dirId", "uuid", "auth" 参数(这些参数单独处理,不应拼接到分享URL中)
// 忽略 "url", "pwd", "dirId", "uuid", "auth", "stoken" 参数(这些参数单独处理,不应拼接到分享URL中)
if (!paramName.equals("url") && !paramName.equals("pwd") && !paramName.equals("dirId")
&& !paramName.equals("uuid") && !paramName.equals("auth")) {
&& !paramName.equals("uuid") && !paramName.equals("auth") && !paramName.equals("stoken")) {
if (firstParam) {
urlBuilder.append("?");
firstParam = false;
@@ -161,7 +161,7 @@ public class ParserApi {
@RouteMapping("/getFileList")
public Future<List<FileInfo>> getFileList(HttpServerRequest request, String pwd, String dirId, String uuid,
String auth) {
String stoken, String auth) {
String url = URLParamUtil.parserParams(request);
ParserCreate parserCreate;
try {
@@ -176,6 +176,9 @@ 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);
}