This commit is contained in:
wangli 2026-01-29 00:50:30 +08:00
parent 0dc64cba1d
commit 7fc7cc26a7
6 changed files with 282 additions and 62 deletions

View File

@ -20,7 +20,7 @@ public abstract class GoofishAbstractApi<T> implements GoofishApi<T> {
private String apiHostUrl;
protected final String buildApiUrl() {
return apiHostUrl + getApi() + "/" + getVersion() + "/";
return apiHostUrl + getApi() + "/" + getVersion() + "/?";
}
protected Map<String, String> buildQueryParams(String cookieStr, String dataStr) {

View File

@ -40,19 +40,25 @@ public class GetAccountApi extends GoofishAbstractApi<String> {
@Override
public String call(String goofishId, String cookieStr, String dataStr) {
String apiUrl = buildApiUrl() + HttpUtil.toParams(buildQueryParams(cookieStr, dataStr));
log.debug("【{}】获取账号名 ApiUrl: {}", goofishId, apiUrl);
log.debug("【{}】获取账号名时使用的 Cookie 为: {}", goofishId, cookieStr);
try (HttpResponse response = HttpRequest.post(apiUrl)
.header("Cookie", cookieStr)
.header("content-type", "application/x-www-form-urlencoded")
.header("priority", "u=1, i")
.body("data=" + URLEncoder.encode(dataStr, StandardCharsets.UTF_8))
.execute()) {
String body = response.body();
log.info("【{}】Auto confirm response: {}", goofishId, body);
log.info("【{}】获取账号名时,服务端返回的完整响应为: {}", goofishId, body);
JSONObject json = JSONUtil.parseObj(body);
if (json.containsKey("ret")) {
JSONArray ret = json.getJSONArray("ret");
if (ret != null && !ret.isEmpty() && ret.getStr(0).startsWith("SUCCESS")) {
return json.getJSONObject("data").getJSONObject("module").getJSONObject("base").getStr("displayNick");
String account = json.getJSONObject("data").getJSONObject("module").getJSONObject("base").getStr("displayNick");
log.debug("【{}】获取到的账号名为: {}", goofishId, account);
return account;
}
}

View File

@ -1,14 +1,15 @@
package top.biwin.xianyu.goofish.service;
import cn.hutool.core.collection.CollUtil;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Frame;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.options.Cookie;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.biwin.xianyu.goofish.util.CookieUtils;
import java.util.List;
import java.util.Objects;
@ -28,8 +29,9 @@ public class GoofishPwdLoginService {
@Autowired
private BrowserService browserService;
@Autowired
@Autowired
private GoofishApiService goofishApiService;
/**
*
* @param account
@ -68,13 +70,14 @@ public class GoofishPwdLoginService {
log.debug("【{}】正在检查协议复选框,并勾选......", account);
agreement.click();
}
log.info("【{}】单击登录按钮...", account);
log.debug("【{}】单击登录按钮...", account);
loginFrame.click("button.fm-button.fm-submit.password-login");
// TODO 这里点击后可能并不能成功会出现滑块
if (Objects.nonNull(findLoginFrame(page, account))) {
boolean b = attemptSolveSlider(loginFrame, account);
log.debug("滑块结果: {}", b);
// TODO 内部应该要有重试处理滑块的逻辑
boolean resolveResult = attemptSolveSlider(loginFrame, account);
log.debug("【{}】滑块结果: {}", account, resolveResult ? "成功" : "失败");
}
} else {
@ -83,18 +86,25 @@ public class GoofishPwdLoginService {
} else {
// 可能是快捷登录按钮页等待 10s 查看右上角有没有登录信息
page.waitForTimeout(10000);
checkLoggedIn(context, account);
}
// TODO 处理登录成功后的账号 cookie并存库更新persistentData 目录名称
if (checkLoggedIn(context, account)) {
}
log.debug("....");
}
private boolean checkLoggedIn(BrowserContext context,String account) {
List<Cookie> cookies = context.cookies();
private boolean checkLoggedIn(BrowserContext context, String account) {
List<Cookie> cookies = context.cookies().stream().filter(i -> Objects.equals(i.domain, ".goofish.com")).toList();
if (CollUtil.isEmpty(cookies)) {
return false;
}
goofishApiService.getAccount(account, "");
return false;
String remoteAccount = goofishApiService.getAccount(account, CookieUtils.buildCookieStr(cookies));
return Objects.equals(remoteAccount, account);
}
private Frame findLoginFrame(Page page, String account) {

View File

@ -4,52 +4,185 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* 浏览器轨迹生成工具类
* 用于生成模拟人类滑动行为的鼠标轨迹
*
* @author wangli
* @since 2026-01-28
*/
public class BrowserTrajectoryUtils {
public static class TrajectoryPoint {
public double x;
public double y;
public double delay;
private static final Random random = new Random();
public TrajectoryPoint(double x, double y, double delay) {
/**
* 轨迹点包含坐标
*/
public static class TrajectoryPoint {
/** X坐标偏移量 */
public double x;
/** Y坐标偏移量微小抖动 */
public double y;
public TrajectoryPoint(double x, double y) {
this.x = x;
this.y = y;
this.delay = delay;
}
}
/**
* Port of _generate_physics_trajectory from Python
* Based on physics acceleration model - Fast Mode
* 生成丝滑轨迹 - 快速加速一滑到底
*
* 核心理念高密度轨迹点 + 加速曲线 = 丝滑
*
* 特点
* - 大量轨迹点50-80个让滑动超级丝滑
* - 加速度曲线先快速加速然后匀速冲刺
* - 微小Y轴抖动模拟人手
* - 微小超调后回调
*
* @param distance 需要滑动的总距离像素
* @return 轨迹点列表
*/
public static List<TrajectoryPoint> generatePhysicsTrajectory(double distance) {
Random random = new Random();
public static List<TrajectoryPoint> generateSmoothTrajectory(double distance) {
List<TrajectoryPoint> trajectory = new ArrayList<>();
// Ensure overshoot 100-110%
double targetDistance = distance * (2.0 + random.nextDouble() * 0.1);
// 高密度轨迹点50-80个点让滑动丝滑流畅
int steps = 50 + random.nextInt(31);
// Minimal steps (5-8 steps)
int steps = 5 + random.nextInt(4); // 5 to 8
// 微小超调2-5像素然后回调
double overshoot = 2 + random.nextDouble() * 3;
double targetWithOvershoot = distance + overshoot;
// Fast time interval (0.2ms - 0.5ms) - roughly converted to use in sleep
double baseDelay = 0.0002 + random.nextDouble() * 0.0003;
// 主滑动阶段90%的步数用于主滑动
int mainSteps = (int) (steps * 0.9);
for (int i = 0; i < steps; i++) {
double progress = (double)(i + 1) / steps;
// 使用加速曲线生成主轨迹
for (int i = 1; i <= mainSteps; i++) {
double progress = (double) i / mainSteps;
// Calculate current position (Square acceleration curve)
double x = targetDistance * Math.pow(progress, 1.5);
// 加速曲线ease-out (1 - (1-t)^2)快速起步然后平稳
// 这会让开始阶段加速很快后面逐渐平稳
double easedProgress = 1 - Math.pow(1 - progress, 2.5);
// Minimal Y jitter
double y = random.nextDouble() * 2;
double x = targetWithOvershoot * easedProgress;
// Short delay
double delay = baseDelay * (0.9 + random.nextDouble() * 0.2); // 0.9 to 1.1 factor
// Y轴微小随机抖动±2像素模拟人手不稳
double y = (random.nextDouble() - 0.5) * 4;
trajectory.add(new TrajectoryPoint(x, y, delay));
trajectory.add(new TrajectoryPoint(x, y));
}
// 回调阶段10%的步数用于回调到精确位置
int returnSteps = steps - mainSteps;
double currentX = targetWithOvershoot;
for (int i = 1; i <= returnSteps; i++) {
double progress = (double) i / returnSteps;
// 线性回调到目标位置
double x = currentX - overshoot * progress;
// 回调时Y轴抖动更小
double y = (random.nextDouble() - 0.5) * 1.5;
trajectory.add(new TrajectoryPoint(x, y));
}
// 最后确保精确到达目标±1像素内
trajectory.add(new TrajectoryPoint(
distance + (random.nextDouble() - 0.5) * 2,
(random.nextDouble() - 0.5) * 1
));
return trajectory;
}
/**
* 生成极速轨迹 - 最快速度一滑到底备选方案
*
* 如果丝滑版通过率不高可以尝试这个更激进的版本
* 特点更少的点更快的速度更像""过去
*
* @param distance 需要滑动的总距离像素
* @return 轨迹点列表
*/
public static List<TrajectoryPoint> generateFastTrajectory(double distance) {
List<TrajectoryPoint> trajectory = new ArrayList<>();
// 较少的点20-30个速度更快
int steps = 20 + random.nextInt(11);
// 激进超调5-10像素
double overshoot = 5 + random.nextDouble() * 5;
double target = distance + overshoot;
for (int i = 1; i <= steps; i++) {
double progress = (double) i / steps;
// 更激进的加速曲线几乎立即达到最大速度
double easedProgress = 1 - Math.pow(1 - progress, 3);
double x = target * easedProgress;
double y = (random.nextDouble() - 0.5) * 3;
trajectory.add(new TrajectoryPoint(x, y));
}
// 快速回调2-3步
for (int i = 1; i <= 3; i++) {
double x = target - overshoot * ((double) i / 3);
double y = (random.nextDouble() - 0.5) * 2;
trajectory.add(new TrajectoryPoint(x, y));
}
return trajectory;
}
/**
* 生成贝塞尔曲线轨迹备选方案
* 使用三次贝塞尔曲线生成更平滑的轨迹
*
* @param distance 需要滑动的总距离像素
* @return 轨迹点列表
*/
public static List<TrajectoryPoint> generateBezierTrajectory(double distance) {
List<TrajectoryPoint> trajectory = new ArrayList<>();
// 控制点定义曲线形状
double p0x = 0, p0y = 0; // 起点
double p1x = distance * 0.6, p1y = random.nextDouble() * 8 - 4; // 控制点1快速加速
double p2x = distance * 0.9, p2y = random.nextDouble() * 6 - 3; // 控制点2接近终点
double p3x = distance, p3y = 0; // 终点
int steps = 40 + random.nextInt(20);
for (int i = 1; i <= steps; i++) {
double t = (double) i / steps;
// 三次贝塞尔曲线公式
double x = cubicBezier(t, p0x, p1x, p2x, p3x);
double y = cubicBezier(t, p0y, p1y, p2y, p3y);
// 添加微小随机抖动
x += (random.nextDouble() - 0.5) * 1.5;
y += (random.nextDouble() - 0.5) * 2;
trajectory.add(new TrajectoryPoint(x, y));
}
return trajectory;
}
/**
* 三次贝塞尔曲线计算
* B(t) = (1-t)^3 * P0 + 3(1-t)^2 * t * P1 + 3(1-t) * t^2 * P2 + t^3 * P3
*/
private static double cubicBezier(double t, double p0, double p1, double p2, double p3) {
double oneMinusT = 1 - t;
return oneMinusT * oneMinusT * oneMinusT * p0 +
3 * oneMinusT * oneMinusT * t * p1 +
3 * oneMinusT * t * t * p2 +
t * t * t * p3;
}
}

View File

@ -1,5 +1,6 @@
package top.biwin.xianyu.goofish.util;
import cn.hutool.core.collection.CollUtil;
import com.microsoft.playwright.options.Cookie;
import java.util.List;
@ -11,9 +12,17 @@ import java.util.List;
* @since 2026-01-28 23:02
*/
public final class CookieUtils {
private CookieUtils(){}
private CookieUtils() {
}
public String buildCookieStr(List<Cookie> cookies) {
return "";
public static String buildCookieStr(List<Cookie> cookies) {
if (CollUtil.isEmpty(cookies)) {
return "";
}
StringBuilder sb = new StringBuilder();
cookies.forEach(i -> {
sb.append(i.name).append("=").append(i.value).append(";");
});
return sb.substring(0, sb.length() - 1);
}
}

View File

@ -7,23 +7,34 @@ import com.microsoft.playwright.options.BoundingBox;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
/**
* 滑块处理
* 滑块验证码处理工具类
* 模拟人类操作行为解决滑块验证
*
* @author wangli
* @since 2026-01-28 23:16
*/
@Slf4j
public final class SliderUtils {
private SliderUtils() {
}
/**
* 尝试解决滑块验证码
*
* @param frame 包含滑块的Frame
* @param goofishId 闲鱼账号ID用于日志
* @return 是否成功解决滑块
*/
public static boolean attemptSolveSlider(Frame frame, String goofishId) {
try {
String[] sliderSelectors = {"#nc_1_n1z", ".nc-container", ".nc_scale", ".nc-wrapper"};
ElementHandle sliderButton = null;
// 检查当前Frame是否包含滑块容器
boolean containerFound = false;
for (String s : sliderSelectors) {
if (frame.querySelector(s) != null && frame.isVisible(s)) {
@ -32,6 +43,7 @@ public final class SliderUtils {
}
}
// 如果当前Frame没有滑块递归检查子Frame
if (!containerFound && CollUtil.isNotEmpty(frame.childFrames())) {
for (Frame childFrame : frame.childFrames()) {
return attemptSolveSlider(childFrame, goofishId);
@ -40,50 +52,100 @@ public final class SliderUtils {
if (!containerFound) return false;
// 定位滑块按钮
sliderButton = frame.querySelector("#nc_1_n1z");
if (sliderButton == null) sliderButton = frame.querySelector(".nc_iconfont");
if (sliderButton != null && sliderButton.isVisible()) {
log.info("【Login Task】Detected slider in frame: {}", frame.url());
log.info("【Login Task】检测到滑块验证Frame: {}", frame.url());
BoundingBox box = sliderButton.boundingBox();
if (box == null) return false;
// 定位滑块轨道
ElementHandle track = frame.querySelector("#nc_1_n1t");
if (track == null) track = frame.querySelector(".nc_scale");
if (track == null) return false;
BoundingBox trackBox = track.boundingBox();
double distance = trackBox.width - box.width;
log.info("【Login Task】Solving Slider: distance={}", distance);
log.info("【Login Task】准备解决滑块: 距离={}px", distance);
List<BrowserTrajectoryUtils.TrajectoryPoint> trajectory =
BrowserTrajectoryUtils.generatePhysicsTrajectory(distance);
// 执行人类模拟滑动丝滑版
boolean success = performSmoothSlide(frame, box, distance);
double startX = box.x + box.width / 2;
double startY = box.y + box.height / 2;
// 等待验证结果
Thread.sleep(800 + ThreadLocalRandom.current().nextInt(400));
// PlaywrightUtils.moveMouseInFrame(frame, startX, startY);
frame.page().mouse().move(startX, startY);
frame.page().mouse().down();
for (BrowserTrajectoryUtils.TrajectoryPoint p : trajectory) {
// PlaywrightUtils.moveMouseInFrame(frame, startX + p.x, startY + p.y);
frame.page().mouse().move(startX + p.x, startY + p.y);
}
frame.page().mouse().up();
Thread.sleep(1000);
// 检查滑块是否消失成功标志
if (!sliderButton.isVisible()) {
log.info("【Login Task】Slider solved (button disappeared)!");
log.info("【Login Task】滑块验证成功按钮已消失");
return true;
}
return true;
return success;
}
} catch (Exception e) {
log.warn("【Login Task】Error solving slider: {}", e.getMessage());
log.warn("【Login Task】解决滑块时出错: {}", e.getMessage());
}
return false;
}
/**
* 执行丝滑滑动操作
* <p>
* 关键理念快速一滑到底中间不停顿
* <p>
* 人类快速滑动特征
* 1. 按下鼠标前有短暂停顿视觉定位
* 2. 按下后立即快速连续滑动一气呵成不停顿
* 3. 到达终点后释放
*
* @param frame 目标Frame
* @param buttonBox 滑块按钮的边界框
* @param distance 需要滑动的距离
* @return 是否执行成功
*/
private static boolean performSmoothSlide(Frame frame, BoundingBox buttonBox, double distance) {
try {
ThreadLocalRandom random = ThreadLocalRandom.current();
// 计算滑块起始位置微小随机偏移
double startX = buttonBox.x + buttonBox.width / 2 + (random.nextDouble() - 0.5) * 4;
double startY = buttonBox.y + buttonBox.height / 2 + (random.nextDouble() - 0.5) * 3;
// 步骤1移动鼠标到滑块位置
frame.page().mouse().move(startX, startY);
// 步骤2短暂停顿人类反应时间100-250ms
Thread.sleep(100 + random.nextInt(150));
// 步骤3按下鼠标
frame.page().mouse().down();
// 步骤4按下后极短延迟30-60ms
Thread.sleep(30 + random.nextInt(30));
// 步骤5生成丝滑轨迹
List<BrowserTrajectoryUtils.TrajectoryPoint> trajectory =
BrowserTrajectoryUtils.generateFastTrajectory(distance);
log.debug("【Login Task】生成轨迹点数: {}", trajectory.size());
// 步骤6一气呵成快速滑动不加任何sleep
for (BrowserTrajectoryUtils.TrajectoryPoint point : trajectory) {
frame.page().mouse().move(startX + point.x, startY + point.y);
// 不加sleep让Playwright自己处理速度
}
// 步骤7释放鼠标
frame.page().mouse().up();
log.info("【Login Task】滑动执行完成等待验证结果...");
return true;
} catch (Exception e) {
log.error("【Login Task】执行滑动时出错: {}", e.getMessage());
return false;
}
}
}