Compare commits

..
Author SHA1 Message Date
Cursor Agentandqaiu dd092e50dd fix(qk): resolve folder shares and guest small-file downloads
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: qaiu <[email protected]>
2026-10-30 23:41:37 +00:00
qaiu 12d0f6bfbb Merge pull request #233 from qaiu/cursor/ye-password-risk-auth-hint-140f
fix: 123 云盘账号密码登录触发验证码/风控时引导配置 Authorization
2026-10-31 07:06:12 +08:00
qaiu 9d2fd09331 Merge branch 'main' into cursor/ye-password-risk-auth-hint-140f 2026-10-31 07:03:25 +08:00
qaiu e9be9b3345 Merge pull request #259 from qaiu/fix/iz-auth-fallback
fix(iz): 蓝奏优享登录失败回退免登录 + release 0.4.8
2026-10-14 04:53:24 +08:00
qaiu 6acb774c87 release: 0.4.8 Youxiang auth fallback and configurable XFF 2026-10-14 04:38:00 +08:00
qaiu 511515cae4 fix(iz): 蓝奏优享登录失败回退免登录、TokenCache 多账号、可配置 XFF
- IzTool.parse 预登录失败 recover 后继续免登录解析
- IzTool / IzToolWithAuth 登录或重登失败回退匿名下载(downloadAnonymous)
- IzTool 改用 TokenCache(按 _configId/username 隔离),校验 token 前恢复 appToken 头
- 新增 IzXffHeader:-Dnfd.iz.xff / NFD_IZ_XFF = off(默认)/random/固定IP
2026-10-14 04:38:00 +08:00
Cursor Agentandqaiu 1ab9df7826 fix: guide Authorization when 123 password login hits captcha
123 pan username/password sign-in that returns captcha or risk-control
now fails with a user-facing hint to configure Authorization (Bearer/JWT),
instead of the raw upstream message. Wrong-password and network errors stay
distinct. No captcha solving, QR login, or retries.

Co-authored-by: qaiu <[email protected]>
2026-09-27 22:08:16 +00:00
qaiu ab8b687837 Merge pull request #231 from qaiu/cursor/release-047-9e8b
release: v0.4.7
2026-09-25 16:35:29 +08:00
Cursor Agentandqaiu 7616bf3144 release: 0.4.7 Lanzou host rewrite and apifile ajax
Co-authored-by: qaiu <[email protected]>
2026-09-25 08:34:52 +00:00
qaiu 01d7956de5 Merge pull request #229 from qaiu/cursor/lanzou-skip-wwww-rewrite-d50d
fix(parser): 蓝奏个性域名保留原主机,文件直链走 apifile
2026-09-25 16:33:05 +08:00
Cursor Agentandqaiu 78e53c076e fix(parser): 蓝奏文件 ajax 优先使用页面里的 apifile 绝对地址
个性域名文件页把 ajaxfile.php 写到 apifile.lanzouw.com。相对路径打到页面源站会空响应,直链拿不到。绝对地址优先,apifile 放进文件 ajax 兜底且排在四 w 之前;目录列表仍不走 apifile。

Co-authored-by: qaiu <[email protected]>
2026-09-25 08:27:58 +00:00
Cursor Agentandqaiu 4d985cc96f fix(parser): 蓝奏个性域名不再强制改写到 wwww.lanzoux.com
a.lanzouw.com 的页面请求被改到四个 w 的主机后,部分出口会连接超时。
分享页 GET 保留原主机;ajax/filemore 再按 w1、www、wwww 顺序回退。

