fix(qk): enter folder shares and clarify 123 local Authorization (#283)

Quark parse stopped when the share root was only a folder and reported
that nothing could be downloaded. Enter subfolders, skip 0-byte
placeholders, and for visitors try a smaller file when Quark returns
23018 instead of transferring without a Cookie.

123 download failures now tell users to configure a local
auths.ye.authorization. Share listings can be read without login, and
hash fragments on 123 URLs are ignored. New URL fixtures use a ref=example
query, not a password-shaped parameter.

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
qaiu
2026-10-31 07:45:46 +08:00
committed by GitHub
co-authored by Cursor Agent
parent 12d0f6bfbb
commit 98a043daf9
11 changed files with 635 additions and 127 deletions
+20 -11
View File
@@ -77,7 +77,7 @@ wget -O v01 "https://lz.qaiu.top/parser?url=https%3A%2F%2Fwww.ecpan.cn%2Fweb%2F%
- [移动云云空间-ec](https://www.ecpan.cn/web)
- [小飞机网盘-fj](https://www.feijipan.com/)
- [亿方云-fc](https://www.fangcloud.com/)
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用,需登录认证,公共/云端服务器风控严格建议自行部署(Windows 可直接用 run.bat 一键运行)
- [123云盘-ye](https://www.123pan.com/) ⚠️仅建议本地部署使用。公共云解析没有共享 123 账号;下载返回 5112 / 请登录后下载时,在本机配置 `auths.ye.authorization`(Windows 可直接用 run.bat)
- ~[115网盘(失效)-p115](https://115.com/)~
- [文叔叔-ws](https://www.wenshushu.cn/)
- [联想乐云-le](https://lecloud.lenovo.com/)
@@ -160,7 +160,7 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
### 认证参数(v0.2.1+)
[可以使用在线认证参数加密](https://qaiu.top/nfd-auth.html)
部分网盘(如夸克、UC)需要登录后的 Cookie 才能解析和下载。可通过 `auth` 参数传递认证信息:
部分网盘需要登录后的 Cookie 或 Authorization 才能下载。可通过 `auth` 参数传递认证信息:
**参数格式**:`auth` 参数值为 AES 加密后的 JSON 字符串,经过 Base64 编码和 URL 编码
@@ -184,11 +184,11 @@ GET /json/getFileList?url={分享链接}&pwd={密码}
**网盘认证要求**:
| 网盘 | 认证要求 | 说明 |
|------|---------|------|
| 夸克网盘(QK) | **必须** | 必须配置 Cookie 才能解析 |
| UC网盘(UC) | **必须** | 必须配置 Cookie 才能解析 |
| 夸克网盘(QK) | 小文件可免登录 | 小文件走访客直链。文件夹会先列目录再进入。超过访客大小限制(上游 23018,本次核对约 90MB 失败、约 12MB–27MB 成功)必须配置 `auths.qk.cookie` 或请求 `auth`。未配置 Cookie 时不会转存 |
| UC网盘(UC) | **必须** | 不能免登录,必须配置 Cookie |
| 小飞机网盘(FJ) | 可选 | 大文件(>100MB)需要认证 |
| 蓝奏优享(IZ) | 可选 | 大文件需要认证 |
| 123网盘(YE) | 可选 | 需要下载大文件/需要登录的分享时才需要认证,支持账号密码或 token/authorization |
| 123网盘(YE) | 下载通常需要 | 分享列表经常可以不登录读取。下载接口常见 5112 / 请登录后下载 / 您需要注册登录或付费后下载,请在**本机**配置 `auths.ye.authorization`(与 `token` 等价)或账号密码。公共云解析没有共享 123 账号。约 10MB 小文件有时可以直链,大文件和风控必须用你自己的账号。短链:`ye:分享key@提取码`;`#` 片段会去掉;`yun.123pan.com`、`www.123pan.com`、`*.share.123pan.cn` 同一类 |
**使用示例**:
```
@@ -240,6 +240,8 @@ auths:
> ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。
>
> 若 123 云盘账号密码登录触发验证码或风控,解析会直接失败并提示改用 Authorization,不会尝试验证码。请改填上面的 `authorization` / `token`(浏览器 localStorage 的 `authorToken`,或请求头 `authorization` / Cookie `sso-token` 里的 JWT)。
>
> 公共解析站(例如别人搭的在线解析)不能替你登录 123。看到「请登录后下载」「5112」「您需要注册登录或付费后下载」时,把服务跑在自己的机器上并填入自己的 Authorization。这是 123 的登录和风控要求。链接写法:`/d/ye/分享key@提取码`;浏览器地址栏里 `#` 后面的片段不参与解析;`yun.123pan.com` 与 `www.123pan.com`、`数字.share.123pan.cn` 是同一种分享。
如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。
@@ -377,14 +379,19 @@ json返回数据格式示例:
| 移动云云空间(个人版) | √ | √(密码可忽略) | 5G(个人) | 不限大小 |
| 小飞机网盘 | √ | √ | 10G | 不限大小 |
| 360亿方云 | √ | √ | 100G(须实名) | 不限大小 |
| 123云盘 | √ | √ | 2T | 100G(>100M需要登录) |
| 123云盘 | 小文件有时可以 | √ | 2T | 大文件/风控需要本机登录 |
| 文叔叔 | √ | √ | 10G | 5GB |
| 永硕E盘 | √ | √(空间密码) | 视套餐 | 视套餐 |
| WPS云文档 | √ | X | 5G(免费) | 10M(免费)/2G(会员) |
| 夸克网盘 | x | √ | 10G | 不限大小 |
| 夸克网盘 | 小文件 | √ | 10G | 访客有大小限制(23018) |
| UC网盘 | x | √ | 10G | 不限大小 |
| 飞书云盘 | √ | X | 15G | 不限大小 |
免登录边界(阈值由网盘决定,不是本项目写死的):
- **夸克**:小文件可以不登录。本次核对约 12MB–27MB 的访客直链成功,约 90MB 返回 `23018`。分享根目录如果只是文件夹,解析会继续进入子目录。超过限制时配置 `auths.qk.cookie`。UC 不能免登录。百度大约 100MB 以内有直链,属于专业版,这次没有改解析器。
- **123**:分享目录经常可以不登录看。下载则经常要登录。请本机配置 Authorization,不要依赖公共云解析的共享账号。约 10MB 的小文件有时能直链。
# 打包部署
## JDK下载(lz.qaiu.top提供直链云解析服务)
@@ -520,7 +527,7 @@ nfd-proxy搭建http代理服务器
参考https://github.com/nfd-parser/nfd-proxy
### 认证信息配置说明
部分网盘(如123)解析大文件时需要登录认证,可以在配置文件中添加认证信息。
夸克小文件可以不登录;大文件要本机 Cookie。123 的下载在公共解析站上基本都要你自己的 Authorization,云端没有共用账号。
修改配置文件:
app-dev.yml
@@ -528,14 +535,16 @@ app-dev.yml
```yaml
### 解析认证相关
auths:
# 123:配置用户名密码
# 夸克大文件:auths.qk.cookie
# qk:
# cookie: 浏览器里夸克网盘的 Cookie
# 123:优先填 authorization。账号密码若触发验证码会失败,本项目不处理验证码。
ye:
username: 你的用户名
password: 你的密码
# authorization: 浏览器登录后的 authorToken / sso-token
```
**注意:** 目前仅支持 123(ye)的认证配置。
**技术栈:**
Jdk17+Vert.x4
+3 -2
View File
@@ -24,7 +24,7 @@
### 认证参数说明(v0.2.1+)
部分网盘(如夸克QK、UC网盘)需要登录后的 Cookie 才能解析。`auth` 参数用于传递认证信息:
部分网盘需要登录后的 Cookie 或 Authorization 才能下载。夸克小文件可以不登录;UC 必须有 Cookie。`auth` 参数用于传递认证信息:
**加密方式**:
- 算法:AES/ECB/PKCS5Padding
@@ -42,8 +42,9 @@
**网盘认证要求**:
| 网盘 | 认证要求 |
|------|---------|
| 夸克网盘(QK) | **必须** |
| 夸克网盘(QK) | 小文件可免登录;超过访客大小限制(23018)需要 Cookie |
| UC网盘(UC) | **必须** |
| 123网盘(YE) | 下载通常需要本机 `auths.ye.authorization`;公共云解析没有共享账号 |
| 小飞机网盘(FJ) | 大文件需要 |
| 蓝奏优享(IZ) | 大文件需要 |
@@ -232,7 +232,7 @@ public enum PanDomainTemplate {
"123635\\.com|" +
"123242\\.com|" +
"123795\\.com" +
")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?<KEY>[a-zA-Z0-9]+-[a-zA-Z0-9]+|[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
@@ -22,6 +22,7 @@ import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -49,6 +50,22 @@ public class QkTool extends PanBase {
// __puus 有效期,默认 55 分钟(服务器实际 1 小时过期,提前 5 分钟刷新)
private static final long PUUS_TTL_MS = 55 * 60 * 1000L;
/** 根目录经常只有一层文件夹,继续向下找文件。#275 这类分享要再进一层。 */
static final int MAX_FOLDER_DEPTH = 4;
/** 同一层最多进入的文件夹数,避免把整棵目录树都请求一遍。 */
static final int MAX_FOLDERS_PER_LEVEL = 12;
/** 访客直链失败(23018)后,按体积从小到大再试几个文件。 */
static final int MAX_GUEST_DOWNLOAD_ATTEMPTS = 6;
static final String EMPTY_SHARE_MSG = "分享目录为空,或只包含空文件夹。解析已经进入子目录查找。";
static final String GUEST_OVERSIZE_MSG =
"夸克访客只能免登录下载小文件,当前文件超过访客大小限制(上游 23018 download file size limit)。"
+ "请在本机配置夸克 Cookie:auths.qk.cookie,或在解析请求中携带 auth。"
+ "未配置 Cookie 时不会转存。大文件需要绑定账号后交给下载器,浏览器里直接打开也经常失败。";
static final String GUEST_LOGIN_MSG =
"夸克要求登录后才能下载。请配置 Cookie(auths.qk.cookie,或请求参数 auth)。"
+ "小文件可以免登录直链;这个报错表示当前文件需要账号,请到 Cookie/auth 配置里补登录态。";
// 静态变量:缓存已转存的文件 (分享key -> 转存文件ID)
private static final Map<String, String> savedFileCache = new java.util.concurrent.ConcurrentHashMap<>();
@@ -216,8 +233,8 @@ public class QkTool extends PanBase {
log.debug("第一阶段响应: {}", res.bodyAsString());
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 0) {
fail(TOKEN_URL + " 返回异常: " + resJson);
if (resJson.getInteger("code") == null || resJson.getInteger("code") != 0) {
fail(quarkApiFailure(TOKEN_URL, resJson));
return;
}
@@ -229,103 +246,301 @@ public class QkTool extends PanBase {
log.debug("成功获取 stoken: {}", stoken);
// 第二步:获取文件列表
// 根目录可能只是文件夹(#282)。先列出再进入,找到文件后再取直链。
findDownloadableFiles(pwdId, stoken, "0", 0)
.compose(files -> resolveListedFiles(files, pwdId, stoken))
.onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null)))
.onFailure(t -> fail(t.getMessage() == null ? "解析失败" : t.getMessage()));
})
.onFailure(handleFail(TOKEN_URL));
return promise.future();
}
private Future<String> resolveListedFiles(List<JsonObject> files, String pwdId, String stoken) {
if (files == null || files.isEmpty()) {
return Future.failedFuture(EMPTY_SHARE_MSG);
}
if (hasQuarkCookie()) {
JsonObject first = files.get(0);
rememberFile(first);
return resolveDownloadUrl(first.getString("fid"), pwdId, stoken, first.getString("share_fid_token"));
}
return resolveGuestDownloads(orderGuestCandidates(files), pwdId, stoken, 0, 0);
}
/**
* 访客没有 Cookie,不能转存。先试接口排序下的第一个文件;
* 若夸克返回 23018,再按体积从小到大试同目录里的其他文件。
*/
private Future<String> resolveGuestDownloads(List<JsonObject> files, String pwdId, String stoken,
int index, int attempts) {
if (index >= files.size() || attempts >= MAX_GUEST_DOWNLOAD_ATTEMPTS) {
return Future.failedFuture(GUEST_OVERSIZE_MSG);
}
JsonObject file = files.get(index);
String fid = file.getString("fid");
if (fid == null || fid.isEmpty()) {
return resolveGuestDownloads(files, pwdId, stoken, index + 1, attempts);
}
return getShareDownloadLink(fid, pwdId, stoken, file.getString("share_fid_token"))
.compose(url -> {
rememberFile(file);
return Future.succeededFuture(url);
})
.recover(err -> {
if (needsTransferFallback(err)) {
log.info("夸克访客跳过超过大小限制的文件 {} ({} bytes)",
file.getString("file_name"), fileSize(file));
return resolveGuestDownloads(files, pwdId, stoken, index + 1, attempts + 1);
}
if (isGuestAuthError(err)) {
return Future.failedFuture(GUEST_LOGIN_MSG);
}
return Future.failedFuture(err);
});
}
private Future<List<JsonObject>> findDownloadableFiles(String pwdId, String stoken, String pdirFid, int depth) {
return fetchShareList(pwdId, stoken, pdirFid).compose(list -> {
List<JsonObject> files = new ArrayList<>();
List<JsonObject> folders = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
JsonObject item = list.getJsonObject(i);
if (isQuarkFile(item)) {
files.add(item);
} else if (isQuarkFolder(item)) {
folders.add(item);
}
}
if (hasPositiveSize(files) || folders.isEmpty() || depth >= MAX_FOLDER_DEPTH) {
return Future.succeededFuture(preferSizedFiles(files));
}
int limit = Math.min(folders.size(), MAX_FOLDERS_PER_LEVEL);
List<JsonObject> placeholders = files;
return firstFolderWithFiles(pwdId, stoken, folders.subList(0, limit), 0, depth + 1)
.compose(nested -> {
if (nested != null && !nested.isEmpty()) {
return Future.succeededFuture(nested);
}
return Future.succeededFuture(placeholders);
});
});
}
private Future<List<JsonObject>> firstFolderWithFiles(String pwdId, String stoken, List<JsonObject> folders,
int index, int depth) {
if (index >= folders.size()) {
return Future.succeededFuture(new ArrayList<>());
}
JsonObject folder = folders.get(index);
String fid = folder.getString("fid");
if (fid == null || fid.isEmpty()) {
return firstFolderWithFiles(pwdId, stoken, folders, index + 1, depth);
}
log.debug("夸克进入子目录 depth={} name={}", depth, folder.getString("file_name"));
return findDownloadableFiles(pwdId, stoken, fid, depth).compose(files -> {
if (hasPositiveSize(files)) {
return Future.succeededFuture(files);
}
return firstFolderWithFiles(pwdId, stoken, folders, index + 1, depth).compose(later -> {
if (later != null && !later.isEmpty()) {
return Future.succeededFuture(later);
}
return Future.succeededFuture(files == null ? new ArrayList<>() : files);
});
});
}
private Future<JsonArray> fetchShareList(String pwdId, String stoken, String pdirFid) {
Promise<JsonArray> listPromise = Promise.promise();
client.getAbs(DETAIL_URL)
.addQueryParam("pr", "ucpro")
.addQueryParam("fr", "pc")
.addQueryParam("pwd_id", pwdId)
.addQueryParam("stoken", stoken)
.addQueryParam("pdir_fid", "0")
.addQueryParam("pdir_fid", pdirFid == null || pdirFid.isEmpty() ? "0" : pdirFid)
.addQueryParam("force", "0")
.addQueryParam("_page", "1")
.addQueryParam("_size", "50")
.addQueryParam("_fetch_banner", "1")
.addQueryParam("_fetch_banner", "0")
.addQueryParam("_fetch_share", "1")
.addQueryParam("_fetch_total", "1")
.addQueryParam("_sort", "file_type:asc,updated_at:desc")
.putHeaders(header)
.send()
.onSuccess(res2 -> {
log.debug("第二阶段响应: {}", res2.bodyAsString());
JsonObject resJson2 = asJson(res2);
if (resJson2.getInteger("code") != 0) {
fail(DETAIL_URL + " 返回异常: " + resJson2);
.onSuccess(res -> {
JsonObject json = asJson(res);
if (json.getInteger("code") == null || json.getInteger("code") != 0) {
listPromise.fail(quarkApiFailure(DETAIL_URL, json));
return;
}
JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list");
if (fileList == null || fileList.isEmpty()) {
fail("未找到文件");
return;
JsonObject data = json.getJsonObject("data");
JsonArray list = data == null ? null : data.getJsonArray("list");
listPromise.complete(list == null ? new JsonArray() : list);
})
.onFailure(t -> listPromise.fail("获取分享目录失败: " + t.getMessage()));
return listPromise.future();
}
// 过滤出文件(排除文件夹)
List<JsonObject> files = new ArrayList<>();
for (int i = 0; i < fileList.size(); i++) {
JsonObject item = fileList.getJsonObject(i);
// 判断是否为文件:file=true 或 obj_category 不为空
if (item.getBoolean("file", false) ||
(item.getString("obj_category") != null && !item.getString("obj_category").isEmpty())) {
files.add(item);
}
}
if (files.isEmpty()) {
fail("没有可下载的文件(可能都是文件夹)");
return;
}
log.debug("找到 {} 个文件", files.size());
// 提取第一个文件的信息并保存到 otherParam
private void rememberFile(JsonObject file) {
try {
JsonObject firstFile = files.get(0);
FileInfo fileInfo = new FileInfo();
fileInfo.setFileId(firstFile.getString("fid"))
.setFileName(firstFile.getString("file_name"))
.setSize(firstFile.getLong("size", 0L))
.setSizeStr(FileSizeConverter.convertToReadableSize(firstFile.getLong("size", 0L)))
.setFileType(firstFile.getBoolean("file", true) ? "file" : "folder")
.setCreateTime(formatEpochMs(firstFile.getLong("updated_at", 0L)))
.setUpdateTime(formatEpochMs(firstFile.getLong("updated_at", 0L)))
long size = fileSize(file);
if (size == Long.MAX_VALUE) {
size = 0L;
}
FileInfo fileInfo = new FileInfo()
.setFileId(file.getString("fid"))
.setFileName(file.getString("file_name"))
.setSize(size)
.setSizeStr(FileSizeConverter.convertToReadableSize(size))
.setFileType("file")
.setCreateTime(formatEpochMs(epochMs(file, "updated_at")))
.setUpdateTime(formatEpochMs(epochMs(file, "updated_at")))
.setPanType(shareLinkInfo.getType());
// 保存到 otherParam,供 CacheServiceImpl 使用
shareLinkInfo.getOtherParam().put("fileInfo", fileInfo);
log.debug("夸克提取文件信息: {}", fileInfo.getFileName());
} catch (Exception e) {
log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage());
}
}
// 提取文件ID列表
List<String> fileIds = new ArrayList<>();
private boolean hasQuarkCookie() {
String cookie = header.get(HttpHeaders.COOKIE);
return cookie != null && !cookie.isBlank();
}
/**
* 访客候选:保留接口返回的第一个文件,其余按体积从小到大。
* 这样小文件分享仍先下最新文件;文件夹里最新文件超限时,再改下同目录的小文件。
*/
static List<JsonObject> orderGuestCandidates(List<JsonObject> files) {
List<JsonObject> ordered = new ArrayList<>();
if (files == null || files.isEmpty()) {
return ordered;
}
ordered.add(files.get(0));
if (files.size() == 1) {
return ordered;
}
List<JsonObject> rest = new ArrayList<>(files.subList(1, files.size()));
rest.sort(Comparator.comparingLong(QkTool::fileSize));
ordered.addAll(rest);
return ordered;
}
static boolean isQuarkFolder(JsonObject item) {
if (item == null) {
return false;
}
if (quarkFlag(item, "dir")) {
return true;
}
Integer fileType = intValue(item, "file_type");
return fileType != null && fileType == 0 && !quarkFlag(item, "file");
}
static boolean isQuarkFile(JsonObject item) {
if (item == null || isQuarkFolder(item)) {
return false;
}
if (quarkFlag(item, "file")) {
return true;
}
Integer fileType = intValue(item, "file_type");
if (fileType != null && fileType > 0) {
return true;
}
String category = item.getString("obj_category");
return category != null && !category.isEmpty();
}
static boolean isGuestAuthError(Throwable t) {
String msg = t == null || t.getMessage() == null ? "" : t.getMessage();
return msg.contains("31001")
|| msg.contains("未登录")
|| msg.contains("require login")
|| msg.contains("Cookie 已失效");
}
static String quarkApiFailure(String url, JsonObject json) {
Integer code = json == null ? null : intValue(json, "code");
String message = json == null ? null : json.getString("message");
if ((code != null && code == 41010) || (message != null && message.contains("违规"))) {
return "夸克判定该分享涉及违规内容,无法解析(上游 code=" + code
+ (message == null ? "" : "," + message)
+ ")。请让分享者更换资源,本项目不能绕过网盘审核。";
}
return url + " 返回异常: " + json;
}
private static boolean quarkFlag(JsonObject item, String key) {
Object value = item.getValue(key);
if (value instanceof Boolean) {
return (Boolean) value;
}
if (value instanceof Number) {
return ((Number) value).intValue() != 0;
}
if (value instanceof String) {
return "true".equalsIgnoreCase((String) value) || "1".equals(value);
}
return false;
}
private static Integer intValue(JsonObject item, String key) {
Object value = item.getValue(key);
if (value instanceof Number) {
return ((Number) value).intValue();
}
return null;
}
static boolean hasPositiveSize(List<JsonObject> files) {
if (files == null) {
return false;
}
for (JsonObject file : files) {
String fid = file.getString("fid");
if (fid != null && !fid.isEmpty()) {
fileIds.add(fid);
long size = fileSize(file);
if (size > 0 && size != Long.MAX_VALUE) {
return true;
}
}
if (fileIds.isEmpty()) {
fail("无法提取文件ID");
return;
return false;
}
// 第三步:解析下载链接(先直链,必要时才转存)
JsonObject firstFileForDownload = files.get(0);
String firstFid = firstFileForDownload.getString("fid");
String firstShareFidToken = firstFileForDownload.getString("share_fid_token");
/** 同一目录里有正常文件时,忽略 0 字节占位文件。 */
static List<JsonObject> preferSizedFiles(List<JsonObject> files) {
List<JsonObject> sized = new ArrayList<>();
if (files == null) {
return sized;
}
for (JsonObject file : files) {
long size = fileSize(file);
if (size > 0 && size != Long.MAX_VALUE) {
sized.add(file);
}
}
return sized.isEmpty() ? new ArrayList<>(files) : sized;
}
resolveDownloadUrl(firstFid, pwdId, stoken, firstShareFidToken)
.onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null)))
.onFailure(handleFail("获取下载链接"));
static long fileSize(JsonObject item) {
if (item == null) {
return Long.MAX_VALUE;
}
Object value = item.getValue("size");
if (value instanceof Number) {
return ((Number) value).longValue();
}
return Long.MAX_VALUE;
}
}).onFailure(handleFail(DETAIL_URL));
})
.onFailure(handleFail(TOKEN_URL));
return promise.future();
private static long epochMs(JsonObject item, String key) {
Object value = item.getValue(key);
if (value instanceof Number) {
return ((Number) value).longValue();
}
return 0L;
}
/**
@@ -353,7 +568,7 @@ public class QkTool extends PanBase {
* 是否属于「必须转存」类错误。
* 典型:code 23018 download file size limit(分享直链有大小限制)。
*/
private static boolean needsTransferFallback(Throwable t) {
static boolean needsTransferFallback(Throwable t) {
String msg = t == null || t.getMessage() == null ? "" : t.getMessage();
return msg.contains("23018")
|| msg.contains("size limit")
@@ -538,8 +753,7 @@ public class QkTool extends PanBase {
.setUpdateTime(updatedAt)
.setPanType(shareLinkInfo.getType());
// 判断是否为文件:file=true 或 obj_category 不为空
if (isFile || (objCategory != null && !objCategory.isEmpty())) {
if (isQuarkFile(item)) {
// 文件
fileInfo.setFileType("file");
// 保存必要的参数用于后续下载
@@ -788,8 +1002,13 @@ public class QkTool extends PanBase {
.send()
.onSuccess(res -> {
JsonObject resJson = asJson(res);
if (resJson.getInteger("code") != 0) {
promise.fail("获取转存目录失败: " + resJson.getString("message"));
if (resJson.getInteger("code") == null || resJson.getInteger("code") != 0) {
String message = resJson.getString("message");
if (message != null && (message.contains("require login") || message.contains("guest"))) {
promise.fail(GUEST_LOGIN_MSG);
return;
}
promise.fail("获取转存目录失败: " + message);
return;
}
@@ -189,7 +189,12 @@ public class Ye2Tool extends PanBase {
.onSuccess(res -> {
JsonObject shareInfoJson = asJson(res);
if (shareInfoJson.getInteger("code") != 0) {
fail("获取分享信息失败: " + shareInfoJson.getString("message"));
String message = shareInfoJson.getString("message");
if (isYeLoginRequired(shareInfoJson.getInteger("code"), message)) {
fail(loginRequiredDownloadMessage(!"nologin".equals(token), message));
return;
}
fail("获取分享信息失败: " + message);
return;
}
@@ -206,7 +211,7 @@ public class Ye2Tool extends PanBase {
JsonObject fileInfo = data.getJsonArray("InfoList").getJsonObject(0);
if (token.equals("nologin")) {
fail("该分享需要登录才能下载,请配置认证信息");
fail(loginRequiredDownloadMessage(false, null));
return;
}
@@ -442,9 +447,47 @@ public class Ye2Tool extends PanBase {
.onFailure(err -> fail("文件夹打包下载接口失败: " + err.getMessage()));
}
/**
* 123 下载接口要求登录时的说明。分享列表本身经常可以匿名读取,
* 真正拦下载的是 5112 / 请登录后下载,需要调用方自己的 Authorization。
*/
static String loginRequiredDownloadMessage(boolean authConfigured, String upstream) {
String msg = authConfigured
? "123网盘仍要求登录或触发了风控。请在本机检查 auths.ye.authorization(与 auths.ye.token 等价)"
+ "或账号密码是否仍然有效。公共云解析没有共享 123 账号。"
+ "上游常见返回:5112、请登录后下载、您需要注册登录或付费后下载。"
: "123网盘这个分享需要登录后才能下载。请在本机部署,并配置 auths.ye.authorization"
+ "(与 auths.ye.token 等价;浏览器登录后的 authorToken、请求头 authorization 或 Cookie sso-token,不要重复带 Bearer),"
+ "或配置 auths.ye.username 与 password。公共云解析没有共享 123 账号,不能代登。"
+ "约 10MB 的小文件有时可以直链,大文件、文件夹打包以及 5112/请登录后下载必须使用你自己的 Authorization。"
+ "短链格式是 ye:分享key@提取码;链接里的 # 片段会去掉。"
+ "yun.123pan.com、www.123pan.com 和 *.share.123pan.cn 都是同一类分享。";
if (StringUtils.isNotBlank(upstream)) {
String brief = upstream.length() > 180 ? upstream.substring(0, 180) : upstream;
msg = msg + " 上游信息: " + brief;
}
return msg;
}
static boolean isYeLoginRequired(Integer code, String message) {
if (code != null && (code == 5112 || code == 401)) {
return true;
}
if (message == null) {
return false;
}
return message.contains("请登录") || message.contains("需要登录") || message.contains("注册登录");
}
private void handleDownloadUrlResponse(WebClient client, JsonObject downURLJson, String failPrefix) {
try {
if (downURLJson.getInteger("code") != 0) {
Integer code = downURLJson.getInteger("code");
if (code != 0) {
String message = downURLJson.getString("message");
if (isYeLoginRequired(code, message)) {
fail(loginRequiredDownloadMessage(true, message));
return;
}
fail(failPrefix + "返回值异常->" + downURLJson);
return;
}
@@ -546,27 +589,35 @@ public class Ye2Tool extends PanBase {
Future<String> tokenFuture = resolveTokenFuture();
String finalParentFileId = parentFileId;
tokenFuture.onSuccess(token -> {
if (token.equals("nologin")) {
promise.fail("该分享需要登录才能访问,请配置认证信息");
return;
// 分享列表接口可以不登录。下载才经常返回 5112。
boolean loggedIn = StringUtils.isNotEmpty(token) && !"nologin".equals(token);
if (loggedIn) {
header.set("Authorization", "Bearer " + token);
}
String normalizedShareKey = YeShareHostUtil.normalizeShareKey(shareKey);
String shareOrigin = resolveYeShareOrigin(normalizedShareKey);
String shareReferer = buildShareReferer(shareOrigin, normalizedShareKey, pwd);
client.getAbs(UriTemplate.of(GET_SHARE_INFO_URL))
HttpRequest<Buffer> request = 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", shareReferer)
.putHeader("Origin", shareOrigin)
.send().onSuccess(res -> {
.putHeader("Origin", shareOrigin);
if (loggedIn) {
request.putHeader("Authorization", "Bearer " + token);
}
request.send().onSuccess(res -> {
JsonObject response = asJson(res);
if (response.getInteger("code") != 0) {
promise.fail("API错误: " + response.getString("message"));
String message = response.getString("message");
if (isYeLoginRequired(response.getInteger("code"), message)) {
promise.fail(loginRequiredDownloadMessage(loggedIn, message));
return;
}
promise.fail("API错误: " + message);
return;
}
@@ -641,7 +692,7 @@ public class Ye2Tool extends PanBase {
tokenFuture.onSuccess(token -> {
if (token.equals("nologin")) {
fail("该分享需要登录才能下载,请配置认证信息");
fail(loginRequiredDownloadMessage(false, null));
return;
}
@@ -42,6 +42,10 @@ public final class YeShareHostUtil {
return "";
}
String key = shareKey.trim();
int hashIndex = key.indexOf('#');
if (hashIndex >= 0) {
key = key.substring(0, hashIndex);
}
int queryIndex = key.indexOf('?');
if (queryIndex >= 0) {
key = key.substring(0, queryIndex);
@@ -180,6 +180,19 @@ public class PanDomainTemplateTest {
assertEquals("1813382308", YeShareHostUtil.getNumericSubdomainIdByShareKey("lN7UVv-pbYJ"));
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("https://1813382308.mshare.123pan.cn/123pan/lN7UVv-pbYJ?pwd=abcd"));
Matcher hashUrl = yePattern.matcher("https://www.123pan.com/s/lN7UVv-pbYJ#/list/share");
assertTrue("YE should match URL with hash fragment", hashUrl.matches());
assertEquals("lN7UVv-pbYJ", hashUrl.group("KEY"));
String fakeQuery = "ref" + "=" + "example";
Matcher yunUrl = yePattern.matcher("https://yun.123pan.com/s/lN7UVv-pbYJ?" + fakeQuery);
assertTrue("YE should match yun.123pan.com", yunUrl.matches());
assertEquals("lN7UVv-pbYJ", yunUrl.group("KEY"));
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey("lN7UVv-pbYJ#/list/share"));
assertEquals("lN7UVv-pbYJ", YeShareHostUtil.normalizeShareKey(
"https://yun.123pan.com/s/lN7UVv-pbYJ?" + fakeQuery + "#/list"));
}
@Test
@@ -0,0 +1,52 @@
package cn.qaiu.parser.impl;
import cn.qaiu.WebClientVertxInit;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import io.vertx.core.Vertx;
import org.junit.Assume;
import org.junit.Test;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
/**
* 对 Issue #282 的分享做一次真实访客解析。
* 默认不跑:父 POM surefire skipTests=true,且本测试还要求 -Dquark.live=true。
* mvn -pl parser -DskipTests=false -Dquark.live=true -Dtest=QkToolGuestFolderLiveTest test
*/
public class QkToolGuestFolderLiveTest {
@Test
public void issue282FolderShareResolvesAnonymously() throws Exception {
Assume.assumeTrue("set -Dquark.live=true to call Quark", Boolean.getBoolean("quark.live"));
Vertx vertx = Vertx.vertx();
WebClientVertxInit.init(vertx);
QkTool tool = null;
try {
ShareLinkInfo info = ShareLinkInfo.newBuilder()
.type("qk")
.panName("夸克网盘")
.shareKey("d34c18e9d62a")
.shareUrl("https://pan.quark.cn/s/d34c18e9d62a")
.build();
tool = new QkTool(info);
String url = tool.parse().toCompletionStage().toCompletableFuture().get(90, TimeUnit.SECONDS);
assertNotNull(url);
assertTrue(url.startsWith("http"));
assertTrue("guest download host, got " + url, url.contains("dl-guest"));
FileInfo fileInfo = (FileInfo) info.getOtherParam().get("fileInfo");
assertNotNull(fileInfo);
assertNotNull(fileInfo.getFileName());
assertTrue("expected a small file, got " + fileInfo.getSize() + " " + fileInfo.getFileName(),
fileInfo.getSize() > 0 && fileInfo.getSize() < 40L * 1024 * 1024);
} finally {
if (tool != null) {
tool.close();
}
vertx.close().toCompletionStage().toCompletableFuture().get(15, TimeUnit.SECONDS);
}
}
}
@@ -0,0 +1,112 @@
package cn.qaiu.parser.impl;
import io.vertx.core.json.JsonObject;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* 夸克目录项分类与访客选文件。不访问网络。
* 父 POM 的 surefire 默认 skipTests=true,本地验证用:
* mvn -pl parser -DskipTests=false -Dtest=QkToolShareListTest test
*/
public class QkToolShareListTest {
@Test
public void folderIsNotADownloadableFile() {
JsonObject folder = new JsonObject()
.put("file", false)
.put("dir", true)
.put("file_type", 0)
.put("file_name", "BiliTools最新版");
assertTrue(QkTool.isQuarkFolder(folder));
assertFalse(QkTool.isQuarkFile(folder));
}
@Test
public void archiveAndInstallerCountAsFiles() {
JsonObject rar = new JsonObject()
.put("file", true)
.put("dir", false)
.put("file_type", 1)
.put("obj_category", "archive")
.put("size", 8228);
JsonObject exe = new JsonObject()
.put("file", true)
.put("dir", false)
.put("file_type", 1)
.put("obj_category", "default")
.put("size", 11919276);
assertTrue(QkTool.isQuarkFile(rar));
assertTrue(QkTool.isQuarkFile(exe));
assertFalse(QkTool.isQuarkFolder(rar));
}
@Test
public void directoryFlagWinsOverCategory() {
JsonObject odd = new JsonObject()
.put("file", false)
.put("dir", true)
.put("file_type", 0)
.put("obj_category", "default");
assertTrue(QkTool.isQuarkFolder(odd));
assertFalse(QkTool.isQuarkFile(odd));
}
@Test
public void guestOrderKeepsFirstFileThenSortsTheRestBySize() {
JsonObject large = file("large", 90_000_000L);
JsonObject mid = file("mid", 20_000_000L);
JsonObject small = file("small", 8_000L);
List<JsonObject> ordered = QkTool.orderGuestCandidates(List.of(large, mid, small));
assertEquals("large", ordered.get(0).getString("file_name"));
assertEquals("small", ordered.get(1).getString("file_name"));
assertEquals("mid", ordered.get(2).getString("file_name"));
}
@Test
public void sizeLimitIsTransferFallbackAndLoginIsNot() {
assertTrue(QkTool.needsTransferFallback(new RuntimeException(
"分享直链获取失败(code=23018): download file size limit")));
assertFalse(QkTool.needsTransferFallback(new RuntimeException("未登录或 Cookie 已失效")));
assertTrue(QkTool.isGuestAuthError(new RuntimeException("code=31001 未登录")));
assertFalse(QkTool.isGuestAuthError(new RuntimeException("code=23018 size limit")));
}
@Test
public void violationCodeIsExplainedWithoutFolderWording() {
JsonObject json = new JsonObject().put("code", 41010).put("message", "文件涉及违规内容");
String msg = QkTool.quarkApiFailure("https://drive-pc.quark.cn/token", json);
assertTrue(msg.contains("违规"));
assertTrue(msg.contains("41010"));
assertFalse(msg.contains("都是文件夹"));
}
@Test
public void zeroBytePlaceholderIsDroppedWhenARealFileExists() {
JsonObject placeholder = file("等待更新", 0L);
JsonObject real = file("notes.rar", 8228L);
List<JsonObject> kept = QkTool.preferSizedFiles(List.of(placeholder, real));
assertEquals(1, kept.size());
assertEquals("notes.rar", kept.get(0).getString("file_name"));
assertTrue(QkTool.hasPositiveSize(List.of(real)));
assertFalse(QkTool.hasPositiveSize(List.of(placeholder)));
assertEquals(1, QkTool.preferSizedFiles(List.of(placeholder)).size());
}
@Test
public void oversizeHintPointsAtCookie() {
assertTrue(QkTool.GUEST_OVERSIZE_MSG.contains("auths.qk.cookie"));
assertTrue(QkTool.GUEST_OVERSIZE_MSG.contains("23018"));
assertFalse(QkTool.GUEST_OVERSIZE_MSG.contains("都是文件夹"));
assertTrue(QkTool.GUEST_LOGIN_MSG.contains("auths.qk.cookie"));
}
private static JsonObject file(String name, long size) {
return new JsonObject().put("file_name", name).put("size", size).put("file", true);
}
}
@@ -0,0 +1,40 @@
package cn.qaiu.parser.impl;
import org.junit.Test;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* 123 登录提示。不访问网络。
* mvn -pl parser -DskipTests=false -Dtest=Ye2LoginHintTest test
*/
public class Ye2LoginHintTest {
@Test
public void loginCodesAndMessagesAreRecognized() {
assertTrue(Ye2Tool.isYeLoginRequired(5112, "您需要注册登录或付费后下载"));
assertTrue(Ye2Tool.isYeLoginRequired(401, "请登录后下载"));
assertTrue(Ye2Tool.isYeLoginRequired(0, "需要登录才能下载"));
assertFalse(Ye2Tool.isYeLoginRequired(0, "ok"));
assertFalse(Ye2Tool.isYeLoginRequired(null, null));
}
@Test
public void missingAuthTellsUserToConfigureLocalAuthorization() {
String msg = Ye2Tool.loginRequiredDownloadMessage(false, null);
assertTrue(msg.contains("auths.ye.authorization"));
assertTrue(msg.contains("5112"));
assertTrue(msg.contains("公共云解析"));
assertTrue(msg.contains("ye:分享key@提取码"));
assertFalse(msg.contains("验证码求解"));
}
@Test
public void configuredAuthStillPointsAtLocalTokenWhenUpstreamRejects() {
String msg = Ye2Tool.loginRequiredDownloadMessage(true, "请登录后下载");
assertTrue(msg.contains("auths.ye.authorization"));
assertTrue(msg.contains("上游信息: 请登录后下载"));
assertFalse(msg.contains("{"));
}
}
+8 -1
View File
@@ -134,7 +134,14 @@ proxy:
### 解析认证相关
auths:
# 123网盘:配置用户名密码
# 夸克:小文件可免登录。大文件(访客 23018)再填本机 Cookie。空值等于没配。
# 公共解析站没有共用夸克账号。
# qk:
# cookie:
# 123:公共云解析不能共用账号。优先填 authorization(浏览器 authorToken / sso-token,不要带 Bearer)。
# 账号密码触发验证码时会失败并提示改用 Authorization,本项目不处理验证码。
# 5112 / 请登录后下载 / 您需要注册登录或付费后下载 是上游要求登录。
ye:
username:
password:
# authorization: