fix: support 123pan redirected share links

This commit is contained in:
q
2026-07-06 08:49:29 +08:00
parent 275f6b9e6d
commit 24bf98bca9
5 changed files with 498 additions and 418 deletions
@@ -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/(?<KEY>[a-zA-Z0-9_-]+)(?:\\.html)?"),
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[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
@@ -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<String, String> TOKENS = new ConcurrentHashMap<>();
private static final Map<String, Long> 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;
}
}
@@ -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<String> parse() {
Future<String> tokenFuture;
Future<String> 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<String> 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<String> 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,21 +313,98 @@ public class Ye2Tool extends PanBase {
HttpRequest<Buffer> 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);
.onSuccess(res2 -> handleDownloadUrlResponse(client, asJson(res2), "Ye2"))
.onFailure(err -> fail("下载接口失败: " + err.getMessage()));
}
private Future<String> requestShareV2Download(String shareKey, JsonObject body) {
Promise<String> 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<Buffer> 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();
}
private void getZipDownUrl(WebClient client, JsonObject fileInfo) {
JsonObject jsonObject = new JsonObject();
jsonObject.put("shareKey", YeShareHostUtil.normalizeShareKey(shareLinkInfo.getShareKey()));
jsonObject.put("fileIdList", new JsonArray().add(JsonObject.of("fileId", fileInfo.getInteger("FileId"))));
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;
log.info("Ye2 Batch Download API URL: {}", apiUrl);
HttpRequest<Buffer> bufferHttpRequest = client.postAbs(apiUrl);
bufferHttpRequest.putHeader("platform", "android");
bufferHttpRequest.putHeader("App-Version", "55");
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 -> 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("Ye2: downURLJson返回值异常->" + downURLJson);
fail(failPrefix + "返回值异常->" + downURLJson);
return;
}
} catch (Exception ignored) {
fail("Ye2: downURLJson格式异常->" + downURLJson);
fail(failPrefix + "格式异常->" + downURLJson);
return;
}
@@ -342,7 +414,7 @@ public class Ye2Tool extends PanBase {
}
if (StringUtils.isEmpty(downURL)) {
fail("Ye2: 未获取到下载链接");
fail(failPrefix + "未获取到下载链接");
return;
}
@@ -350,7 +422,6 @@ public class Ye2Tool extends PanBase {
Map<String, String> urlParams = CommonUtils.getURLParams(downURL);
String params = urlParams.get("params");
if (StringUtils.isEmpty(params)) {
// 如果没有params参数,直接使用downURL
complete(downURL);
return;
}
@@ -371,11 +442,11 @@ public class Ye2Tool extends PanBase {
JsonObject res3Json = asJson(res3);
try {
if (res3Json.getInteger("code") != 0) {
fail("Ye2: downUrl2返回值异常->" + res3Json);
fail(failPrefix + "重定向返回值异常->" + res3Json);
return;
}
} catch (Exception ignored) {
fail("Ye2: downUrl2格式异常->" + downURLJson);
fail(failPrefix + "重定向格式异常->" + downURLJson);
return;
}
String redirectUrl = res3Json.getJsonObject("data").getString("redirect_url");
@@ -386,112 +457,12 @@ public class Ye2Tool extends PanBase {
}
}).onFailure(err -> fail("获取直链失败: " + err.getMessage()));
} catch (MalformedURLException e) {
// 如果解析失败,直接使用downURL
complete(downURL);
} catch (Exception e) {
fail("urlParams解析异常: " + e.getMessage());
}
}).onFailure(err -> fail("下载接口失败: " + err.getMessage()));
}
/**
* 获取文件夹打包下载链接(使用Android平台API)
*/
private void getZipDownUrl(WebClient client, JsonObject fileInfo) {
// 构建请求数据
JsonObject jsonObject = new JsonObject();
jsonObject.put("shareKey", shareLinkInfo.getShareKey().replace(".html", ""));
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;
log.info("Ye2 Batch Download API URL: {}", apiUrl);
HttpRequest<Buffer> bufferHttpRequest = client.postAbs(apiUrl);
bufferHttpRequest.putHeader("platform", "android");
bufferHttpRequest.putHeader("App-Version", "55");
bufferHttpRequest.putHeader("Authorization", "Bearer " + ssoToken);
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<String, String> 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()));
}
/**
* 设置文件信息
*/
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<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> 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<String> 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<String> 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<FileInfo> 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,48 +584,63 @@ 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<String> parseById() {
JsonObject paramJson = (JsonObject) shareLinkInfo.getOtherParam().get("paramJson");
// 确保已登录
Future<String> 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<String> tokenFuture = resolveTokenFuture();
tokenFuture.onSuccess(token -> {
if (token.equals("nologin")) {
fail("该分享需要登录才能下载,请提供账号密码或token");
fail("该分享需要登录才能下载,请配置认证信息");
return;
}
// 使用encode123加密URL参数
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;
}
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;
@@ -714,77 +656,57 @@ public class Ye2Tool extends PanBase {
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;
.onSuccess(res2 -> handleDownloadUrlResponse(client, asJson(res2), "Ye2"))
.onFailure(err -> fail("下载接口失败: " + err.getMessage()));
}
String downURL = downURLJson.getJsonObject("data").getString("DownloadUrl");
if (StringUtils.isEmpty(downURL)) {
downURL = downURLJson.getJsonObject("data").getString("DownloadURL");
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()));
}
if (StringUtils.isEmpty(downURL)) {
fail("Ye2: 未获取到下载链接");
return;
}
try {
Map<String, String> 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()));
return promise.future();
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;
}
}
@@ -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<Character, Integer> 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;
}
}
@@ -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();