diff --git a/README.md b/README.md index a18f003..cba6364 100644 --- a/README.md +++ b/README.md @@ -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 | 不限大小 | diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index e499609..9ecbe5e 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -377,6 +377,13 @@ 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]+)\\.)(?[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 diff --git a/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java b/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java new file mode 100644 index 0000000..27ef2d9 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/impl/YsTool.java @@ -0,0 +1,620 @@ +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) + *

+ * 空间分享形如 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 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 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> parseFileList() { + Promise> 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 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 ensureSession() { + Promise 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 handleSpaceHome(String origin, String space, HttpResponse 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 verifyPassword(String origin, String space, String pwd, String antiforgery) { + Promise 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 reloadSpace(String origin, String space, String verifiedJwt) { + Promise 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 fetchDirectories(Session session) { + Promise 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 fetchFiles(Session session, int dirId) { + Promise 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 apiPost(Session session, String path, JsonObject body) { + Promise p = Promise.promise(); + String url = session.apiBase + path; + HttpRequest 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> collectDownloadableFiles(Session session, JsonArray dirs) { + Promise> p = Promise.promise(); + List collected = new ArrayList<>(); + List 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 dirIds, int index, + List collected, Promise> 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 mapDirectories(Session session, JsonArray dirs) { + List 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 mapFiles(Session session, int dirId, JsonObject filesResp) { + List 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 downloadableFiles(JsonObject filesResp) { + List 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 downloadHeaders(String referer) { + Map 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 res) { + List 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) { + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java b/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java new file mode 100644 index 0000000..60a42f7 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/YsToolTest.java @@ -0,0 +1,173 @@ +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 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 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 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 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()); + } +}