mirror of
https://github.com/qaiu/netdisk-fast-download.git
synced 2026-08-26 11:32:02 +00:00
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>
This commit is contained in:
@@ -414,7 +414,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);
|
||||
}
|
||||
@@ -522,6 +533,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;
|
||||
|
||||
@@ -378,8 +378,11 @@ public enum PanDomainTemplate {
|
||||
// =====================私有盘解析==========================
|
||||
|
||||
// 永硕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|ys168)\\.com/?(?:\\?.*)?"),
|
||||
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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 永硕E盘 (ysepan.com / ys168.com)
|
||||
* 永硕E盘(主 ysepan.com / ys168.com,备 cccpan.com / ysupan.com / uupan.net / ysok.net)
|
||||
* <p>
|
||||
* 空间分享形如 https://{space}.ysepan.com/ ,需空间访问密码时通过 sharePassword 传入。
|
||||
*/
|
||||
@@ -42,6 +42,8 @@ public class YsTool extends PanBase {
|
||||
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);
|
||||
@@ -88,9 +90,10 @@ public class YsTool extends PanBase {
|
||||
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));
|
||||
listPromise.complete(mapFiles(session, dirId, filesResp, zmlFilter));
|
||||
} catch (Exception e) {
|
||||
listPromise.fail(baseMsg() + " - 解析文件列表失败: " + e.getMessage());
|
||||
}
|
||||
@@ -132,7 +135,7 @@ public class YsTool extends PanBase {
|
||||
fail("下载参数不完整: {}", paramJson);
|
||||
return promise.future();
|
||||
}
|
||||
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName, true);
|
||||
String url = buildDownloadUrl(space, xzpz, pz, fwq, fileName);
|
||||
completeWithMeta(url, downloadHeaders(paramJson.getString("referer", spaceOrigin())));
|
||||
return promise.future();
|
||||
}
|
||||
@@ -375,36 +378,94 @@ public class YsTool extends PanBase {
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<FileInfo> mapFiles(Session session, int dirId, JsonObject filesResp) {
|
||||
/**
|
||||
* 将目录内文件按 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 wjlx = item.getString("wjlx", "");
|
||||
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 title = StringUtils.defaultIfBlank(item.getString("bt"), item.getString("wjm", "链接"));
|
||||
String link = item.getString("wjm", "");
|
||||
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(item.getString("zml", ""))
|
||||
.setFilePath(itemZml)
|
||||
.setCreateTime(normalizeTime(item.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setPreviewUrl(link)
|
||||
.setDescription(link);
|
||||
// parserUrl 置空,避免前端误走下载;打开走 previewUrl
|
||||
result.add(urlInfo);
|
||||
continue;
|
||||
}
|
||||
@@ -417,7 +478,7 @@ public class YsTool extends PanBase {
|
||||
}
|
||||
|
||||
long size = item.getLong("dx", 0L);
|
||||
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName, true);
|
||||
String downloadUrl = buildDownloadUrl(session.space, xzpz, pz, fwq, fileName);
|
||||
JsonObject param = new JsonObject()
|
||||
.put("space", session.space)
|
||||
.put("xzpz", xzpz)
|
||||
@@ -436,7 +497,7 @@ public class YsTool extends PanBase {
|
||||
.setFileType("file")
|
||||
.setSize(size)
|
||||
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
|
||||
.setFilePath(item.getString("zml", ""))
|
||||
.setFilePath(itemZml)
|
||||
.setCreateTime(normalizeTime(item.getString("sj")))
|
||||
.setPanType(shareLinkInfo.getType())
|
||||
.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s",
|
||||
@@ -448,6 +509,18 @@ public class YsTool extends PanBase {
|
||||
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", "");
|
||||
@@ -474,7 +547,7 @@ public class YsTool extends PanBase {
|
||||
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);
|
||||
file.getString("fwq"), file.getString("wjm"));
|
||||
|
||||
FileInfo fileInfo = new FileInfo()
|
||||
.setFileName(file.getString("wjm"))
|
||||
@@ -489,15 +562,16 @@ public class YsTool extends PanBase {
|
||||
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;
|
||||
/**
|
||||
* 拼装直链。注意:不要在 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(token) + "/"
|
||||
+ encodePathSegment(xzpz) + "/"
|
||||
+ encodePathSegment(pz) + "/"
|
||||
+ encodePathSegment(fileName);
|
||||
}
|
||||
|
||||
@@ -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 的 Base64(URL-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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
// 测试文件链接解析
|
||||
|
||||
@@ -6,7 +6,9 @@ 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;
|
||||
@@ -19,20 +21,25 @@ import java.util.regex.Pattern;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* 永硕E盘解析测试(含示例空间联调)
|
||||
* 永硕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();
|
||||
}
|
||||
@@ -54,10 +61,16 @@ public class YsToolTest {
|
||||
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
|
||||
@@ -71,19 +84,40 @@ public class YsToolTest {
|
||||
@Test
|
||||
public void testBuildDownloadUrl() {
|
||||
String url = YsTool.buildDownloadUrl(
|
||||
"qaiu",
|
||||
"UOkGHeA9O9hFJHG",
|
||||
"rEBaljD.Ba69AMzTBmAb9AC9CPvC2E",
|
||||
"C",
|
||||
"Pycharm2023.1激活.zip",
|
||||
true);
|
||||
"yssl",
|
||||
"A95UIe495EkSKE",
|
||||
"Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC",
|
||||
"L",
|
||||
"lu20.jpg");
|
||||
// 与官方一致:xzpz 前不加 "_"
|
||||
assertEquals(
|
||||
"https://ys-c.ysepan.com/wap/qaiu/_UOkGHeA9O9hFJHG/rEBaljD.Ba69AMzTBmAb9AC9CPvC2E/Pycharm2023.1%E6%BF%80%E6%B4%BB.zip",
|
||||
"https://ys-l.ysepan.com/wap/yssl/A95UIe495EkSKE/Bc8hF8Nsbl2I4Ec6vAm5EGe9HU36iC/lu20.jpg",
|
||||
url);
|
||||
assertFalse("force-download 前缀会导致 404", url.contains("/_"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testQaiuSpaceFileListAndDownload() throws Exception {
|
||||
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");
|
||||
@@ -118,8 +152,9 @@ public class YsToolTest {
|
||||
assertNotNull(zip.getParserUrl());
|
||||
|
||||
String param = zip.getParserUrl().substring(zip.getParserUrl().lastIndexOf('/') + 1);
|
||||
String decoded = CommonUtils.urlBase64Decode(param);
|
||||
JsonObject paramJson = new JsonObject(decoded);
|
||||
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");
|
||||
@@ -130,9 +165,74 @@ public class YsToolTest {
|
||||
.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);
|
||||
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
|
||||
@@ -166,8 +266,47 @@ public class YsToolTest {
|
||||
|
||||
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());
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user