Co-authored-by: qaiu <[email protected]>
2026-09-25 06:46:11 +00:00
24 changed files with 1806 additions and 308 deletions
+23 -12
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` 同一类 |
**使用示例**:
```
@@ -238,6 +238,10 @@ 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`),无需重启服务,仅本次请求生效。
@@ -375,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提供直链云解析服务)
@@ -461,7 +470,7 @@ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtow
> 注意: netdisk-fast-download.service中的ExecStart的路径改为实际路径
```shell
cd ~
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.6/netdisk-fast-download-linux-amd64.zip
wget -O netdisk-fast-download.zip https://github.com/qaiu/netdisk-fast-download/releases/download/v0.4.8/netdisk-fast-download-linux-amd64.zip
unzip netdisk-fast-download.zip
cd netdisk-fast-download
bash service-install.sh
@@ -518,7 +527,7 @@ nfd-proxy搭建http代理服务器
参考https://github.com/nfd-parser/nfd-proxy
### 认证信息配置说明
部分网盘(如123)解析大文件时需要登录认证,可以在配置文件中添加认证信息。
夸克小文件可以不登录;大文件要本机 Cookie。123 的下载在公共解析站上基本都要你自己的 Authorization,云端没有共用账号。
修改配置文件:
app-dev.yml
@@ -526,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) | 大文件需要 |
@@ -109,6 +109,8 @@ URL解码 → Base64解码 → AES解密 → JSON对象
}
```
> 123云盘(`ye`)账号密码登录如果返回验证码或风控,解析会直接失败,并提示改用 Authorization,不会尝试验证码。请在浏览器登录后复制 `authorToken` / 请求头 `authorization` / Cookie `sso-token`,用 `authType=authorization` 或 `accesstoken` 传入,或写入 `app-dev.yml` 的 `auths.ye.authorization`。
#### 4. 自定义认证
```json
{
@@ -339,3 +341,4 @@ server:
## 更新日志
- **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型
- **2026-09-27**: 补充 123云盘账号密码登录触发验证码/风控时改用 Authorization 的说明
@@ -57,6 +57,8 @@
}
```
> 123云盘(`ye`)账号密码登录触发验证码/风控时,请改用 `authType=authorization` 或 `accesstoken`(`token` 填浏览器中的 Bearer token / JWT),或在 `app-dev.yml` 配置 `auths.ye.authorization`。解析不会尝试验证码。
### 自定义认证
```json
{
@@ -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
@@ -0,0 +1,270 @@
package cn.qaiu.parser;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.apache.commons.lang3.StringUtils;
import java.util.Locale;
/**
* 账号密码登录撞上验证码/风控时的用户提示。
* 只识别失败形态并引导改用 Authorization,不实现验证码求解、扫码或重试。
*/
public final class PasswordLoginRisk {
/**
* 123 云盘(ye)账号密码登录被验证码/风控拦住时返回给调用方的固定说明。
* 上游 code/message 会另附在这句话后面,便于和“密码错误”区分。
*/
public static final String AUTHORIZATION_HINT =
"账号密码登录触发了验证码/风控,已停止用户名密码登录(不会尝试验证码)。"
+ "请改为配置 Authorization(浏览器登录后的 Bearer token / JWT,"
+ "例如 localStorage 的 authorToken、请求头 authorization 或 Cookie 的 sso-token)。"
+ "静态配置写在 app-dev.yml 的 auths.ye.authorization(与 auths.ye.token 等价);"
+ "临时请求使用 authType=authorization 或 authType=accesstoken,token 填该令牌(不要重复带 Bearer 前缀)。"
+ "说明见 parser/doc/auth-param/。";
/**
* 上游文案命中这些片段时视为验证码/风控,而不是单纯的密码错误。
* 依据公开客户端(AList/OpenList)实际展示过的 123 登录失败文案,以及登录页上的滑块/人机验证提示。
*/
private static final String[] RISK_KEYWORDS = {
"验证码",
"风控",
"安全风险",
"安全验证",
"安全校验",
"滑块",
"滑动验证",
"拼图验证",
"短信验证",
"微信登录",
"微信进行登录",
"境外登录",
"需要验证",
"请验证",
"请你验证",
"进行验证",
"完成验证",
"二次验证",
"人机验证",
"操作频繁",
"请求过于频繁",
"请求频繁",
"captcha",
"recaptcha",
"risk control"
};
private PasswordLoginRisk() {
}
public static boolean isLoginSuccess(JsonObject json) {
if (json == null) {
return false;
}
Integer code = readCode(json);
return code != null && code == 200 && StringUtils.isNotBlank(readToken(json));
}
public static boolean indicatesCaptchaOrRisk(JsonObject json) {
if (json == null) {
return false;
}
if (hasTruthyRiskField(json) || hasTruthyRiskField(dataObject(json))) {
return true;
}
return textIndicatesCaptchaOrRisk(collectMessageText(json));
}
public static boolean textIndicatesCaptchaOrRisk(String text) {
if (StringUtils.isBlank(text)) {
return false;
}
String lower = text.toLowerCase(Locale.ROOT);
for (String keyword : RISK_KEYWORDS) {
if (lower.contains(keyword.toLowerCase(Locale.ROOT))) {
return true;
}
}
return false;
}
/**
* 非成功登录响应的用户可见说明。验证码/风控走 Authorization 引导,其余保持“登录失败: 上游文案”。
*/
public static String describeLoginFailure(JsonObject json) {
if (json == null) {
return "登录响应格式异常";
}
if (indicatesCaptchaOrRisk(json)) {
return AUTHORIZATION_HINT + " 上游返回:" + upstreamSummary(json);
}
Integer code = readCode(json);
if (code != null && code == 200) {
return "未获取到token";
}
String message = firstMessage(json);
if (StringUtils.isBlank(message)) {
return "登录失败";
}
return "登录失败: " + message;
}
public static String nonJsonLoginFailure(String body) {
if (textIndicatesCaptchaOrRisk(body)) {
return AUTHORIZATION_HINT + " 上游返回:" + truncate(body, 300);
}
return "登录响应格式异常: " + body;
}
private static String collectMessageText(JsonObject json) {
StringBuilder text = new StringBuilder();
appendMessage(text, json);
JsonObject data = dataObject(json);
if (data != null) {
appendMessage(text, data);
}
return text.toString();
}
private static void appendMessage(StringBuilder text, JsonObject json) {
append(text, json.getString("message"));
append(text, json.getString("msg"));
}
private static void append(StringBuilder text, String value) {
if (StringUtils.isNotBlank(value)) {
if (text.length() > 0) {
text.append(' ');
}
text.append(value);
}
}
private static boolean hasTruthyRiskField(JsonObject obj) {
if (obj == null) {
return false;
}
for (String key : obj.fieldNames()) {
if (!isRiskFieldName(key)) {
continue;
}
if (isTruthyRiskValue(obj.getValue(key))) {
return true;
}
}
return false;
}
private static boolean isRiskFieldName(String key) {
String lower = key.toLowerCase(Locale.ROOT);
return lower.contains("captcha")
|| lower.contains("vcode")
|| lower.contains("risk")
|| "needverify".equals(lower)
|| "need_verify".equals(lower)
|| "slideverify".equals(lower)
|| "verifycode".equals(lower)
|| "verify_code".equals(lower);
}
private static boolean isTruthyRiskValue(Object value) {
if (value == null) {
return false;
}
if (value instanceof Boolean) {
return (Boolean) value;
}
if (value instanceof Number) {
return ((Number) value).doubleValue() != 0d;
}
if (value instanceof CharSequence) {
String text = value.toString().trim();
if (text.isEmpty()) {
return false;
}
String lower = text.toLowerCase(Locale.ROOT);
return !"false".equals(lower) && !"0".equals(lower) && !"null".equals(lower) && !"none".equals(lower);
}
if (value instanceof JsonObject) {
return !((JsonObject) value).isEmpty();
}
if (value instanceof JsonArray) {
return !((JsonArray) value).isEmpty();
}
return true;
}
private static String upstreamSummary(JsonObject json) {
StringBuilder summary = new StringBuilder();
Integer code = readCode(json);
if (code != null) {
summary.append("code=").append(code);
}
String message = firstMessage(json);
if (StringUtils.isNotBlank(message)) {
if (summary.length() > 0) {
summary.append(", ");
}
summary.append("message=").append(message);
}
if (summary.length() == 0) {
summary.append(json.encode());
}
return truncate(summary.toString(), 300);
}
private static String firstMessage(JsonObject json) {
String message = json.getString("message");
if (StringUtils.isBlank(message)) {
message = json.getString("msg");
}
if (StringUtils.isBlank(message)) {
JsonObject data = dataObject(json);
if (data != null) {
message = data.getString("message");
if (StringUtils.isBlank(message)) {
message = data.getString("msg");
}
}
}
return message;
}
static String readToken(JsonObject json) {
JsonObject data = dataObject(json);
if (data == null) {
return null;
}
Object token = data.getValue("token");
return token == null ? null : token.toString();
}
private static JsonObject dataObject(JsonObject json) {
Object data = json.getValue("data");
return data instanceof JsonObject ? (JsonObject) data : null;
}
static Integer readCode(JsonObject json) {
Object code = json.getValue("code");
if (code instanceof Number) {
return ((Number) code).intValue();
}
if (code instanceof String && StringUtils.isNumeric(((String) code).trim())) {
return Integer.parseInt(((String) code).trim());
}
return null;
}
private static String truncate(String value, int max) {
if (value == null) {
return "";
}
String trimmed = value.trim();
if (trimmed.length() <= max) {
return trimmed;
}
return trimmed.substring(0, max) + "...";
}
}
@@ -3,6 +3,7 @@ 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.AESUtils;
import cn.qaiu.util.AcwScV2Generator;
import cn.qaiu.util.CommonUtils;
@@ -85,12 +86,31 @@ public class IzTool extends PanBase {
}
public IzTool(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
IzXffHeader.apply(header);
String accountId = "_default";
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (auths.contains("_configId")) {
accountId = auths.get("_configId");
} else if (auths.contains("username")) {
accountId = auths.get("username");
}
}
this.cacheKey = TokenCache.key("iz", accountId);
}
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
public static volatile String token = null;
public static volatile boolean authFlag = true;
// 多账号隔离:用 TokenCache 代替 static 变量
private final String cacheKey;
private String token() {
return TokenCache.getToken(cacheKey);
}
private boolean authFlag() {
return TokenCache.getFlag(cacheKey, true);
}
public Future<String> parse() {
@@ -101,22 +121,21 @@ public class IzTool extends PanBase {
// 检查并输出认证状态
if (shareLinkInfo.getOtherParam().containsKey("auths")) {
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
String token = token();
log.info("文件解析检测到认证信息: isTempAuth={}, authFlag={}, token={}",
isTempAuth, authFlag, token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
isTempAuth, authFlag(), token != null ? "已登录(" + token.substring(0, Math.min(8, token.length())) + "...)" : "未登录");
// 如果需要认证但还没有token,先执行登录
if ((isTempAuth || authFlag) && token == null) {
if ((isTempAuth || authFlag()) && token == null) {
log.info("文件解析需要登录,开始执行登录流程...");
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
return login(tsEncode, auths)
.compose(v -> {
log.info("文件解析预登录成功,继续解析流程");
return parseWithAuth(shareId, tsEncode);
.recover(err -> {
// 预登录失败不终止解析,继续走免登录
log.warn("文件解析预登录失败: {},使用免登录模式", err.getMessage());
return Future.succeededFuture();
})
.onFailure(err -> {
log.warn("文件解析预登录失败: {},尝试使用免登录模式", err.getMessage());
// 登录失败,继续使用免登录模式
});
.compose(v -> parseWithAuth(shareId, tsEncode));
} else if (token != null) {
log.info("文件解析使用已有token: {}...", token.substring(0, Math.min(8, token.length())));
}
@@ -231,8 +250,8 @@ public class IzTool extends PanBase {
// 检查是否为临时认证(临时认证每次都尝试登录)
boolean isTempAuth = shareLinkInfo.getOtherParam().containsKey("__TEMP_AUTH_ADDED");
// 如果是临时认证,或者是后台配置且authFlag为true,则尝试使用认证
if (isTempAuth || authFlag) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag);
if (isTempAuth || authFlag()) {
log.debug("尝试使用认证信息解析, isTempAuth={}, authFlag={}", isTempAuth, authFlag());
HttpRequest<Buffer> httpRequest =
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL_VIP))
.setTemplateParam("fidEncode", fidEncode)
@@ -241,18 +260,19 @@ public class IzTool extends PanBase {
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey);
MultiMap auths = (MultiMap) shareLinkInfo.getOtherParam().get("auths");
if (token == null) {
// 执行登录
if (token() == null) {
// 执行登录,失败则回退免登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
httpRequest.setTemplateParam("appToken", token())
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
// 验证token
// 验证token — 先从缓存恢复到 header
header.set("appToken", token());
webClientSession.postAbs(UriTemplate.of(TOKEN_VERIFY_URL))
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
@@ -260,15 +280,15 @@ public class IzTool extends PanBase {
// log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
httpRequest.setTemplateParam("appToken", token())
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
});
} else {
httpRequest.setTemplateParam("appToken", token)
httpRequest.setTemplateParam("appToken", token())
.putHeaders(header);
httpRequest.send().onSuccess(this::down).onFailure(handleFail("请求2"));
}
@@ -277,29 +297,27 @@ public class IzTool extends PanBase {
} else {
// authFlag 为 false,使用免登录解析
log.debug("authFlag=false,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
} else {
// 没有认证信息,使用免登录解析
log.debug("无认证信息,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
}
private void downloadAnonymous(String fidEncode, String tsEncode2, String auth, String dataKey) {
log.debug("使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
}
private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise();
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
@@ -310,8 +328,13 @@ public class IzTool extends PanBase {
.onSuccess(res2->{
JsonObject json = asJson(res2);
if (json.getInteger("code") == 200) {
token = json.getJsonObject("data").getString("appToken");
log.info("登录成功 token: {}...", token != null ? token.substring(0, Math.min(8, token.length())) : "null");
String loginToken = json.getJsonObject("data").getString("appToken");
TokenCache.putToken(cacheKey, loginToken);
TokenCache.queueCachedTokenPersist(cacheKey, loginToken, System.currentTimeMillis() + 24 * 60 * 60 * 1000L);
if (loginToken != null) {
header.set("appToken", loginToken);
}
log.info("登录成功 token: {}...", loginToken != null ? loginToken.substring(0, Math.min(8, loginToken.length())) : "null");
promise1.complete();
} else {
// 检查是否为临时认证
@@ -323,7 +346,7 @@ public class IzTool extends PanBase {
} else {
// 后台配置的认证失败,设置authFlag并返回失败,让下次请求使用免登陆解析
log.warn("后台配置认证失败: {}, authFlag将设为false,请重新解析", json.getString("msg"));
authFlag = false;
TokenCache.putFlag(cacheKey, false);
promise1.fail("认证失败: " + json.getString("msg") + ", 请重新解析将使用免登陆模式");
}
}
@@ -443,75 +466,75 @@ public class IzTool extends PanBase {
}
}
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
String location = headers.get("Location");
if (StringUtils.isBlank(location)) {
fail("{}", buildMissingLocationMessage(res2));
return;
}
promise.complete(location);
}
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
message.append(", HTTP ").append(response.statusCode());
String body = null;
try {
body = asText(response);
} catch (Exception e) {
body = "<响应体读取失败: " + e.getMessage() + ">";
}
if (StringUtils.isNotBlank(body)) {
try {
JsonObject json = new JsonObject(body);
String upstreamMsg = json.getString("msg");
Object code = json.getValue("code");
if (StringUtils.isNotBlank(upstreamMsg)) {
message.append(", 上游返回: ").append(upstreamMsg);
if (code != null) {
message.append(" (code=").append(code).append(")");
}
} else {
message.append(", 响应体: ").append(previewBody(body));
}
} catch (Exception ignored) {
message.append(", 响应体: ").append(previewBody(body));
}
} else {
message.append(", 响应体为空");
}
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
if (fileName != null) {
message.append(", 文件: ").append(fileName);
}
if (fileSize != null) {
message.append(", 大小: ").append(fileSize);
}
if (!hasConfiguredAuth()) {
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
}
return message.toString();
}
private boolean hasConfiguredAuth() {
Object authObj = shareLinkInfo.getOtherParam().get("auths");
if (!(authObj instanceof MultiMap auths)) {
return false;
}
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
}
private String previewBody(String body) {
int maxLength = 500;
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
}
// 目录解析
private void down(HttpResponse<Buffer> res2) {
MultiMap headers = res2.headers();
String location = headers.get("Location");
if (StringUtils.isBlank(location)) {
fail("{}", buildMissingLocationMessage(res2));
return;
}
promise.complete(location);
}
private String buildMissingLocationMessage(HttpResponse<Buffer> response) {
StringBuilder message = new StringBuilder("未获取到下载重定向地址");
message.append(", HTTP ").append(response.statusCode());
String body = null;
try {
body = asText(response);
} catch (Exception e) {
body = "<响应体读取失败: " + e.getMessage() + ">";
}
if (StringUtils.isNotBlank(body)) {
try {
JsonObject json = new JsonObject(body);
String upstreamMsg = json.getString("msg");
Object code = json.getValue("code");
if (StringUtils.isNotBlank(upstreamMsg)) {
message.append(", 上游返回: ").append(upstreamMsg);
if (code != null) {
message.append(" (code=").append(code).append(")");
}
} else {
message.append(", 响应体: ").append(previewBody(body));
}
} catch (Exception ignored) {
message.append(", 响应体: ").append(previewBody(body));
}
} else {
message.append(", 响应体为空");
}
Object fileName = shareLinkInfo.getOtherParam().get("fileName");
Object fileSize = shareLinkInfo.getOtherParam().get("fileSizeFormat");
if (fileName != null) {
message.append(", 文件: ").append(fileName);
}
if (fileSize != null) {
message.append(", 大小: ").append(fileSize);
}
if (!hasConfiguredAuth()) {
message.append(", 当前为免登录解析,上游可能要求登录、会员或人工处理");
}
return message.toString();
}
private boolean hasConfiguredAuth() {
Object authObj = shareLinkInfo.getOtherParam().get("auths");
if (!(authObj instanceof MultiMap auths)) {
return false;
}
return StringUtils.isNotBlank(auths.get("username")) && StringUtils.isNotBlank(auths.get("password"));
}
private String previewBody(String body) {
int maxLength = 500;
return body.length() <= maxLength ? body : body.substring(0, maxLength) + "...";
}
// 目录解析
@Override
public Future<List<FileInfo>> parseFileList() {
Promise<List<FileInfo>> promise = Promise.promise();
@@ -691,7 +714,6 @@ public class IzTool extends PanBase {
}
public static void resetToken() {
token = null;
authFlag = true;
TokenCache.removeByDiskType("iz");
}
}
@@ -84,6 +84,7 @@ public class IzToolWithAuth extends PanBase {
}
public IzToolWithAuth(ShareLinkInfo shareLinkInfo) {
super(shareLinkInfo);
IzXffHeader.apply(header);
}
String uuid = UUID.randomUUID().toString().toLowerCase(); // 也可以使用 UUID.randomUUID().toString()
@@ -213,8 +214,8 @@ public class IzToolWithAuth extends PanBase {
if (token == null) {
// 执行登录
login(tsEncode2, auths).onFailure(failRes-> {
log.warn("登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
@@ -229,8 +230,8 @@ public class IzToolWithAuth extends PanBase {
// log.info("res: {}",asJson(res));
if (asJson(res).getInteger("code") != 200) {
login(tsEncode2, auths).onFailure(failRes -> {
log.warn("重新登录失败: {}", failRes.getMessage());
fail(failRes.getMessage());
log.warn("重新登录失败,回退免登录: {}", failRes.getMessage());
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}).onSuccess(r-> {
httpRequest.setTemplateParam("appToken", token)
.putHeaders(header);
@@ -246,29 +247,27 @@ public class IzToolWithAuth extends PanBase {
} else {
// authFlag 为 false,使用免登录解析
log.debug("authFlag=false,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
} else {
// 没有认证信息,使用免登录解析
log.debug("无认证信息,使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
downloadAnonymous(fidEncode, tsEncode2, auth, dataKey);
}
}
private void downloadAnonymous(String fidEncode, String tsEncode2, String auth, String dataKey) {
log.debug("使用免登录解析");
webClientSession.getAbs(UriTemplate.of(SECOND_REQUEST_URL))
.putHeaders(header)
.setTemplateParam("fidEncode", fidEncode)
.setTemplateParam("uuid", uuid)
.setTemplateParam("ts", tsEncode2)
.setTemplateParam("auth", auth)
.setTemplateParam("dataKey", dataKey).send()
.onSuccess(this::down).onFailure(handleFail("请求2"));
}
private Future<Void> login(String tsEncode2, MultiMap auths) {
Promise<Void> promise1 = Promise.promise();
webClientSession.postAbs(UriTemplate.of(LOGIN_URL))
@@ -0,0 +1,60 @@
package cn.qaiu.parser.impl;
import io.vertx.core.MultiMap;
import org.apache.commons.lang3.StringUtils;
import java.util.concurrent.ThreadLocalRandom;
/**
* 蓝奏云优享 X-Forwarded-For 配置。
* <p>
* 读取系统属性 {@code nfd.iz.xff},其次环境变量 {@code NFD_IZ_XFF}:
* <ul>
* <li>未设置 / 空 / {@code off}:不发送该头(默认)</li>
* <li>{@code random}:每次创建解析器时随机一个国内常见网段 IP</li>
* <li>其它值:原样作为头的值,例如 {@code 113.96.181.18}</li>
* </ul>
*/
public final class IzXffHeader {
public static final String PROP = "nfd.iz.xff";
public static final String ENV = "NFD_IZ_XFF";
private static final String HEADER = "X-Forwarded-For";
/** 国内运营商常见 /8 段首字节 */
private static final int[] CN_PREFIX = {36, 39, 42, 58, 59, 60, 61, 101, 106, 110, 111, 112, 113, 114,
115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 175, 180, 182, 183, 202, 210, 211, 218, 219, 220, 221, 222, 223};
private IzXffHeader() {
}
static String configured() {
String v = System.getProperty(PROP);
if (StringUtils.isBlank(v)) {
v = System.getenv(ENV);
}
return v == null ? "" : v.trim();
}
/** 根据配置得到头的值,返回 null 表示不发送 */
static String resolve(String mode) {
if (StringUtils.isBlank(mode) || "off".equalsIgnoreCase(mode)) {
return null;
}
if ("random".equalsIgnoreCase(mode)) {
ThreadLocalRandom r = ThreadLocalRandom.current();
return CN_PREFIX[r.nextInt(CN_PREFIX.length)] + "." + r.nextInt(256) + "." + r.nextInt(256) + "." + (1 + r.nextInt(254));
}
return mode;
}
/** 按当前配置写入或移除请求头 */
public static void apply(MultiMap headers) {
String value = resolve(configured());
if (value == null) {
headers.remove(HEADER);
} else {
headers.set(HEADER, value);
}
}
}
@@ -7,6 +7,7 @@ import cn.qaiu.parser.PanBase;
import cn.qaiu.util.*;
import io.netty.handler.codec.http.cookie.DefaultCookie;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.MultiMap;
import io.vertx.core.Promise;
import io.vertx.core.buffer.Buffer;
@@ -41,9 +42,37 @@ public class LzTool extends PanBase {
private final WebClientSession webClientSession;
/** 实测服务器出口 IP 上 wwww.lanzoux.com 可用,且个性域名目录内文件 ID 也能打开。 */
public static final String SHARE_URL_PREFIX = "https://wwww.lanzoux.com/";
private static final String SHARE_ORIGIN = "https://wwww.lanzoux.com";
/**
* 没有原始分享页主机时才使用(目录内文件 ID、shareKey 占位)。
* 与 PanDomainTemplate.LZ 标准模板一致:{@code https://w1.lanzn.com/{shareKey}}。
* 已有分享页不要改写到 wwww.lanzoux.com,部分出口连四个 w 的域名会超时。
*/
public static final String SHARE_URL_PREFIX = "https://w1.lanzn.com/";
/** 页面 URL 解析失败时的源站兜底,与 {@link #SHARE_URL_PREFIX} 同主机。 */
private static final String SHARE_ORIGIN = "https://w1.lanzn.com";
/**
* 目录 filemoreajax.php 换域顺序。个性域名上会立刻返回「已超时」。
* wwww 只放最后,避免单一主机超时直接失败。
* 文件 ajax 走 {@link #FILE_AJAX_FALLBACK_ORIGINS},不要把 apifile 混进目录列表。
*/
static final List<String> AJAX_FALLBACK_ORIGINS = List.of(
"https://w1.lanzn.com",
"https://www.lanzoux.com",
"https://wwww.lanzoux.com"
);
/**
* 文件页 iframe / 密码页里的 ajaxfile.php 经常是绝对地址
* {@code https://apifile.lanzouw.com/ajaxfile.php?file=…}。
* 相对路径打到页面源站或 w1/www 会空响应。apifile 放在文件 ajax 兜底的最前,
* 页面源站空响应后立刻换过去,不在四 w 上耗光超时。
*/
static final String APIFILE_ORIGIN = "https://apifile.lanzouw.com";
static final List<String> FILE_AJAX_FALLBACK_ORIGINS = List.of(
APIFILE_ORIGIN,
"https://w1.lanzn.com",
"https://www.lanzoux.com",
"https://wwww.lanzoux.com"
);
/** 分享页 / ajax 请求超时 */
private static final long REQUEST_TIMEOUT = 8000;
/** 下载域跳转与二次验证超时,链路更长 */
@@ -54,6 +83,10 @@ public class LzTool extends PanBase {
private static final Pattern P_AJAXDATA = Pattern.compile("ajaxdata\\s*=\\s*'([^']+)'");
private static final Pattern P_WEBSIGN = Pattern.compile("'websign'\\s*:\\s*'([^']*)'");
private static final Pattern P_WEBPAGE = Pattern.compile("[?&]webpage=([^&\"'\\s#]+)");
/** 页面脚本里的绝对 ajax,例如 https://apifile.lanzouw.com/ajaxfile.php?file=123 */
private static final Pattern P_AJAX_ABSOLUTE = Pattern.compile(
"['\"]((?:https?:)?//[^'\"\\s<>]+/ajax(?:m|file)\\.php\\?file=\\d+)['\"]",
Pattern.CASE_INSENSITIVE);
private static final Pattern P_AJAX_PATH = Pattern.compile("(?:['\"/]|^)(ajax(?:m|file)\\.php\\?file=\\d+)");
private static final Pattern P_SIGN = Pattern.compile("'sign'\\s*:\\s*'([^']+)'");
private static final Pattern P_ISNGIS = Pattern.compile("var\\s+isngis\\s*=\\s*'([^']+)'");
@@ -259,18 +292,17 @@ public class LzTool extends PanBase {
return path.isEmpty() || "-".equals(path);
}
/** 统一改写到 {@link #SHARE_URL_PREFIX},只保留分享路径。 */
private String resolveShareUrl() {
String u = shareLinkInfo.getShareUrl();
/**
* 分享页 / 目录页 GET 保留原始主机和路径。
* 只有分享路径缺失(空链接、shareKey 占位)时才落到 {@link #SHARE_URL_PREFIX}。
*/
static String resolveSharePageUrl(String shareUrl, String standardUrl, String shareKey) {
String u = shareUrl;
if (isPlaceholderShare(u)) {
u = shareLinkInfo.getStandardUrl();
u = standardUrl;
}
if (isPlaceholderShare(u)) {
String key = shareLinkInfo.getShareKey();
if (key == null || key.isBlank() || "-".equals(key)) {
return SHARE_URL_PREFIX;
}
return SHARE_URL_PREFIX + key;
return canonicalShareUrl(shareKey, null);
}
int q = u.indexOf('?');
String query = null;
@@ -284,10 +316,167 @@ public class LzTool extends PanBase {
}
int pathStart = u.indexOf('/', schemeEnd + 3);
if (pathStart < 0) {
String key = shareLinkInfo.getShareKey();
return appendWebpage(SHARE_URL_PREFIX + (key == null ? "" : key), query);
String base = u.endsWith("/") ? u : u + "/";
if (shareKey == null || shareKey.isBlank() || "-".equals(shareKey)) {
return appendWebpage(base, query);
}
return appendWebpage(base + shareKey, query);
}
return appendWebpage(SHARE_ORIGIN + u.substring(pathStart), query);
return appendWebpage(u, query);
}
private String resolveShareUrl() {
return resolveSharePageUrl(
shareLinkInfo.getShareUrl(),
shareLinkInfo.getStandardUrl(),
shareLinkInfo.getShareKey());
}
/** 无原始主机时用标准下载域拼分享路径,不再写死 wwww.lanzoux.com。 */
private static String canonicalShareUrl(String shareKey, String query) {
if (shareKey == null || shareKey.isBlank() || "-".equals(shareKey)) {
return appendWebpage(SHARE_URL_PREFIX, query);
}
return appendWebpage(SHARE_URL_PREFIX + shareKey, query);
}
/**
* 目录内文件页主机:沿用已有分享主机,否则用 {@link #SHARE_URL_PREFIX}。
*/
static String sharePageHostBase(String shareUrl, String standardUrl) {
String u = !isPlaceholderShare(shareUrl) ? shareUrl : standardUrl;
if (!isPlaceholderShare(u)) {
String origin = normalizeOrigin(originOf(u, SHARE_ORIGIN));
return origin + "/";
}
return SHARE_URL_PREFIX;
}
/**
* 目录 filemore 的 POST 主机顺序:分享页源站优先(普通节点通常可用),
* 然后是稳定下载域。wwww.lanzoux.com 固定排在最后。
*/
static List<String> ajaxOrigins(String pageUrl) {
List<String> origins = new ArrayList<>();
addAjaxOrigin(origins, normalizeOrigin(originOf(pageUrl, "")));
for (String candidate : AJAX_FALLBACK_ORIGINS) {
addAjaxOrigin(origins, candidate);
}
return origins;
}
/**
* 文件 ajax 主机顺序。页面里若写出绝对 ajax URL,该主机最先尝试;
* 否则先打页面源站。空响应后再试 {@link #APIFILE_ORIGIN},四 w 仍在最后。
*
* @param ajaxTarget {@link #fileAjaxTarget} 的返回值,绝对 URL 或相对路径;可为 null
*/
static List<String> fileAjaxOrigins(String pageUrl, String ajaxTarget) {
List<String> origins = new ArrayList<>();
addAjaxOrigin(origins, originFromAjaxTarget(ajaxTarget));
addAjaxOrigin(origins, normalizeOrigin(originOf(pageUrl, "")));
for (String candidate : FILE_AJAX_FALLBACK_ORIGINS) {
addAjaxOrigin(origins, candidate);
}
return origins;
}
/**
* 从分享页或 iframe HTML 取出文件 ajax 地址。
* 绝对 URL(含协议相对 {@code //host/...})原样返回;否则返回以 {@code /} 开头的相对路径。
*/
static String fileAjaxTarget(String html) {
if (html == null || html.isEmpty()) {
return null;
}
Matcher abs = P_AJAX_ABSOLUTE.matcher(html);
if (abs.find()) {
String url = abs.group(1);
if (url.startsWith("//")) {
url = "https:" + url;
}
return url;
}
Matcher ajax = P_AJAX_PATH.matcher(html);
if (!ajax.find()) {
return null;
}
return "/" + ajax.group(1);
}
private static void addAjaxOrigin(List<String> origins, String origin) {
if (origin != null && origin.contains("://") && !origins.contains(origin)) {
origins.add(origin);
}
}
private static String originFromAjaxTarget(String ajaxTarget) {
if (ajaxTarget == null) {
return "";
}
if (ajaxTarget.startsWith("http://") || ajaxTarget.startsWith("https://")) {
return normalizeOrigin(originOf(ajaxTarget, ""));
}
return "";
}
private static String normalizeOrigin(String origin) {
if (origin == null) {
return "";
}
String o = origin.trim();
while (o.endsWith("/")) {
o = o.substring(0, o.length() - 1);
}
return o;
}
/**
* 个性域名常见 {@code inf=已超时},或响应根本不是 JSON。这类结果说明当前主机不可用,应换下一个域名。
* 密码错误、分享失效等业务错误不换域名。
*/
static boolean ajaxResponseShouldFallback(String text) {
if (text == null || text.isBlank()) {
return true;
}
JsonObject json = parseLzJson(text);
if (json == null) {
return true;
}
Integer zt = jsonInt(json, "zt");
if (zt != null && zt == 1) {
return false;
}
String info = ajaxInfoText(json);
return info != null && info.contains("超时");
}
private static Integer jsonInt(JsonObject json, String key) {
Object v = json.getValue(key);
if (v instanceof Number n) {
return n.intValue();
}
if (v instanceof CharSequence) {
try {
return Integer.parseInt(v.toString().trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private static String ajaxInfoText(JsonObject json) {
for (String key : new String[]{"inf", "info"}) {
Object v = json.getValue(key);
if (v instanceof CharSequence) {
String s = v.toString().trim();
if (!s.isEmpty() && !"null".equalsIgnoreCase(s)) {
return s;
}
}
}
return null;
}
/** 目录文件 ID 可能带 webpage=,必须保留;pwd 走独立字段,不拼进 URL。 */
@@ -411,8 +600,12 @@ public class LzTool extends PanBase {
return true;
}
/** 页面里提取出的 ajax 调用:相对路径 + 表单参数。 */
private record AjaxCall(String path, Map<String, String> form) {
/** 页面里提取出的 ajax 调用。absoluteUrl 非空时优先打该主机。 */
private record AjaxCall(String path, Map<String, String> form, String absoluteUrl) {
private AjaxCall(String path, Map<String, String> form) {
this(path, form, null);
}
MultiMap toForm() {
MultiMap m = MultiMap.caseInsensitiveMultiMap();
form.forEach(m::set);
@@ -424,11 +617,12 @@ public class LzTool extends PanBase {
if (html == null || html.isEmpty()) {
return null;
}
Matcher ajax = P_AJAX_PATH.matcher(html);
if (!ajax.find()) {
String target = fileAjaxTarget(html);
if (target == null) {
return null;
}
String ajaxPath = ajax.group(1);
String absolute = target.startsWith("http://") || target.startsWith("https://") ? target : null;
String ajaxPath = absolute == null ? target : pathOfAbsolute(absolute);
Map<String, String> data = new LinkedHashMap<>();
data.put("action", "downprocess");
Matcher wp = P_WP_SIGN.matcher(html);
@@ -482,7 +676,23 @@ public class LzTool extends PanBase {
data.put("signs", ad2.group(1));
}
}
return new AjaxCall("/" + ajaxPath, data);
return new AjaxCall(ajaxPath, data, absolute);
}
private static String pathOfAbsolute(String url) {
try {
URL u = new URL(url);
String path = u.getPath() == null ? "" : u.getPath();
if (u.getQuery() != null && !u.getQuery().isEmpty()) {
path = path + "?" + u.getQuery();
}
if (!path.startsWith("/")) {
path = "/" + path;
}
return path;
} catch (Exception e) {
return url;
}
}
private static AjaxCall extractFolderAjax(String html, String pwd) {
@@ -576,12 +786,58 @@ public class LzTool extends PanBase {
private void getDownURL(String referer, AjaxCall call) {
MultiMap headers = HeaderUtils.parseHeaders(DOWN_AJAX_HEADERS);
// 个性域名 ajaxfile.php 会立刻 inf=已超时;POST 必须打 wwww。iframe 请求 Referer 用 iframe 地址。
headers.set("referer", referer != null && !referer.isBlank() ? referer : resolveShareUrl());
String url = joinUrl(SHARE_ORIGIN + "/", call.path());
// 文件 ajax 优先用页面里的绝对地址(常见 apifile.lanzouw.com)。
// 页面源站空响应再换 FILE_AJAX_FALLBACK_ORIGINS,wwww 仍在最后。
// iframe / 分享页的 Referer 保持原主机,不把页面 GET 改写到 wwww。
String page = referer != null && !referer.isBlank() ? referer : resolveShareUrl();
headers.set("referer", page);
String target = call.absoluteUrl() != null ? call.absoluteUrl() : call.path();
postAjaxAt(fileAjaxOrigins(page, target), 0, call, headers, this::handleAjaxDownResponse, null);
}
/**
* 依次向 {@link #ajaxOrigins} 发 POST。连接失败或「已超时」换下一个主机;
* wwww.lanzoux.com 只在最后尝试。业务错误(密码、失效)立即返回。
*/
private void postAjaxWithFallback(String pageUrl, AjaxCall call, MultiMap headers,
Handler<String> onBody, Handler<Throwable> onFailure) {
postAjaxAt(ajaxOrigins(pageUrl), 0, call, headers, onBody, onFailure);
}
private void postAjaxAt(List<String> origins, int index, AjaxCall call, MultiMap headers,
Handler<String> onBody, Handler<Throwable> onFailure) {
if (index >= origins.size()) {
String msg = "蓝奏 ajax 全部域名均失败";
if (onFailure != null) {
onFailure.handle(new RuntimeException(msg));
} else {
fail(msg);
}
return;
}
String url = joinUrl(origins.get(index) + "/", call.path());
boolean hasNext = index + 1 < origins.size();
postFormWithArg1Retry(url, headers, call.toForm())
.onSuccess(this::handleAjaxDownResponse)
.onFailure(handleFail(url));
.onSuccess(text -> {
if (hasNext && ajaxResponseShouldFallback(text)) {
log.warn("蓝奏 ajax {} 不可用,改试下一域名", url);
postAjaxAt(origins, index + 1, call, headers, onBody, onFailure);
return;
}
onBody.handle(text);
})
.onFailure(err -> {
if (hasNext) {
log.warn("蓝奏 ajax {} 请求失败: {},改试下一域名", url, err.getMessage());
postAjaxAt(origins, index + 1, call, headers, onBody, onFailure);
return;
}
if (onFailure != null) {
onFailure.handle(err);
} else {
handleFail(url).handle(err);
}
});
}
private void handleAjaxDownResponse(String text) {
@@ -913,10 +1169,9 @@ public class LzTool extends PanBase {
}
log.debug("解析参数: {}", call.form());
String url = joinUrl(originOf(sUrl, SHARE_ORIGIN) + "/", call.path());
postFormWithArg1Retry(url, folderListHeaders(sUrl), call.toForm())
.onSuccess(body -> handleFileListResponse(body, listPromise))
.onFailure(listPromise::fail);
postAjaxWithFallback(sUrl, call, folderListHeaders(sUrl),
body -> handleFileListResponse(body, listPromise),
listPromise::fail);
} catch (ScriptException | NoSuchMethodException | RuntimeException e) {
listPromise.fail(e);
}
@@ -1069,7 +1324,7 @@ public class LzTool extends PanBase {
if (webpage == null || webpage.isBlank()) {
webpage = extractWebpage(shareLinkInfo.getShareUrl(), null);
}
String fileUrl = SHARE_URL_PREFIX + id;
String fileUrl = sharePageHostBase(shareLinkInfo.getShareUrl(), shareLinkInfo.getStandardUrl()) + id;
if (webpage != null && !webpage.isBlank()) {
fileUrl = fileUrl + "?webpage=" + webpage;
}
@@ -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<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;
}
@@ -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<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
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<String> 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<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", 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<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) {
long size = fileSize(file);
if (size > 0 && size != Long.MAX_VALUE) {
return true;
}
}
return false;
}
/** 同一目录里有正常文件时,忽略 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;
}
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 共用):
* <ol>
@@ -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;
}
@@ -3,6 +3,7 @@ package cn.qaiu.parser.impl;
import cn.qaiu.entity.FileInfo;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.parser.PanBase;
import cn.qaiu.parser.PasswordLoginRisk;
import cn.qaiu.parser.TokenCache;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.FileSizeConverter;
@@ -188,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;
}
@@ -205,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;
}
@@ -272,24 +278,27 @@ public class Ye2Tool extends PanBase {
.putHeader("platform", "web")
.sendJsonObject(loginBody)
.onSuccess(res -> {
JsonObject json = res.bodyAsJsonObject();
JsonObject json;
try {
json = res.bodyAsJsonObject();
} catch (Exception e) {
promise.fail(PasswordLoginRisk.nonJsonLoginFailure(res.bodyAsString()));
return;
}
if (json == null) {
promise.fail("登录响应格式异常: " + res.bodyAsString());
promise.fail(PasswordLoginRisk.nonJsonLoginFailure(res.bodyAsString()));
return;
}
if (!json.containsKey("code")) {
promise.fail("登录响应格式异常: " + res.bodyAsString());
return;
}
if (json.getInteger("code") != 200) {
promise.fail("登录失败: " + json.getString("message"));
// 验证码/风控只提示改用 Authorization,不求解、不重试。
if (!PasswordLoginRisk.isLoginSuccess(json)) {
promise.fail(PasswordLoginRisk.describeLoginFailure(json));
return;
}
JsonObject data = json.getJsonObject("data");
if (data == null || !data.containsKey("token")) {
promise.fail("未获取到token");
return;
}
String ssoToken = data.getString("token");
String expireStr = data.getString("expire");
long expireMs;
@@ -438,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;
}
@@ -542,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;
}
@@ -637,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);
+50 -6
View File
@@ -55,8 +55,50 @@ def encode123(url, way, version, timestamp):
# 返回加密后的URL参数
return f"?{y}={time_long}-{a}-{final_crc}"
# 与 parser PasswordLoginRisk 保持一致:验证码/风控只提示改用 Authorization,不求解、不重试。
_RISK_KEYWORDS = (
"验证码", "风控", "安全风险", "安全验证", "安全校验", "滑块", "滑动验证",
"拼图验证", "短信验证", "微信登录", "微信进行登录", "境外登录", "需要验证", "请验证",
"请你验证", "进行验证", "完成验证", "二次验证", "人机验证", "操作频繁", "请求过于频繁",
"请求频繁", "captcha", "recaptcha", "risk control",
)
_AUTH_HINT = (
"账号密码登录触发了验证码/风控,已停止用户名密码登录(不会尝试验证码)。"
"请改为配置 Authorization(浏览器登录后的 Bearer token / JWT,"
"例如 localStorage 的 authorToken、请求头 authorization 或 Cookie 的 sso-token)。"
"静态配置写在 app-dev.yml 的 auths.ye.authorization(与 auths.ye.token 等价);"
"临时请求使用 authType=authorization 或 authType=accesstoken,token 填该令牌(不要重复带 Bearer 前缀)。"
"说明见 parser/doc/auth-param/。"
)
def format_login_failure(result):
"""把 123 登录失败响应转成用户可见说明。"""
if not isinstance(result, dict):
return "登录失败"
message = result.get("message") or result.get("msg") or ""
data = result.get("data") if isinstance(result.get("data"), dict) else {}
data_message = data.get("message") or data.get("msg") or ""
blob = f"{message} {data_message}".lower()
risk_field = any(
key.lower().find(flag) >= 0 and data.get(key) not in (None, False, 0, "", "false", "0")
for key in data
for flag in ("captcha", "vcode", "risk", "needverify", "need_verify", "slideverify", "verifycode", "verify_code")
)
if risk_field or any(keyword.lower() in blob for keyword in _RISK_KEYWORDS):
code = result.get("code")
upstream = message or data_message
extra = f"code={code}" if code is not None else ""
if upstream:
extra = f"{extra}, message={upstream}" if extra else f"message={upstream}"
return f"{_AUTH_HINT} 上游返回:{extra}" if extra else _AUTH_HINT
if result.get("code") == 200:
return "未获取到token"
return f"登录失败: {message}" if message else "登录失败"
def login_123pan(username, password):
"""登录123盘获取token"""
"""登录123盘获取token。成功返回 token,失败返回 None(错误已打印)。"""
print(f"🔐 正在登录账号: {username}")
login_data = {
@@ -73,16 +115,18 @@ def login_123pan(username, password):
)
result = response.json()
if result.get('code') == 200:
if result.get('code') == 200 and result.get('data', {}).get('token'):
token = result.get('data', {}).get('token', '')
print(f"✅ 登录成功!")
return token
else:
error_msg = result.get('message', '未知错误')
print(f"❌ 登录失败: {error_msg}")
error_msg = format_login_failure(result)
login_123pan.last_error = error_msg
print(f"❌ {error_msg}")
return None
except Exception as e:
print(f"❌ 登录请求失败: {e}")
login_123pan.last_error = f"登录请求失败: {e}"
print(f"❌ {login_123pan.last_error}")
return None
def get_share_info(share_key, password=''):
@@ -189,7 +233,7 @@ def start(link, password='', username='', user_password=''):
if not token:
return {
"code": 201,
"message": "登录失败"
"message": getattr(login_123pan, "last_error", None) or "登录失败"
}
else:
print("⚠️ 未提供登录信息,某些文件可能无法下载")
@@ -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,115 @@
package cn.qaiu.parser;
import io.vertx.core.json.JsonObject;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class PasswordLoginRiskTest {
@Test
public void securityRiskMessageGuidesAuthorization() {
JsonObject body = new JsonObject()
.put("code", 401)
.put("message", "当前账号存在安全风险,请使用短信验证码或者微信进行登录。");
assertTrue(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
String message = PasswordLoginRisk.describeLoginFailure(body);
assertTrue(message.startsWith(PasswordLoginRisk.AUTHORIZATION_HINT));
assertTrue(message.contains("验证码/风控"));
assertTrue(message.contains("auths.ye.authorization"));
assertTrue(message.contains("authType=authorization"));
assertTrue(message.contains("authType=accesstoken"));
assertTrue(message.contains("code=401"));
assertTrue(message.contains("当前账号存在安全风险"));
assertFalse(message.startsWith("登录失败:"));
}
@Test
public void overseasRiskAndCaptchaPhrasesMatch() {
assertRisk("当前账号存在境外登录风险,请使用短信验证码或者微信进行登录。");
assertRisk("请完成安全验证");
assertRisk("请进行滑块验证");
assertRisk("操作频繁,请稍后再试");
assertRisk("captcha required");
}
@Test
public void wrongPasswordStaysDistinct() {
JsonObject body = new JsonObject()
.put("code", 500)
.put("message", "用户名或密码错误");
assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
assertEquals("登录失败: 用户名或密码错误", PasswordLoginRisk.describeLoginFailure(body));
JsonObject account = new JsonObject().put("code", 1).put("msg", "账号或密码错误");
assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(account));
assertEquals("登录失败: 账号或密码错误", PasswordLoginRisk.describeLoginFailure(account));
}
@Test
public void riskFieldWithoutKeywordStillHints() {
JsonObject body = new JsonObject()
.put("code", 200)
.put("message", "ok")
.put("data", new JsonObject().put("needCaptcha", true));
assertFalse(PasswordLoginRisk.isLoginSuccess(body));
assertTrue(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
assertTrue(PasswordLoginRisk.describeLoginFailure(body).contains("Authorization"));
}
@Test
public void falseCaptchaFlagDoesNotHint() {
JsonObject body = new JsonObject()
.put("code", 500)
.put("message", "用户名或密码错误")
.put("data", new JsonObject().put("needCaptcha", false));
assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(body));
assertEquals("登录失败: 用户名或密码错误", PasswordLoginRisk.describeLoginFailure(body));
}
@Test
public void successTokenIsNotTreatedAsFailure() {
JsonObject body = new JsonObject()
.put("code", 200)
.put("message", "ok")
.put("data", new JsonObject().put("token", "jwt-token"));
assertTrue(PasswordLoginRisk.isLoginSuccess(body));
}
@Test
public void blankTokenWithoutRiskKeepsMissingTokenMessage() {
JsonObject body = new JsonObject()
.put("code", 200)
.put("message", "ok")
.put("data", new JsonObject().put("token", ""));
assertFalse(PasswordLoginRisk.isLoginSuccess(body));
assertEquals("未获取到token", PasswordLoginRisk.describeLoginFailure(body));
}
@Test
public void nonJsonCaptchaBodyUsesHint() {
String body = "<html>请完成滑块验证</html>";
String message = PasswordLoginRisk.nonJsonLoginFailure(body);
assertTrue(message.startsWith(PasswordLoginRisk.AUTHORIZATION_HINT));
}
@Test
public void nonJsonWrongPasswordStaysFormatError() {
assertEquals("登录响应格式异常: 用户名或密码错误",
PasswordLoginRisk.nonJsonLoginFailure("用户名或密码错误"));
}
private static void assertRisk(String message) {
JsonObject body = new JsonObject().put("code", 5202).put("message", message);
assertTrue(message, PasswordLoginRisk.indicatesCaptchaOrRisk(body));
assertTrue(PasswordLoginRisk.describeLoginFailure(body).startsWith(PasswordLoginRisk.AUTHORIZATION_HINT));
}
}
@@ -0,0 +1,36 @@
package cn.qaiu.parser.impl;
import io.vertx.core.MultiMap;
import org.junit.Test;
import static org.junit.Assert.*;
public class IzXffHeaderTest {
@Test
public void offByDefault() {
assertNull(IzXffHeader.resolve(null));
assertNull(IzXffHeader.resolve(""));
assertNull(IzXffHeader.resolve("OFF"));
}
@Test
public void literalAndRandom() {
assertEquals("113.96.181.18", IzXffHeader.resolve("113.96.181.18"));
String ip = IzXffHeader.resolve("random");
assertTrue(ip, ip.matches("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}"));
}
@Test
public void applyRemovesWhenOff() {
MultiMap h = MultiMap.caseInsensitiveMultiMap().set("X-Forwarded-For", "1.1.1.1");
System.clearProperty(IzXffHeader.PROP);
if (System.getenv(IzXffHeader.ENV) == null) {
IzXffHeader.apply(h);
assertFalse(h.contains("X-Forwarded-For"));
}
System.setProperty(IzXffHeader.PROP, "8.8.8.8");
IzXffHeader.apply(h);
assertEquals("8.8.8.8", h.get("X-Forwarded-For"));
System.clearProperty(IzXffHeader.PROP);
}
}
@@ -0,0 +1,178 @@
package cn.qaiu.parser.impl;
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;
/**
* 个性域名 a.lanzouw.com 不能被改写到 wwww.lanzoux.com(四个 w)。
* 部分出口连不上四 w,页面请求会超时;ajax 只把四 w 当作最后兜底。
*/
public class LzToolShareUrlTest {
@Test
public void personalDomainPageStaysOffWwww() {
String resolved = LzTool.resolveSharePageUrl(
"https://a.lanzouw.com/xxx",
"https://w1.lanzn.com/xxx",
"xxx");
assertEquals("https://a.lanzouw.com/xxx", resolved);
assertFalse(resolved.contains("wwww.lanzoux.com"));
}
@Test
public void folderPageKeepsOriginalHost() {
String resolved = LzTool.resolveSharePageUrl(
"https://a.lanzouw.com/b710887",
"https://w1.lanzn.com/b710887",
"b710887");
assertEquals("https://a.lanzouw.com/b710887", resolved);
}
@Test
public void nodeShareIsNotRewrittenToWwww() {
String resolved = LzTool.resolveSharePageUrl(
"https://wwn.lanzouy.com/ihLkw1gezutg",
"https://w1.lanzn.com/ihLkw1gezutg",
"ihLkw1gezutg");
assertEquals("https://wwn.lanzouy.com/ihLkw1gezutg", resolved);
}
@Test
public void webpageQueryStaysOnOriginalHost() {
String resolved = LzTool.resolveSharePageUrl(
"https://a.lanzouw.com/iabc?webpage=wp1&pwd=secret",
"https://w1.lanzn.com/iabc",
"iabc");
assertEquals("https://a.lanzouw.com/iabc?webpage=wp1", resolved);
}
@Test
public void placeholderUsesStandardHostNotWwww() {
assertEquals("https://w1.lanzn.com/", LzTool.SHARE_URL_PREFIX);
assertFalse(LzTool.SHARE_URL_PREFIX.contains("wwww"));
String resolved = LzTool.resolveSharePageUrl(
"https://w1.lanzn.com/-",
"https://w1.lanzn.com/-",
"iFileId");
assertEquals("https://w1.lanzn.com/iFileId", resolved);
assertFalse(resolved.contains("wwww.lanzoux.com"));
}
@Test
public void missingShareUrlKeepsRealStandardHost() {
String resolved = LzTool.resolveSharePageUrl(
null,
"https://www.lanzoux.com/iabc",
"iabc");
assertEquals("https://www.lanzoux.com/iabc", resolved);
}
@Test
public void ajaxFallbackOrderPutsWwwwLast() {
List<String> fallback = LzTool.AJAX_FALLBACK_ORIGINS;
assertEquals("https://w1.lanzn.com", fallback.get(0));
assertEquals("https://www.lanzoux.com", fallback.get(1));
assertEquals("https://wwww.lanzoux.com", fallback.get(fallback.size() - 1));
List<String> origins = LzTool.ajaxOrigins("https://a.lanzouw.com/xxx");
assertEquals("https://a.lanzouw.com", origins.get(0));
assertEquals("https://w1.lanzn.com", origins.get(1));
assertEquals("https://www.lanzoux.com", origins.get(2));
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
assertTrue(origins.indexOf("https://wwww.lanzoux.com")
> origins.indexOf("https://w1.lanzn.com"));
}
@Test
public void ajaxOriginsDoNotDuplicateKnownHost() {
List<String> origins = LzTool.ajaxOrigins("https://www.lanzoux.com/iabc");
assertEquals(3, origins.size());
assertEquals("https://www.lanzoux.com", origins.get(0));
assertEquals("https://w1.lanzn.com", origins.get(1));
assertEquals("https://wwww.lanzoux.com", origins.get(2));
}
@Test
public void timeoutAjaxFallsBackButBusinessErrorDoesNot() {
assertTrue(LzTool.ajaxResponseShouldFallback("{\"zt\":0,\"inf\":\"已超时\"}"));
assertTrue(LzTool.ajaxResponseShouldFallback("{\"info\":\"请求超时\"}"));
assertTrue(LzTool.ajaxResponseShouldFallback(""));
assertTrue(LzTool.ajaxResponseShouldFallback("<html>blocked</html>"));
assertFalse(LzTool.ajaxResponseShouldFallback("{\"zt\":0,\"inf\":\"密码错误\"}"));
assertFalse(LzTool.ajaxResponseShouldFallback(
"{\"zt\":1,\"inf\":\"name.zip\",\"dom\":\"https://x\",\"url\":\"abc\"}"));
}
@Test
public void filePagePrefersExistingHostOverWwww() {
assertEquals("https://a.lanzouw.com/",
LzTool.sharePageHostBase("https://a.lanzouw.com/b123", "https://w1.lanzn.com/b123"));
assertEquals("https://w1.lanzn.com/",
LzTool.sharePageHostBase("https://w1.lanzn.com/-", "https://w1.lanzn.com/-"));
assertFalse(LzTool.sharePageHostBase("https://a.lanzouw.com/b123", null).contains("wwww"));
}
@Test
public void absoluteAjaxUrlIsKeptAndTriedFirst() {
String html = """
url : 'https://apifile.lanzouw.com/ajaxfile.php?file=320665771',
data : { 'action':'downprocess','sign':isngis,'kd':kdns,'p':pwd, },
""";
String target = LzTool.fileAjaxTarget(html);
assertEquals("https://apifile.lanzouw.com/ajaxfile.php?file=320665771", target);
List<String> origins = LzTool.fileAjaxOrigins(
"https://wwaqg.lanzouu.com/iqouM49vy9ib", target);
assertEquals("https://apifile.lanzouw.com", origins.get(0));
assertTrue(origins.contains("https://wwaqg.lanzouu.com"));
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
assertTrue(origins.indexOf("https://apifile.lanzouw.com")
< origins.indexOf("https://wwaqg.lanzouu.com"));
assertTrue(origins.indexOf("https://wwww.lanzoux.com")
> origins.indexOf("https://w1.lanzn.com"));
assertEquals(1, origins.stream().filter("https://apifile.lanzouw.com"::equals).count());
}
@Test
public void protocolRelativeAjaxBecomesHttps() {
String html = "url : '//apifile.lanzouw.com/ajaxfile.php?file=9',";
assertEquals("https://apifile.lanzouw.com/ajaxfile.php?file=9", LzTool.fileAjaxTarget(html));
}
@Test
public void absoluteAjaxWinsOverRelativePathInSamePage() {
String html = """
//data : { url : '/ajaxfile.php?file=1' }
url : "https://apifile.lanzouw.com/ajaxm.php?file=42",
""";
assertEquals("https://apifile.lanzouw.com/ajaxm.php?file=42", LzTool.fileAjaxTarget(html));
}
@Test
public void relativeFileAjaxFallsThroughToApifileBeforeWwww() {
String html = "url : '/ajaxfile.php?file=150233466',";
assertEquals("/ajaxfile.php?file=150233466", LzTool.fileAjaxTarget(html));
List<String> origins = LzTool.fileAjaxOrigins("https://a.lanzouw.com/xxx", LzTool.fileAjaxTarget(html));
assertEquals("https://a.lanzouw.com", origins.get(0));
assertEquals("https://apifile.lanzouw.com", origins.get(1));
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
assertTrue(origins.indexOf("https://apifile.lanzouw.com")
< origins.indexOf("https://wwww.lanzoux.com"));
assertFalse(LzTool.ajaxOrigins("https://a.lanzouw.com/xxx").contains("https://apifile.lanzouw.com"));
}
@Test
public void missingAjaxTargetStillPutsApifileBeforeWwww() {
List<String> origins = LzTool.fileAjaxOrigins("https://www.lanzoux.com/ihLkw1gezutg", null);
assertEquals("https://www.lanzoux.com", origins.get(0));
assertEquals("https://apifile.lanzouw.com", origins.get(1));
assertEquals("https://wwww.lanzoux.com", origins.get(origins.size() - 1));
assertFalse(origins.get(0).contains("wwww"));
}
}
@@ -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("{"));
}
}
+1 -1
View File
@@ -17,7 +17,7 @@
</modules>
<properties>
<revision>0.4.6</revision>
<revision>0.4.8</revision>
<java.version>17</java.version>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nfd-web",
"version": "0.4.6",
"version": "0.4.8",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
+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: