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]>
This commit is contained in:
Cursor Agent
2026-09-27 22:08:16 +00:00
co-authored by qaiu
parent ab8b687837
commit 1ab9df7826
7 changed files with 454 additions and 14 deletions
@@ -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.PasswordLoginRisk;
import cn.qaiu.parser.TokenCache;
import cn.qaiu.util.CommonUtils;
import cn.qaiu.util.FileSizeConverter;
@@ -272,24 +273,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;