From 98a043daf9aa24d5f0eacef738fb13b42bc5a71e Mon Sep 17 00:00:00 2001 From: qaiu Date: Sat, 31 Oct 2026 07:45:46 +0800 Subject: [PATCH] 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 --- README.md | 31 +- parser/doc/API_USAGE.md | 5 +- .../cn/qaiu/parser/PanDomainTemplate.java | 2 +- .../main/java/cn/qaiu/parser/impl/QkTool.java | 419 +++++++++++++----- .../java/cn/qaiu/parser/impl/Ye2Tool.java | 75 +++- .../java/cn/qaiu/util/YeShareHostUtil.java | 4 + .../cn/qaiu/parser/PanDomainTemplateTest.java | 13 + .../impl/QkToolGuestFolderLiveTest.java | 52 +++ .../qaiu/parser/impl/QkToolShareListTest.java | 112 +++++ .../cn/qaiu/parser/impl/Ye2LoginHintTest.java | 40 ++ web-service/src/main/resources/app-dev.yml | 9 +- 11 files changed, 635 insertions(+), 127 deletions(-) create mode 100644 parser/src/test/java/cn/qaiu/parser/impl/QkToolGuestFolderLiveTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/impl/QkToolShareListTest.java create mode 100644 parser/src/test/java/cn/qaiu/parser/impl/Ye2LoginHintTest.java diff --git a/README.md b/README.md index c8cd70d..4c3277b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/parser/doc/API_USAGE.md b/parser/doc/API_USAGE.md index 6d8c2f9..749facf 100644 --- a/parser/doc/API_USAGE.md +++ b/parser/doc/API_USAGE.md @@ -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) | 大文件需要 | diff --git a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java index 220c973..7e6fca9 100644 --- a/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java +++ b/parser/src/main/java/cn/qaiu/parser/PanDomainTemplate.java @@ -232,7 +232,7 @@ public enum PanDomainTemplate { "123635\\.com|" + "123242\\.com|" + "123795\\.com" + - ")/(?:(?:s|123pan)/|(?:[^/?#]+/)+)?(?[a-zA-Z0-9]+-[a-zA-Z0-9]+|[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/impl/QkTool.java b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java index 4d5c24f..29f0b71 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/QkTool.java @@ -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; @@ -48,6 +49,22 @@ public class QkTool extends PanBase { // 多账号隔离:用 TokenCache 代替 static cookie 缓存 // __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 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; } @@ -228,106 +245,304 @@ public class QkTool extends PanBase { } log.debug("成功获取 stoken: {}", stoken); - - // 第二步:获取文件列表 - client.getAbs(DETAIL_URL) - .addQueryParam("pr", "ucpro") - .addQueryParam("fr", "pc") - .addQueryParam("pwd_id", pwdId) - .addQueryParam("stoken", stoken) - .addQueryParam("pdir_fid", "0") - .addQueryParam("force", "0") - .addQueryParam("_page", "1") - .addQueryParam("_size", "50") - .addQueryParam("_fetch_banner", "1") - .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); - return; - } - - JsonArray fileList = resJson2.getJsonObject("data").getJsonArray("list"); - if (fileList == null || fileList.isEmpty()) { - fail("未找到文件"); - return; - } - - // 过滤出文件(排除文件夹) - List 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 - 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))) - .setPanType(shareLinkInfo.getType()); - - // 保存到 otherParam,供 CacheServiceImpl 使用 - shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); - log.debug("夸克提取文件信息: {}", fileInfo.getFileName()); - } catch (Exception e) { - log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage()); - } - - // 提取文件ID列表 - List fileIds = new ArrayList<>(); - for (JsonObject file : files) { - String fid = file.getString("fid"); - if (fid != null && !fid.isEmpty()) { - fileIds.add(fid); - } - } - - if (fileIds.isEmpty()) { - fail("无法提取文件ID"); - return; - } - - // 第三步:解析下载链接(先直链,必要时才转存) - JsonObject firstFileForDownload = files.get(0); - String firstFid = firstFileForDownload.getString("fid"); - String firstShareFidToken = firstFileForDownload.getString("share_fid_token"); - resolveDownloadUrl(firstFid, pwdId, stoken, firstShareFidToken) - .onSuccess(downloadUrl -> completeWithMeta(downloadUrl, buildDownloadHeaders(null))) - .onFailure(handleFail("获取下载链接")); - - }).onFailure(handleFail(DETAIL_URL)); + // 根目录可能只是文件夹(#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 resolveListedFiles(List 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 resolveGuestDownloads(List 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> findDownloadableFiles(String pwdId, String stoken, String pdirFid, int depth) { + return fetchShareList(pwdId, stoken, pdirFid).compose(list -> { + List files = new ArrayList<>(); + List 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 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> firstFolderWithFiles(String pwdId, String stoken, List 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 fetchShareList(String pwdId, String stoken, String pdirFid) { + Promise listPromise = Promise.promise(); + client.getAbs(DETAIL_URL) + .addQueryParam("pr", "ucpro") + .addQueryParam("fr", "pc") + .addQueryParam("pwd_id", pwdId) + .addQueryParam("stoken", stoken) + .addQueryParam("pdir_fid", pdirFid == null || pdirFid.isEmpty() ? "0" : pdirFid) + .addQueryParam("force", "0") + .addQueryParam("_page", "1") + .addQueryParam("_size", "50") + .addQueryParam("_fetch_banner", "0") + .addQueryParam("_fetch_share", "1") + .addQueryParam("_fetch_total", "1") + .addQueryParam("_sort", "file_type:asc,updated_at:desc") + .putHeaders(header) + .send() + .onSuccess(res -> { + JsonObject json = asJson(res); + if (json.getInteger("code") == null || json.getInteger("code") != 0) { + listPromise.fail(quarkApiFailure(DETAIL_URL, json)); + 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(); + } + + private void rememberFile(JsonObject file) { + try { + 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()); + shareLinkInfo.getOtherParam().put("fileInfo", fileInfo); + log.debug("夸克提取文件信息: {}", fileInfo.getFileName()); + } catch (Exception e) { + log.warn("夸克提取文件信息失败,继续解析: {}", e.getMessage()); + } + } + + private boolean hasQuarkCookie() { + String cookie = header.get(HttpHeaders.COOKIE); + return cookie != null && !cookie.isBlank(); + } + + /** + * 访客候选:保留接口返回的第一个文件,其余按体积从小到大。 + * 这样小文件分享仍先下最新文件;文件夹里最新文件超限时,再改下同目录的小文件。 + */ + static List orderGuestCandidates(List files) { + List ordered = new ArrayList<>(); + if (files == null || files.isEmpty()) { + return ordered; + } + ordered.add(files.get(0)); + if (files.size() == 1) { + return ordered; + } + List 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 files) { + if (files == null) { + return false; + } + for (JsonObject file : files) { + long size = fileSize(file); + if (size > 0 && size != Long.MAX_VALUE) { + return true; + } + } + return false; + } + + /** 同一目录里有正常文件时,忽略 0 字节占位文件。 */ + static List preferSizedFiles(List files) { + List 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; + } + + 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; + } + + private static long epochMs(JsonObject item, String key) { + Object value = item.getValue(key); + if (value instanceof Number) { + return ((Number) value).longValue(); + } + return 0L; + } + /** * 统一下载入口(parse / parseById 共用): *
    @@ -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; } 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 965acf3..3f99697 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java @@ -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 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 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; } diff --git a/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java b/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java index 9c5d2f4..7a47769 100644 --- a/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java +++ b/parser/src/main/java/cn/qaiu/util/YeShareHostUtil.java @@ -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); diff --git a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java index d0eb78c..cad23e4 100644 --- a/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java +++ b/parser/src/test/java/cn/qaiu/parser/PanDomainTemplateTest.java @@ -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 diff --git a/parser/src/test/java/cn/qaiu/parser/impl/QkToolGuestFolderLiveTest.java b/parser/src/test/java/cn/qaiu/parser/impl/QkToolGuestFolderLiveTest.java new file mode 100644 index 0000000..b4f3ae3 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/QkToolGuestFolderLiveTest.java @@ -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); + } + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/impl/QkToolShareListTest.java b/parser/src/test/java/cn/qaiu/parser/impl/QkToolShareListTest.java new file mode 100644 index 0000000..20249fb --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/QkToolShareListTest.java @@ -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 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 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); + } +} diff --git a/parser/src/test/java/cn/qaiu/parser/impl/Ye2LoginHintTest.java b/parser/src/test/java/cn/qaiu/parser/impl/Ye2LoginHintTest.java new file mode 100644 index 0000000..65067f0 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/impl/Ye2LoginHintTest.java @@ -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("{")); + } +} diff --git a/web-service/src/main/resources/app-dev.yml b/web-service/src/main/resources/app-dev.yml index 4a7f80c..0730044 100644 --- a/web-service/src/main/resources/app-dev.yml +++ b/web-service/src/main/resources/app-dev.yml @@ -134,7 +134,14 @@ proxy: ### 解析认证相关 auths: - # 123网盘:配置用户名密码 + # 夸克:小文件可免登录。大文件(访客 23018)再填本机 Cookie。空值等于没配。 + # 公共解析站没有共用夸克账号。 + # qk: + # cookie: + # 123:公共云解析不能共用账号。优先填 authorization(浏览器 authorToken / sso-token,不要带 Bearer)。 + # 账号密码触发验证码时会失败并提示改用 Authorization,本项目不处理验证码。 + # 5112 / 请登录后下载 / 您需要注册登录或付费后下载 是上游要求登录。 ye: username: password: + # authorization: