diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index a9334ad..e499609 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -208,7 +208,7 @@ public enum PanDomainTemplate { 123795.com */ YE("123网盘", - compile("https://www\\.(" + + compile("https://(?:[a-zA-Z\\d-]+\\.)*(" + "123254\\.com|" + "123957\\.com|" + "123295\\.com|" + @@ -232,7 +232,7 @@ public enum PanDomainTemplate { "123635\\.com|" + "123242\\.com|" + "123795\\.com" + - ")/s/(?[a-zA-Z0-9_-]+)(?:\\.html)?"), + ")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?[a-zA-Z0-9]+-[a-zA-Z0-9]+|[a-zA-Z0-9_-]+)(?:\\.html)?(?:\\?.*)?"), "https://www.123pan.com/s/{shareKey}", Ye2Tool.class), // https://www.ecpan.cn/web/#/yunpanProxy?path=%2F%23%2Fdrive%2Foutside&data={code}&isShare=1 diff --git a/parser/src/main/java/cn/qaiu/parser/TokenCache.java b/parser/src/main/java/cn/qaiu/parser/TokenCache.java new file mode 100644 index 0000000..a3e4029 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/TokenCache.java @@ -0,0 +1,53 @@ +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. + */ +public final class TokenCache { + + private static final Map TOKENS = new ConcurrentHashMap<>(); + private static final Map EXPIRES = new ConcurrentHashMap<>(); + + private TokenCache() { + } + + public static String key(String type, String accountId) { + return type + ":" + (StringUtils.isBlank(accountId) ? "_default" : accountId); + } + + public static void putToken(String key, String token) { + if (StringUtils.isBlank(key) || StringUtils.isBlank(token)) { + return; + } + 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); + } + + public static void putExpire(String key, long expireTimeMillis) { + if (StringUtils.isBlank(key)) { + return; + } + EXPIRES.put(key, expireTimeMillis); + } + + public static boolean isExpired(String key) { + Long expireTimeMillis = EXPIRES.get(key); + return expireTimeMillis != null && System.currentTimeMillis() > expireTimeMillis; + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java b/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java index c6b24d5..6619975 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java @@ -3,8 +3,10 @@ package cn.qaiu.parser.impl; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.parser.TokenCache; import cn.qaiu.util.CommonUtils; import cn.qaiu.util.FileSizeConverter; +import cn.qaiu.util.YeShareHostUtil; import io.vertx.core.Future; import io.vertx.core.MultiMap; import io.vertx.core.Promise; @@ -19,7 +21,11 @@ import org.apache.commons.lang3.StringUtils; import java.net.MalformedURLException; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; -import java.util.*; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Random; import java.util.zip.CRC32; import static cn.qaiu.util.RandomStringGenerator.gen36String; @@ -32,24 +38,22 @@ import static cn.qaiu.util.RandomStringGenerator.gen36String; */ public class Ye2Tool extends PanBase { - public static final String SHARE_URL_PREFIX = "https://www.123pan.com/s/"; - public static final String FIRST_REQUEST_URL = SHARE_URL_PREFIX + "{key}.html"; - private static final String GET_SHARE_INFO_URL = "https://www.123pan.com/b/api/share/get?limit=100&next=1&orderBy=share_id&orderDirection=desc&shareKey={shareKey}&SharePwd={pwd}&ParentFileId={ParentFileId}&Page=1"; - private static final String DOWNLOAD_API_URL = "https://www.123pan.com/b/api/file/download_info"; - private static final String BATCH_DOWNLOAD_API_URL = "https://www.123pan.com/b/api/file/batch_download_share_info"; + private static final String API_BASE = "https://api.123278.com"; + private static final String GET_SHARE_INFO_URL = API_BASE + "/b/api/share/get?limit=100&next=1&orderBy=share_id&orderDirection=desc&shareKey={shareKey}&SharePwd={pwd}&ParentFileId={ParentFileId}&Page=1"; + private static final String DOWNLOAD_API_URL = API_BASE + "/b/api/file/download_info"; + private static final String DOWNLOAD_API_V2_BASE = "https://api.123278.com"; + private static final String DOWNLOAD_API_V2_PATH = "/b/api/v2/share/download/info"; + private static final String BATCH_DOWNLOAD_API_URL = API_BASE + "/b/api/file/batch_download_share_info"; private static final String LOGIN_URL = "https://login.123pan.com/api/user/sign_in"; - // 字符映射表 private static final String CHAR_MAP = "adefghlmyijnopkqrstubcvwsz"; private final MultiMap header = MultiMap.caseInsensitiveMultiMap(); - - // Token管理 - private static String ssoToken; - private static long tokenExpireTime = 0L; // 毫秒时间戳 + private final String cacheKey; public Ye2Tool(ShareLinkInfo shareLinkInfo) { super(shareLinkInfo); + this.cacheKey = TokenCache.key("ye2", resolveAccountId()); header.set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"); header.set("App-Version", "55"); header.set("Cache-Control", "no-cache"); @@ -65,16 +69,27 @@ public class Ye2Tool extends PanBase { header.set("Content-Type", "application/json"); } - /** - * 判断 token 是否过期 - */ - private boolean isTokenExpired() { - return System.currentTimeMillis() > tokenExpireTime - 60_000; // 提前1分钟刷新 + private String resolveAccountId() { + String accountId = "_default"; + if (!shareLinkInfo.getOtherParam().containsKey("auths")) { + return accountId; + } + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + if (auths.contains("_configId")) { + accountId = auths.get("_configId"); + } else if (auths.contains("username")) { + accountId = auths.get("username"); + } else if (auths.contains("token")) { + String token = auths.get("token"); + accountId = token.substring(0, Math.min(16, token.length())); + } + return accountId; + } + + private boolean isTokenExpired() { + return TokenCache.isExpired(cacheKey); } - /** - * 计算CRC32并转换为16进制字符串 - */ private String crc32(String data) { CRC32 crc32 = new CRC32(); crc32.update(data.getBytes()); @@ -82,105 +97,58 @@ public class Ye2Tool extends PanBase { return String.format("%08x", value); } - /** - * 16进制转10进制 - */ private long hexToInt(String hexStr) { return Long.parseLong(hexStr, 16); } - /** - * 123盘的URL加密算法 - * 参考Python代码中的encode123函数 - * - * @param url 请求路径 - * @param way 平台标识(如"android") - * @param version 版本号(如"55") - * @param timestamp 时间戳(毫秒) - * @return 加密后的URL参数,格式:?{y}={time_long}-{a}-{final_crc} - */ private String encode123(String url, String way, String version, String timestamp) { Random random = new Random(); - // 生成随机数 a = int(10000000 * random.randint(1, 10000000) / 10000) int randomInt = random.nextInt(10000000) + 1; long a = (10000000L * randomInt) / 10000; - // 将时间戳转换为时间格式 long timeLong = Long.parseLong(timestamp) / 1000; java.time.LocalDateTime dateTime = java.time.Instant.ofEpochSecond(timeLong) .atZone(java.time.ZoneId.systemDefault()) .toLocalDateTime(); String timeStr = dateTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")); - // 根据时间字符串生成g StringBuilder g = new StringBuilder(); for (char c : timeStr.toCharArray()) { int digit = Character.getNumericValue(c); if (digit == 0) { g.append(CHAR_MAP.charAt(0)); } else { - // 数字1对应索引0,数字2对应索引1,以此类推 g.append(CHAR_MAP.charAt(digit - 1)); } } - // 计算y值(CRC32的十进制) String y = String.valueOf(hexToInt(crc32(g.toString()))); - - // 计算最终的CRC32 String finalCrcInput = String.format("%d|%d|%s|%s|%s|%s", timeLong, a, url, way, version, y); String finalCrc = String.valueOf(hexToInt(crc32(finalCrcInput))); - - // 返回加密后的URL参数 return String.format("?%s=%d-%d-%s", y, timeLong, a, finalCrc); } public Future parse() { - Future tokenFuture; + Future tokenFuture = resolveTokenFuture(); - // 检查是否直接提供了token - MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); - if (auths != null && auths.contains("token")) { - String providedToken = auths.get("token"); - if (StringUtils.isNotEmpty(providedToken)) { - ssoToken = providedToken; - tokenFuture = Future.succeededFuture(providedToken); - } else { - // 如果没有提供token,尝试登录 - if (ssoToken == null || isTokenExpired()) { - tokenFuture = loginAndGetToken(); - } else { - tokenFuture = Future.succeededFuture(ssoToken); - } - } - } else { - // 如果没有提供token,尝试登录 - if (ssoToken == null || isTokenExpired()) { - tokenFuture = loginAndGetToken(); - } else { - tokenFuture = Future.succeededFuture(ssoToken); - } - } - - // 1. 登录获取 sso-token 或使用提供的token tokenFuture.onSuccess(token -> { if (!token.equals("nologin")) { - // 2. 设置 header - ssoToken = token; + TokenCache.putToken(cacheKey, token); header.set("Authorization", "Bearer " + token); } - final String dataKey = shareLinkInfo.getShareKey().replace(".html", ""); + final String dataKey = YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey()); final String pwd = shareLinkInfo.getSharePassword(); + final String shareOrigin = resolveYeShareOrigin(dataKey); + final String shareReferer = buildShareReferer(shareOrigin, dataKey, pwd); - // 3. 获取分享信息 client.getAbs(UriTemplate.of(GET_SHARE_INFO_URL)) .setTemplateParam("shareKey", dataKey) .setTemplateParam("pwd", StringUtils.isEmpty(pwd) ? "" : pwd) .setTemplateParam("ParentFileId", "0") .putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .putHeader("Referer", "https://www.123pan.com/") - .putHeader("Origin", "https://www.123pan.com") + .putHeader("Referer", shareReferer) + .putHeader("Origin", shareOrigin) .send() .onSuccess(res -> { JsonObject shareInfoJson = asJson(res); @@ -200,35 +168,41 @@ public class Ye2Tool extends PanBase { return; } - // 获取第一个文件信息 JsonObject fileInfo = data.getJsonArray("InfoList").getJsonObject(0); - - // 检查是否需要登录 if (token.equals("nologin")) { - fail("该分享需要登录才能下载,请提供账号密码或token"); + fail("该分享需要登录才能下载,请配置认证信息"); return; } - // 判断是否为文件夹: Type: 1为文件夹, 0为文件 if (fileInfo.getInteger("Type", 0) == 1) { - // 4. 获取文件夹打包下载链接 getZipDownUrl(client, fileInfo); } else { - // 4. 获取文件下载链接 getDownUrl(client, fileInfo); } }) .onFailure(this.handleFail(GET_SHARE_INFO_URL)); - }).onFailure(err -> { - fail("登录获取token失败: {}", err.getMessage()); - }); + }).onFailure(err -> fail("123盘解析异常: {}", err.getMessage())); return promise.future(); } - /** - * 登录并获取token - */ + private Future resolveTokenFuture() { + MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); + if (auths != null && auths.contains("token")) { + String providedToken = auths.get("token"); + if (StringUtils.isNotEmpty(providedToken)) { + TokenCache.putToken(cacheKey, providedToken); + return Future.succeededFuture(providedToken); + } + } + + String cached = TokenCache.getToken(cacheKey); + if (cached == null || isTokenExpired()) { + return loginAndGetToken(); + } + return Future.succeededFuture(cached); + } + private Future loginAndGetToken() { MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); if (auths == null) { @@ -275,16 +249,17 @@ public class Ye2Tool extends PanBase { promise.fail("未获取到token"); return; } - ssoToken = data.getString("token"); + String ssoToken = data.getString("token"); String expireStr = data.getString("expire"); - // 解析过期时间 + long expireMs; if (StringUtils.isNotEmpty(expireStr)) { - tokenExpireTime = OffsetDateTime.parse(expireStr) - .toInstant().toEpochMilli(); + expireMs = OffsetDateTime.parse(expireStr) + .toInstant().toEpochMilli() - 60_000; } else { - // 如果没有过期时间,默认1小时后过期 - tokenExpireTime = System.currentTimeMillis() + 3600_000; + expireMs = System.currentTimeMillis() + 3600_000; } + TokenCache.putToken(cacheKey, ssoToken); + TokenCache.putExpire(cacheKey, expireMs); log.info("登录成功,token: {}", ssoToken); promise.complete(ssoToken); }) @@ -292,13 +267,34 @@ public class Ye2Tool extends PanBase { return promise.future(); } - /** - * 获取下载链接(使用Android平台API) - */ private void getDownUrl(WebClient client, JsonObject fileInfo) { setFileInfo(fileInfo); - // 构建请求数据 + String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey()); + if (StringUtils.isNotEmpty(normalizedShareKey)) { + JsonObject v2Body = new JsonObject() + .put("ShareKey", normalizedShareKey) + .put("FileID", fileInfo.getInteger("FileId")) + .put("S3keyFlag", fileInfo.getString("S3KeyFlag")) + .put("Size", fileInfo.getLong("Size")) + .put("Etag", fileInfo.getString("Etag")); + requestShareV2Download(normalizedShareKey, v2Body).onSuccess(v2Url -> { + if (StringUtils.isNotEmpty(v2Url)) { + complete(v2Url); + return; + } + requestLegacyDownUrl(client, fileInfo); + }).onFailure(err -> { + log.warn("Ye2 v2分享下载接口失败,回退旧接口: {}", err.getMessage()); + requestLegacyDownUrl(client, fileInfo); + }); + return; + } + + requestLegacyDownUrl(client, fileInfo); + } + + private void requestLegacyDownUrl(WebClient client, JsonObject fileInfo) { JsonObject jsonObject = new JsonObject(); jsonObject.put("driveId", 0); jsonObject.put("etag", fileInfo.getString("Etag")); @@ -308,7 +304,6 @@ public class Ye2Tool extends PanBase { jsonObject.put("size", fileInfo.getLong("Size")); jsonObject.put("type", 0); - // 使用encode123加密URL参数 String timestamp = String.valueOf(System.currentTimeMillis()); String encryptedParams = encode123("/b/api/file/download_info", "android", "55", timestamp); String apiUrl = DOWNLOAD_API_URL + encryptedParams; @@ -318,92 +313,71 @@ public class Ye2Tool extends PanBase { HttpRequest bufferHttpRequest = client.postAbs(apiUrl); bufferHttpRequest.putHeader("platform", "android"); bufferHttpRequest.putHeader("App-Version", "55"); - bufferHttpRequest.putHeader("Authorization", "Bearer " + ssoToken); + bufferHttpRequest.putHeader("Authorization", "Bearer " + TokenCache.getToken(cacheKey)); bufferHttpRequest.putHeader("User-Agent", "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"); bufferHttpRequest.putHeader("Content-Type", "application/json"); bufferHttpRequest .sendJsonObject(jsonObject) - .onSuccess(res2 -> { - JsonObject downURLJson = asJson(res2); - try { - if (downURLJson.getInteger("code") != 0) { - fail("Ye2: downURLJson返回值异常->" + downURLJson); - return; - } - } catch (Exception ignored) { - fail("Ye2: downURLJson格式异常->" + downURLJson); - return; - } - - String downURL = downURLJson.getJsonObject("data").getString("DownloadUrl"); - if (StringUtils.isEmpty(downURL)) { - downURL = downURLJson.getJsonObject("data").getString("DownloadURL"); - } - - if (StringUtils.isEmpty(downURL)) { - fail("Ye2: 未获取到下载链接"); - return; - } - - try { - Map urlParams = CommonUtils.getURLParams(downURL); - String params = urlParams.get("params"); - if (StringUtils.isEmpty(params)) { - // 如果没有params参数,直接使用downURL - complete(downURL); - return; - } - - byte[] decodeByte = Base64.getDecoder().decode(params); - String downUrl2 = new String(decodeByte); - - clientNoRedirects.getAbs(downUrl2).putHeaders(header).send().onSuccess(res3 -> { - if (res3.statusCode() == 302 || res3.statusCode() == 301) { - String redirectUrl = res3.getHeader("Location"); - if (StringUtils.isBlank(redirectUrl)) { - fail("重定向链接为空"); - return; - } - complete(redirectUrl); - return; - } - JsonObject res3Json = asJson(res3); - try { - if (res3Json.getInteger("code") != 0) { - fail("Ye2: downUrl2返回值异常->" + res3Json); - return; - } - } catch (Exception ignored) { - fail("Ye2: downUrl2格式异常->" + downURLJson); - return; - } - String redirectUrl = res3Json.getJsonObject("data").getString("redirect_url"); - if (StringUtils.isNotEmpty(redirectUrl)) { - complete(redirectUrl); - } else { - complete(downUrl2); - } - }).onFailure(err -> fail("获取直链失败: " + err.getMessage())); - } catch (MalformedURLException e) { - // 如果解析失败,直接使用downURL - complete(downURL); - } catch (Exception e) { - fail("urlParams解析异常: " + e.getMessage()); - } - }).onFailure(err -> fail("下载接口失败: " + err.getMessage())); + .onSuccess(res2 -> handleDownloadUrlResponse(client, asJson(res2), "Ye2")) + .onFailure(err -> fail("下载接口失败: " + err.getMessage())); + } + + private Future requestShareV2Download(String shareKey, JsonObject body) { + Promise promise = Promise.promise(); + String timestamp = String.valueOf(System.currentTimeMillis()); + String encryptedParams = encode123(DOWNLOAD_API_V2_PATH, "web", "3", timestamp); + String apiUrl = DOWNLOAD_API_V2_BASE + DOWNLOAD_API_V2_PATH + encryptedParams; + String shareOrigin = resolveYeShareOrigin(shareKey); + String shareReferer = buildShareReferer(shareOrigin, shareKey, shareLinkInfo.getSharePassword()); + + HttpRequest request = client.postAbs(apiUrl); + request.putHeader("Accept", "*/*"); + request.putHeader("Authorization", "Bearer " + TokenCache.getToken(cacheKey)); + request.putHeader("App-Version", "3"); + request.putHeader("platform", "web"); + request.putHeader("Origin", shareOrigin); + request.putHeader("Referer", shareReferer); + request.putHeader("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"); + request.putHeader("Content-Type", "application/json;charset=UTF-8"); + + request.sendJsonObject(body).onSuccess(resp -> { + JsonObject json = asJson(resp); + if (json == null || json.getInteger("code", -1) != 0) { + promise.fail("v2接口返回异常: " + (json == null ? "null" : json.encode())); + return; + } + JsonObject data = json.getJsonObject("data", new JsonObject()); + JsonArray dispatchList = data.getJsonArray("dispatchList", new JsonArray()); + String downloadPath = data.getString("downloadPath"); + if (StringUtils.isBlank(downloadPath)) { + promise.complete(""); + return; + } + String prefix = ""; + if (dispatchList.size() > 0) { + JsonObject firstDispatch = dispatchList.getJsonObject(0); + if (firstDispatch != null) { + prefix = firstDispatch.getString("prefix", ""); + } + } + if (StringUtils.isBlank(prefix)) { + promise.complete(downloadPath); + return; + } + String finalUrl = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix; + finalUrl += downloadPath.startsWith("/") ? downloadPath : "/" + downloadPath; + promise.complete(finalUrl); + }).onFailure(promise::fail); + + return promise.future(); } - /** - * 获取文件夹打包下载链接(使用Android平台API) - */ private void getZipDownUrl(WebClient client, JsonObject fileInfo) { - // 构建请求数据 JsonObject jsonObject = new JsonObject(); - jsonObject.put("shareKey", shareLinkInfo.getShareKey().replace(".html", "")); + jsonObject.put("shareKey", YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey())); jsonObject.put("fileIdList", new JsonArray().add(JsonObject.of("fileId", fileInfo.getInteger("FileId")))); - // 使用encode123加密URL参数 String timestamp = String.valueOf(System.currentTimeMillis()); String encryptedParams = encode123("/b/api/file/batch_download_share_info", "android", "55", timestamp); String apiUrl = BATCH_DOWNLOAD_API_URL + encryptedParams; @@ -413,85 +387,82 @@ public class Ye2Tool extends PanBase { HttpRequest bufferHttpRequest = client.postAbs(apiUrl); bufferHttpRequest.putHeader("platform", "android"); bufferHttpRequest.putHeader("App-Version", "55"); - bufferHttpRequest.putHeader("Authorization", "Bearer " + ssoToken); + bufferHttpRequest.putHeader("Authorization", "Bearer " + TokenCache.getToken(cacheKey)); bufferHttpRequest.putHeader("User-Agent", "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"); bufferHttpRequest.putHeader("Content-Type", "application/json"); bufferHttpRequest .sendJsonObject(jsonObject) - .onSuccess(res2 -> { - JsonObject downURLJson = asJson(res2); - try { - if (downURLJson.getInteger("code") != 0) { - fail("Ye2: 文件夹打包下载接口返回值异常->" + downURLJson); - return; - } - } catch (Exception ignored) { - fail("Ye2: 文件夹打包下载接口格式异常->" + downURLJson); - return; - } - - String downURL = downURLJson.getJsonObject("data").getString("DownloadUrl"); - if (StringUtils.isEmpty(downURL)) { - downURL = downURLJson.getJsonObject("data").getString("DownloadURL"); - } - - if (StringUtils.isEmpty(downURL)) { - fail("Ye2: 未获取到文件夹打包下载链接"); - return; - } - - try { - Map urlParams = CommonUtils.getURLParams(downURL); - String params = urlParams.get("params"); - if (StringUtils.isEmpty(params)) { - // 如果没有params参数,直接使用downURL - complete(downURL); - return; - } - - byte[] decodeByte = Base64.getDecoder().decode(params); - String downUrl2 = new String(decodeByte); - - clientNoRedirects.getAbs(downUrl2).putHeaders(header).send().onSuccess(res3 -> { - if (res3.statusCode() == 302 || res3.statusCode() == 301) { - String redirectUrl = res3.getHeader("Location"); - if (StringUtils.isBlank(redirectUrl)) { - fail("重定向链接为空"); - return; - } - complete(redirectUrl); - return; - } - JsonObject res3Json = asJson(res3); - try { - if (res3Json.getInteger("code") != 0) { - fail("Ye2: 文件夹打包下载重定向返回值异常->" + res3Json); - return; - } - } catch (Exception ignored) { - fail("Ye2: 文件夹打包下载重定向格式异常->" + downURLJson); - return; - } - String redirectUrl = res3Json.getJsonObject("data").getString("redirect_url"); - if (StringUtils.isNotEmpty(redirectUrl)) { - complete(redirectUrl); - } else { - complete(downUrl2); - } - }).onFailure(err -> fail("获取文件夹打包下载直链失败: " + err.getMessage())); - } catch (MalformedURLException e) { - // 如果解析失败,直接使用downURL - complete(downURL); - } catch (Exception e) { - fail("文件夹打包下载urlParams解析异常: " + e.getMessage()); - } - }).onFailure(err -> fail("文件夹打包下载接口失败: " + err.getMessage())); + .onSuccess(res2 -> handleDownloadUrlResponse(client, asJson(res2), "Ye2: 文件夹打包下载")) + .onFailure(err -> fail("文件夹打包下载接口失败: " + err.getMessage())); + } + + private void handleDownloadUrlResponse(WebClient client, JsonObject downURLJson, String failPrefix) { + try { + if (downURLJson.getInteger("code") != 0) { + fail(failPrefix + "返回值异常->" + downURLJson); + return; + } + } catch (Exception ignored) { + fail(failPrefix + "格式异常->" + downURLJson); + return; + } + + String downURL = downURLJson.getJsonObject("data").getString("DownloadUrl"); + if (StringUtils.isEmpty(downURL)) { + downURL = downURLJson.getJsonObject("data").getString("DownloadURL"); + } + + if (StringUtils.isEmpty(downURL)) { + fail(failPrefix + "未获取到下载链接"); + return; + } + + try { + Map urlParams = CommonUtils.getURLParams(downURL); + String params = urlParams.get("params"); + if (StringUtils.isEmpty(params)) { + complete(downURL); + return; + } + + byte[] decodeByte = Base64.getDecoder().decode(params); + String downUrl2 = new String(decodeByte); + + clientNoRedirects.getAbs(downUrl2).putHeaders(header).send().onSuccess(res3 -> { + if (res3.statusCode() == 302 || res3.statusCode() == 301) { + String redirectUrl = res3.getHeader("Location"); + if (StringUtils.isBlank(redirectUrl)) { + fail("重定向链接为空"); + return; + } + complete(redirectUrl); + return; + } + JsonObject res3Json = asJson(res3); + try { + if (res3Json.getInteger("code") != 0) { + fail(failPrefix + "重定向返回值异常->" + res3Json); + return; + } + } catch (Exception ignored) { + fail(failPrefix + "重定向格式异常->" + downURLJson); + return; + } + String redirectUrl = res3Json.getJsonObject("data").getString("redirect_url"); + if (StringUtils.isNotEmpty(redirectUrl)) { + complete(redirectUrl); + } else { + complete(downUrl2); + } + }).onFailure(err -> fail("获取直链失败: " + err.getMessage())); + } catch (MalformedURLException e) { + complete(downURL); + } catch (Exception e) { + fail("urlParams解析异常: " + e.getMessage()); + } } - /** - * 设置文件信息 - */ void setFileInfo(JsonObject reqBodyJson) { FileInfo fileInfo = new FileInfo(); fileInfo.setFileId(reqBodyJson.getInteger("FileId").toString()); @@ -514,62 +485,39 @@ public class Ye2Tool extends PanBase { shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); } - /** - * 解析文件夹中的文件列表 - */ @Override public Future> parseFileList() { Promise> promise = Promise.promise(); - String shareKey = shareLinkInfo.getShareKey().replace(".html", ""); + String shareKey = YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey()); String pwd = shareLinkInfo.getSharePassword(); - String parentFileId = "0"; // 根目录的文件ID + String parentFileId = "0"; - // 如果参数里的目录ID不为空,则直接解析目录 String dirId = (String) shareLinkInfo.getOtherParam().get("dirId"); if (StringUtils.isNotBlank(dirId)) { parentFileId = dirId; } - // 确保已登录 - Future tokenFuture; - MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); - if (auths != null && auths.contains("token")) { - String providedToken = auths.get("token"); - if (StringUtils.isNotEmpty(providedToken)) { - ssoToken = providedToken; - tokenFuture = Future.succeededFuture(providedToken); - } else { - if (ssoToken == null || isTokenExpired()) { - tokenFuture = loginAndGetToken(); - } else { - tokenFuture = Future.succeededFuture(ssoToken); - } - } - } else { - if (ssoToken == null || isTokenExpired()) { - tokenFuture = loginAndGetToken(); - } else { - tokenFuture = Future.succeededFuture(ssoToken); - } - } - + Future tokenFuture = resolveTokenFuture(); String finalParentFileId = parentFileId; tokenFuture.onSuccess(token -> { if (token.equals("nologin")) { - promise.fail("该分享需要登录才能访问,请提供账号密码或token"); + promise.fail("该分享需要登录才能访问,请配置认证信息"); return; } - // 构造文件列表接口的URL + String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareKey); + String shareOrigin = resolveYeShareOrigin(normalizedShareKey); + String shareReferer = buildShareReferer(shareOrigin, normalizedShareKey, pwd); + client.getAbs(UriTemplate.of(GET_SHARE_INFO_URL)) .setTemplateParam("shareKey", shareKey) .setTemplateParam("pwd", StringUtils.isEmpty(pwd) ? "" : pwd) .setTemplateParam("ParentFileId", finalParentFileId) .putHeader("Authorization", "Bearer " + token) .putHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .putHeader("Referer", "https://www.123pan.com/") - .putHeader("Origin", "https://www.123pan.com") + .putHeader("Referer", shareReferer) + .putHeader("Origin", shareOrigin) .send().onSuccess(res -> { JsonObject response = asJson(res); if (response.getInteger("code") != 0) { @@ -585,13 +533,12 @@ public class Ye2Tool extends PanBase { JsonArray infoList = response.getJsonObject("data").getJsonArray("InfoList"); List result = new ArrayList<>(); - // 遍历返回的文件和目录信息 for (int i = 0; i < infoList.size(); i++) { JsonObject item = infoList.getJsonObject(i); FileInfo fileInfo = new FileInfo(); - // 构建下载参数 JsonObject postData = JsonObject.of() + .put("shareKey", shareLinkInfo.getShareKey()) .put("driveId", 0) .put("etag", item.getString("Etag")) .put("fileId", item.getInteger("FileId")) @@ -602,7 +549,7 @@ public class Ye2Tool extends PanBase { String param = CommonUtils.urlBase64Encode(postData.encode()); - if (item.getInteger("Type") == 0) { // 文件 + if (item.getInteger("Type") == 0) { fileInfo.setFileName(item.getString("FileName")) .setFileId(item.getInteger("FileId").toString()) .setFileType("file") @@ -610,40 +557,20 @@ public class Ye2Tool extends PanBase { .setHash(item.getString("Etag")) .setSizeStr(FileSizeConverter.convertToReadableSize(item.getLong("Size"))); - String createAt = item.getString("CreateAt"); - if (StringUtils.isNotEmpty(createAt)) { - fileInfo.setCreateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - .format(OffsetDateTime.parse(createAt).toLocalDateTime())); - } - - String updateAt = item.getString("UpdateAt"); - if (StringUtils.isNotEmpty(updateAt)) { - fileInfo.setUpdateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - .format(OffsetDateTime.parse(updateAt).toLocalDateTime())); - } + setFileTimes(item, fileInfo); fileInfo.setParserUrl(String.format("%s/v2/redirectUrl/%s/%s", getDomainName(), shareLinkInfo.getType(), param)) .setPreviewUrl(String.format("%s/v2/viewUrl/%s/%s", getDomainName(), shareLinkInfo.getType(), param)); result.add(fileInfo); - } else if (item.getInteger("Type") == 1) { // 目录 + } else if (item.getInteger("Type") == 1) { fileInfo.setFileName(item.getString("FileName")) .setFileId(item.getInteger("FileId").toString()) .setFileType("folder") .setSize(0L); - String createAt = item.getString("CreateAt"); - if (StringUtils.isNotEmpty(createAt)) { - fileInfo.setCreateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - .format(OffsetDateTime.parse(createAt).toLocalDateTime())); - } - - String updateAt = item.getString("UpdateAt"); - if (StringUtils.isNotEmpty(updateAt)) { - fileInfo.setUpdateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") - .format(OffsetDateTime.parse(updateAt).toLocalDateTime())); - } + setFileTimes(item, fileInfo); fileInfo.setParserUrl( String.format("%s/v2/getFileList?url=%s&dirId=%s&pwd=%s", @@ -657,134 +584,129 @@ public class Ye2Tool extends PanBase { } promise.complete(result); }).onFailure(promise::fail); - }).onFailure(err -> promise.fail("登录获取token失败: " + err.getMessage())); + }).onFailure(err -> promise.fail("123盘解析异常: " + err.getMessage())); return promise.future(); } - /** - * 通过ID解析特定文件 - */ @Override public Future parseById() { JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson"); - - // 确保已登录 - Future tokenFuture; - MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths"); - if (auths != null && auths.contains("token")) { - String providedToken = auths.get("token"); - if (StringUtils.isNotEmpty(providedToken)) { - ssoToken = providedToken; - tokenFuture = Future.succeededFuture(providedToken); - } else { - if (ssoToken == null || isTokenExpired()) { - tokenFuture = loginAndGetToken(); - } else { - tokenFuture = Future.succeededFuture(ssoToken); - } - } - } else { - if (ssoToken == null || isTokenExpired()) { - tokenFuture = loginAndGetToken(); - } else { - tokenFuture = Future.succeededFuture(ssoToken); - } - } + Future tokenFuture = resolveTokenFuture(); tokenFuture.onSuccess(token -> { if (token.equals("nologin")) { - fail("该分享需要登录才能下载,请提供账号密码或token"); + fail("该分享需要登录才能下载,请配置认证信息"); return; } - // 使用encode123加密URL参数 - String timestamp = String.valueOf(System.currentTimeMillis()); - String encryptedParams = encode123("/b/api/file/download_info", "android", "55", timestamp); - String apiUrl = DOWNLOAD_API_URL + encryptedParams; + String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey()); + if (StringUtils.isNotEmpty(normalizedShareKey)) { + JsonObject v2Body = new JsonObject() + .put("ShareKey", normalizedShareKey) + .put("FileID", paramJson.getInteger("fileId", paramJson.getInteger("FileID", 0))) + .put("S3keyFlag", paramJson.getString("s3keyFlag", paramJson.getString("S3keyFlag", ""))) + .put("Size", paramJson.getLong("size", paramJson.getLong("Size", 0L))) + .put("Etag", paramJson.getString("etag", paramJson.getString("Etag", ""))); + requestShareV2Download(normalizedShareKey, v2Body).onSuccess(v2Url -> { + if (StringUtils.isNotEmpty(v2Url)) { + complete(v2Url); + return; + } + parseByIdLegacy(token, paramJson); + }).onFailure(err -> { + log.warn("Ye2 parseById v2接口失败,回退旧接口: {}", err.getMessage()); + parseByIdLegacy(token, paramJson); + }); + return; + } - log.info("Ye2 parseById API URL: {}", apiUrl); - - HttpRequest bufferHttpRequest = client.postAbs(apiUrl); - bufferHttpRequest.putHeader("platform", "android"); - bufferHttpRequest.putHeader("App-Version", "55"); - bufferHttpRequest.putHeader("Authorization", "Bearer " + token); - bufferHttpRequest.putHeader("User-Agent", "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"); - bufferHttpRequest.putHeader("Content-Type", "application/json"); - - bufferHttpRequest - .sendJsonObject(paramJson) - .onSuccess(res2 -> { - JsonObject downURLJson = asJson(res2); - try { - if (downURLJson.getInteger("code") != 0) { - fail("Ye2: downURLJson返回值异常->" + downURLJson); - return; - } - } catch (Exception ignored) { - fail("Ye2: downURLJson格式异常->" + downURLJson); - return; - } - - String downURL = downURLJson.getJsonObject("data").getString("DownloadUrl"); - if (StringUtils.isEmpty(downURL)) { - downURL = downURLJson.getJsonObject("data").getString("DownloadURL"); - } - - if (StringUtils.isEmpty(downURL)) { - fail("Ye2: 未获取到下载链接"); - return; - } - - try { - Map urlParams = CommonUtils.getURLParams(downURL); - String params = urlParams.get("params"); - if (StringUtils.isEmpty(params)) { - // 如果没有params参数,直接使用downURL - complete(downURL); - return; - } - - byte[] decodeByte = Base64.getDecoder().decode(params); - String downUrl2 = new String(decodeByte); - - clientNoRedirects.getAbs(downUrl2).putHeaders(header).send().onSuccess(res3 -> { - if (res3.statusCode() == 302 || res3.statusCode() == 301) { - String redirectUrl = res3.getHeader("Location"); - if (StringUtils.isBlank(redirectUrl)) { - fail("重定向链接为空"); - return; - } - complete(redirectUrl); - return; - } - JsonObject res3Json = asJson(res3); - try { - if (res3Json.getInteger("code") != 0) { - fail("Ye2: downUrl2返回值异常->" + res3Json); - return; - } - } catch (Exception ignored) { - fail("Ye2: downUrl2格式异常->" + downURLJson); - return; - } - String redirectUrl = res3Json.getJsonObject("data").getString("redirect_url"); - if (StringUtils.isNotEmpty(redirectUrl)) { - complete(redirectUrl); - } else { - complete(downUrl2); - } - }).onFailure(err -> fail("获取直链失败: " + err.getMessage())); - } catch (MalformedURLException e) { - // 如果解析失败,直接使用downURL - complete(downURL); - } catch (Exception e) { - fail("urlParams解析异常: " + e.getMessage()); - } - }).onFailure(err -> fail("下载接口失败: " + err.getMessage())); - }).onFailure(err -> fail("登录获取token失败: " + err.getMessage())); + parseByIdLegacy(token, paramJson); + }).onFailure(err -> fail("123盘解析异常: " + err.getMessage())); return promise.future(); } -} + private void parseByIdLegacy(String token, JsonObject paramJson) { + if (paramJson.containsKey("FileID") && !paramJson.containsKey("fileId")) { + paramJson.put("fileId", paramJson.getValue("FileID")); + } + if (paramJson.containsKey("S3keyFlag") && !paramJson.containsKey("s3keyFlag")) { + paramJson.put("s3keyFlag", paramJson.getValue("S3keyFlag")); + } + if (paramJson.containsKey("Size") && !paramJson.containsKey("size")) { + paramJson.put("size", paramJson.getValue("Size")); + } + if (paramJson.containsKey("Etag") && !paramJson.containsKey("etag")) { + paramJson.put("etag", paramJson.getValue("Etag")); + } + + String timestamp = String.valueOf(System.currentTimeMillis()); + String encryptedParams = encode123("/b/api/file/download_info", "android", "55", timestamp); + String apiUrl = DOWNLOAD_API_URL + encryptedParams; + + log.info("Ye2 parseById API URL: {}", apiUrl); + + HttpRequest bufferHttpRequest = client.postAbs(apiUrl); + bufferHttpRequest.putHeader("platform", "android"); + bufferHttpRequest.putHeader("App-Version", "55"); + bufferHttpRequest.putHeader("Authorization", "Bearer " + token); + bufferHttpRequest.putHeader("User-Agent", "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36"); + bufferHttpRequest.putHeader("Content-Type", "application/json"); + + bufferHttpRequest + .sendJsonObject(paramJson) + .onSuccess(res2 -> handleDownloadUrlResponse(client, asJson(res2), "Ye2")) + .onFailure(err -> fail("下载接口失败: " + err.getMessage())); + } + + private void setFileTimes(JsonObject item, FileInfo fileInfo) { + String createAt = item.getString("CreateAt"); + if (StringUtils.isNotEmpty(createAt)) { + fileInfo.setCreateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .format(OffsetDateTime.parse(createAt).toLocalDateTime())); + } + + String updateAt = item.getString("UpdateAt"); + if (StringUtils.isNotEmpty(updateAt)) { + fileInfo.setUpdateTime(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + .format(OffsetDateTime.parse(updateAt).toLocalDateTime())); + } + } + + private String resolveYeShareOrigin(String shareKey) { + String origin = extractYeShareOrigin(shareLinkInfo.getShareUrl()); + if (StringUtils.isNotBlank(origin)) { + return origin; + } + origin = extractYeShareOrigin(shareLinkInfo.getStandardUrl()); + if (StringUtils.isNotBlank(origin)) { + return origin; + } + String uid = YeShareHostUtil.getNumericSubdomainIdByShareKey(shareKey); + if (StringUtils.isNotBlank(uid)) { + return "https://" + uid + ".share.123pan.cn"; + } + return "https://www.123pan.com"; + } + + private String extractYeShareOrigin(String url) { + if (StringUtils.isBlank(url) || !url.matches("^https?://[a-zA-Z\\d-]+\\.(?:mshare|share)\\.123pan\\.cn(?:[:/].*)?$")) { + return ""; + } + int idx = url.indexOf('/', url.indexOf("//") + 2); + return idx > 0 ? url.substring(0, idx) : url; + } + + private String buildShareReferer(String shareOrigin, String shareKey, String pwd) { + String key = YeShareHostUtil.normalizeShareKey(shareKey); + if (StringUtils.isBlank(key)) { + return shareOrigin + "/"; + } + String referer = shareOrigin + "/123pan/" + key; + if (StringUtils.isNotBlank(pwd)) { + referer += "?pwd=" + pwd; + } + return referer; + } +} diff --git a/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java b/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java new file mode 100644 index 0000000..9c5d2f4 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java @@ -0,0 +1,84 @@ +package cn.qaiu.util; + +import org.apache.commons.lang3.StringUtils; + +import java.util.HashMap; +import java.util.Map; + +/** + * 123分享链接子域工具:将分享 key 的前半段解码为数字 uid。 + */ +public final class YeShareHostUtil { + + private static final String CODE62 = "Tvd3hHA9QEkom14xpfaBJIMwgFYGPXn2sWCNORDr80KuUSl7bZcetizL5q6yVj"; + private static final long MAX_SAFE_INTEGER = 9007199254740991L; + private static final Map DECODE_MAP = new HashMap<>(); + + static { + for (int i = 0; i < CODE62.length(); i++) { + DECODE_MAP.put(CODE62.charAt(i), i); + } + } + + private YeShareHostUtil() { + } + + public static String getNumericSubdomainIdByShareKey(String shareKey) { + String normalized = normalizeShareKey(shareKey); + if (StringUtils.isBlank(normalized)) { + return ""; + } + int split = normalized.indexOf('-'); + if (split <= 0) { + return ""; + } + String encodedUid = normalized.substring(0, split); + Long uid = decodeBase62LittleEndian(encodedUid); + return uid == null ? "" : String.valueOf(uid); + } + + public static String normalizeShareKey(String shareKey) { + if (StringUtils.isBlank(shareKey)) { + return ""; + } + String key = shareKey.trim(); + int queryIndex = key.indexOf('?'); + if (queryIndex >= 0) { + key = key.substring(0, queryIndex); + } + int slashIndex = key.lastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < key.length() - 1) { + key = key.substring(slashIndex + 1); + } + if (key.endsWith(".html")) { + key = key.substring(0, key.length() - 5); + } + return key; + } + + private static Long decodeBase62LittleEndian(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + long result = 0L; + for (int i = 0; i < value.length(); i++) { + Integer digit = DECODE_MAP.get(value.charAt(i)); + if (digit == null) { + return null; + } + double weighted = digit * Math.pow(62, i); + if (!Double.isFinite(weighted)) { + return null; + } + long next = result + (long) weighted; + if (next <= 0 || next > MAX_SAFE_INTEGER) { + return null; + } + result = next; + } + if (result <= 0) { + return null; + } + return result; + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java index dc99bc0..768b071 100644 --- a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java +++ b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java @@ -1,6 +1,7 @@ package cn.qaiu.parser; import cn.qaiu.entity.ShareLinkInfo; +import cn.qaiu.util.YeShareHostUtil; import org.junit.Test; import java.util.Arrays; @@ -157,6 +158,26 @@ public class PanDomainTemplateTest { assertEquals("somekey", m5.group("KEY")); } + @Test + public void testYeRedirectPatternAndUidDecode() { + Pattern yePattern = PanDomainTemplate.YE.getPattern(); + + Matcher oldUrl = yePattern.matcher("https://www.123pan.com/s/lN7UVv-pbYJ"); + assertTrue("YE should match old 123pan share URL", oldUrl.find()); + assertEquals("lN7UVv-pbYJ", oldUrl.group("KEY")); + + Matcher redirectedUrl = yePattern.matcher("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ"); + assertTrue("YE should match redirected mshare URL", redirectedUrl.find()); + assertEquals("lN7UVv-pbYJ", redirectedUrl.group("KEY")); + + Matcher htmlUrl = yePattern.matcher("https://www.123278.com/s/lN7UVv-pbYJ.html?pwd=abcd"); + assertTrue("YE should match html URL with query", htmlUrl.find()); + assertEquals("lN7UVv-pbYJ", htmlUrl.group("KEY")); + + assertEquals("1813382308", YeShareHostUtil.getNumericSubdomainIdByShareKey("lN7UVv-pbYJ")); + assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ?pwd=abcd")); + } + @Test public void testLePatternFix() { Pattern lePattern = PanDomainTemplate.LE.getPattern();