This commit is contained in:
wangli 2026-02-02 23:42:29 +08:00
parent 0b75cc6ac2
commit a6eedd9001
8 changed files with 264 additions and 135 deletions

View File

@ -4,6 +4,7 @@ import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Frame;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
import com.microsoft.playwright.options.Cookie;
@ -28,9 +29,11 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import static top.biwin.xianyu.goofish.BrowserConstant.STEALTH_SCRIPT;
import static top.biwin.xianyu.goofish.util.CookieUtils.buildCookieStr;
import static top.biwin.xianyu.goofish.util.SliderUtils.attemptSolveSlider;
/**
* TODO
@ -310,7 +313,6 @@ public class BrowserService {
public String refreshGoofishAccountCookie(String goofishId, Boolean showBrowser, Double slowMo, String goofishCookieStr) {
BrowserContext browserContext = loadPersistentContext(goofishId, showBrowser, slowMo, goofishCookieStr);
addPersistentContextCookie(goofishId, goofishCookieStr);
// TODO 刷新账号信息并返回新的 cookie
Page page = browserContext.pages().isEmpty() ? browserContext.newPage() : browserContext.pages().get(0);
@ -320,14 +322,85 @@ public class BrowserService {
log.debug("【{}】等待页面加载,查找登录框... url: http://www.goofish.com/im", goofishId);
// 确保页面加载完成
page.getByText("登录后可以更懂你,推荐你喜欢的商品!");
String cookieStr = buildCookieStr(browserContext);
Long goofishUserId = goofishApiService.getUserId(goofishId, cookieStr);
browserContext.close();
if (goofishUserId > 0L) {
return cookieStr;
}
return "";
}
/**
* 使用账号密码自动登录
*/
public boolean autoLoginUsingAccountAndPassword(BrowserContext context, String account, String password, Boolean showBrowser) {
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
page.addInitScript(STEALTH_SCRIPT);
log.debug("【{}】正在导航至登录页... url: http://www.goofish.com/im", account);
page.navigate("https://www.goofish.com/im");
log.debug("【{}】等待页面加载,查找登录框... url: http://www.goofish.com/im", account);
// 确保页面加载完成
page.getByText("登录后可以更懂你,推荐你喜欢的商品!");
Frame loginFrame = findLoginFrame(page, account);
if (Objects.nonNull(loginFrame)) {
// 开始登录
log.debug("【{}】找到登录框,开始模拟登录", account);
ElementHandle switchLink = loginFrame.querySelector("a.password-login-tab-item");
if (switchLink != null && switchLink.isVisible()) {
log.debug("【{}】切换密码登陆框", account);
switchLink.click(new ElementHandle.ClickOptions().setDelay(100));
log.debug("【{}】开始输入用户名...", account);
loginFrame.fill("#fm-login-id", "");
loginFrame.type("#fm-login-id", account, new Frame.TypeOptions().setDelay(100));
log.debug("【{}】开始输入密码...", account);
loginFrame.fill("#fm-login-password", "");
loginFrame.type("#fm-login-password", password, new Frame.TypeOptions().setDelay(100));
loginFrame.waitForTimeout(1000);
// 点击协议并登录
ElementHandle agreement = loginFrame.querySelector("#fm-agreement-checkbox");
if (agreement != null && !agreement.isChecked()) {
log.debug("【{}】正在查找协议复选框,并勾选......", account);
agreement.click();
}
log.debug("【{}】单击登录按钮...", account);
loginFrame.click("button.fm-button.fm-submit.password-login");
page.waitForTimeout(3000D);
// 这里点击后可能并不能成功会出现滑块
if (Objects.nonNull(findLoginFrame(page, account))) {
// 内部应该要有重试处理滑块的逻辑
boolean resolveResult = attemptSolveSlider(loginFrame, account, new AtomicInteger(3));
log.debug("【{}】滑块处理结果: {}", account, resolveResult ? "成功" : "失败");
if(resolveResult) {
// 这里可能已经登录成功等待选择是否持久化登录
loginFrame.waitForTimeout(10000);
log.debug("【{}】验证是否登录成功...", account);
Frame lastCheckFrame = findLoginFrame(page, account);
if(Objects.nonNull(lastCheckFrame)) {
log.error("【{}】登录失败,仍存在登录页面...", account);
return false;
}
} else {
log.error("【{}】账号密码登录失败", account);
return false;
}
}
} else {
log.debug("【{}】未找到密码登录框,可能是持久化登录...", account);
}
} else {
// 可能是快捷登录按钮页等待 10s 查看右上角有没有登录信息
page.waitForTimeout(10000);
}
return true;
}
public void closePersistentContext(String goofishId) {
BrowserContext browserContext = persistentContexts.get(goofishId);
if (Objects.isNull(browserContext)) {
@ -351,4 +424,50 @@ public class BrowserService {
browserContext.addCookies(cookies);
}
private Frame findLoginFrame(Page page, String account) {
String[] selectors = {
"#fm-login-id",
"input[name='fm-login-id']",
"input[placeholder*='手机号']",
"input[placeholder*='邮箱']",
".fm-login-id",
"#J_LoginForm input[type='text']"
};
// 1. Check Main Frame First
for (String s : selectors) {
try {
if (page.isVisible(s)) {
log.debug("【{}】在主框架中判断登录是否显示: {}", account, s);
return page.mainFrame();
}
if (page.querySelector(s) != null) { // Fallback check availability even if not visible yet
log.debug("【{}】在主框架中判断登录是否存在: {}", account, s);
return page.mainFrame();
}
} catch (Exception e) {
log.error("查找登录框异常:{}", e.getMessage(), e);
}
}
// 2. Check All Frames
for (Frame frame : page.frames()) {
for (String s : selectors) {
try {
if (frame.isVisible(s)) {
log.debug("【{}】在 Frame 中判断登录是否显示 ({}): {}", account, frame.url(), s);
return frame;
}
if (frame.querySelector(s) != null) {
log.debug("【{}】在 Frame 中判断登录是否存在({}): {}", account, frame.url(), s);
return frame;
}
} catch (Exception e) {
log.error("查找登录框异常:{}", e.getMessage(), e);
}
}
}
return null;
}
}

View File

@ -1,25 +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.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import top.biwin.xianyu.core.entity.GoofishAccountEntity;
import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.goofish.util.CookieUtils;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import static top.biwin.xianyu.goofish.BrowserConstant.STEALTH_SCRIPT;
import static top.biwin.xianyu.goofish.util.CookieUtils.buildCookieStr;
import static top.biwin.xianyu.goofish.util.SliderUtils.attemptSolveSlider;
/**
* TODO
@ -42,71 +32,16 @@ public class GoofishPwdLoginService {
*
* @param account
* @param password
* @param showBrowser 是否有头headless
* @param systemUsername 当前系统登录用户
* @param showBrowser 是否有头headless
* @param systemUserId 当前系统登录用户
*/
public void processPasswordLogin(String account, String password, Boolean showBrowser, Long systemUsername) {
public void processPasswordLogin(String account, String password, Boolean showBrowser, Long systemUserId) {
BrowserContext context = browserService.loadPersistentContext(account, showBrowser, 1000D, null);
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
page.addInitScript(STEALTH_SCRIPT);
log.debug("【{}】正在导航至登录页... url: http://www.goofish.com/im", account);
page.navigate("https://www.goofish.com/im");
log.debug("【{}】等待页面加载,查找登录框... url: http://www.goofish.com/im", account);
// 确保页面加载完成
page.getByText("登录后可以更懂你,推荐你喜欢的商品!");
Frame loginFrame = findLoginFrame(page, account);
if (Objects.nonNull(loginFrame)) {
// 开始登录
log.debug("【{}】找到登录框,开始模拟登录", account);
ElementHandle switchLink = loginFrame.querySelector("a.password-login-tab-item");
if (switchLink != null && switchLink.isVisible()) {
log.debug("【{}】切换密码登陆框", account);
switchLink.click(new ElementHandle.ClickOptions().setDelay(100));
log.debug("【{}】开始输入用户名...", account);
loginFrame.fill("#fm-login-id", "");
loginFrame.type("#fm-login-id", account, new Frame.TypeOptions().setDelay(100));
log.debug("【{}】开始输入密码...", account);
loginFrame.fill("#fm-login-password", "");
loginFrame.type("#fm-login-password", password, new Frame.TypeOptions().setDelay(100));
loginFrame.waitForTimeout(1000);
// 点击协议并登录
ElementHandle agreement = loginFrame.querySelector("#fm-agreement-checkbox");
if (agreement != null && !agreement.isChecked()) {
log.debug("【{}】正在查找协议复选框,并勾选......", account);
agreement.click();
}
log.debug("【{}】单击登录按钮...", account);
loginFrame.click("button.fm-button.fm-submit.password-login");
// 这里点击后可能并不能成功会出现滑块
if (Objects.nonNull(findLoginFrame(page, account))) {
// 内部应该要有重试处理滑块的逻辑
boolean resolveResult = attemptSolveSlider(loginFrame, account, new AtomicInteger(3));
log.debug("【{}】滑块结果: {}", account, resolveResult ? "成功" : "失败");
if(resolveResult) {
// 这里可能已经登录成功等待选择是否持久化登录
loginFrame.waitForTimeout(10000);
log.debug("【{}】验证是否登录成功...", account);
Frame lastCheckFrame = findLoginFrame(page, account);
if(Objects.nonNull(lastCheckFrame)) {
log.error("【{}】登录失败,仍存在登录页面...", account);
return;
}
} else {
log.error("【{}】账号密码登录失败", account);
return;
}
}
} else {
log.debug("【{}】未找到密码登录框,可能是持久化登录...", account);
}
} else {
// 可能是快捷登录按钮页等待 10s 查看右上角有没有登录信息
page.waitForTimeout(10000);
log.debug("【{}】开始启动浏览器自动化操作", account);
boolean loginResult = browserService.autoLoginUsingAccountAndPassword(context, account, password, showBrowser);
if (!loginResult) {
log.error("【{}】浏览器自动化操作失败", account);
return;
}
// 处理登录成功后的账号 cookie并存库更新persistentData 目录名称
@ -117,7 +52,7 @@ public class GoofishPwdLoginService {
GoofishAccountEntity entity = new GoofishAccountEntity();
entity.setId(String.valueOf(goofishUserId));
entity.setCookie(cookieStr);
entity.setUserId(systemUsername);
entity.setUserId(systemUserId);
entity.setAutoConfirm(1);
entity.setUsername(account);
entity.setNickname(goofishApiService.getNickName(account, cookieStr));
@ -133,51 +68,4 @@ public class GoofishPwdLoginService {
}
private Frame findLoginFrame(Page page, String account) {
String[] selectors = {
"#fm-login-id",
"input[name='fm-login-id']",
"input[placeholder*='手机号']",
"input[placeholder*='邮箱']",
".fm-login-id",
"#J_LoginForm input[type='text']"
};
// 1. Check Main Frame First
for (String s : selectors) {
try {
if (page.isVisible(s)) {
log.debug("【{}】在主框架中判断登录是否显示: {}", account, s);
return page.mainFrame();
}
if (page.querySelector(s) != null) { // Fallback check availability even if not visible yet
log.debug("【{}】在主框架中判断登录是否存在: {}", account, s);
return page.mainFrame();
}
} catch (Exception e) {
log.error("查找登录框异常:{}", e.getMessage(), e);
}
}
// 2. Check All Frames
for (Frame frame : page.frames()) {
for (String s : selectors) {
try {
if (frame.isVisible(s)) {
log.debug("【{}】在 Frame 中判断登录是否显示 ({}): {}", account, frame.url(), s);
return frame;
}
if (frame.querySelector(s) != null) {
log.debug("【{}】在 Frame 中判断登录是否存在({}): {}", account, frame.url(), s);
return frame;
}
} catch (Exception e) {
log.error("查找登录框异常:{}", e.getMessage(), e);
}
}
}
return null;
}
}

View File

@ -47,8 +47,8 @@ public final class CookieUtils {
if (StrUtil.isBlank(cookiesStr)) {
return cookieMap;
}
String[] parts = cookiesStr.split("; ");
cookiesStr = cookiesStr.replace(" ", "");
String[] parts = cookiesStr.split(";");
for (String part : parts) {
if (part.contains("=")) {
String[] kv = part.split("=", 2);

View File

@ -1,7 +1,6 @@
package top.biwin.xianyu.goofish.websocket;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSON;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
@ -32,7 +31,6 @@ import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.Semaphore;
@ -42,6 +40,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static top.biwin.xianyu.goofish.util.CookieUtils.buildCookieStr;
/**
* TODO
*
@ -91,7 +91,6 @@ public class GoofishAccountWebsocket extends TextWebSocketHandler {
BrowserService browserService,
GoofishApiService goofishApiService,
WebSocketConfiguration webSocketConfiguration,
// WebSocketContainer webSocketContainer,
@Qualifier("goofishAccountWebSocketExecutor") ScheduledExecutorService scheduledExecutorService) {
super();
this.goofishId = goofishId;
@ -136,14 +135,30 @@ public class GoofishAccountWebsocket extends TextWebSocketHandler {
this.userId = account.getUserId();
if (StrUtil.isBlank(cookiesStr)) {
log.error("【{}】Cookie值为空", goofishId);
log.error("【{}】Cookie值为空, 请先用到账号中心重新添加账号或手动更新 cookie", goofishId);
return false;
}
log.debug("【{}】验证 Cookie 是否有效", goofishId);
String accountName = goofishApiService.getAccount(account.getId(), cookiesStr);
if (!StringUtils.hasText(accountName)) {
// 说明 cookie 失效尝试重新登录
this.cookiesStr = browserService.refreshGoofishAccountCookie(account.getId(), account.getShowBrowser() == 1, 1000D, cookiesStr);
// 说明 cookie 失效尝试先用持久化浏览器数据刷新页面获取新的 cookie
log.debug("【{}】Cookie 已失效,尝试使用持久化浏览器数据获取新的 Cookie", goofishId);
this.cookiesStr = browserService.refreshGoofishAccountCookie(account.getId(), account.getShowBrowser() == 1, 1000D, null);
}
if (!StringUtils.hasText(cookiesStr)) {
log.debug("【{}】Cookie 未获取到,再尝试使用基础账号用户名密码进行自动登录获取新的 Cookie", goofishId);
// TODO 重新尝试使用 account 表的基础数据自动登录
BrowserContext browserContext = browserService.loadPersistentContext(account.getId(), account.getShowBrowser() == 1, 1000D, null);
browserService.autoLoginUsingAccountAndPassword(browserContext, account.getUsername(), account.getPassword(), account.getShowBrowser() == 1);
this.cookiesStr = buildCookieStr(browserContext);
browserContext.close();
Long goofishUserId = goofishApiService.getUserId(goofishId, cookiesStr);
if (goofishUserId > 0L) {
log.debug("【{}】Cookie 刷新成功", goofishId);
} else {
log.error("【{}】Cookie 刷新失败", goofishId);
return false;
}
}
// 解析Cookie
@ -398,6 +413,7 @@ public class GoofishAccountWebsocket extends TextWebSocketHandler {
try {
currentToken = goofishApiService.getWsToken(goofishId, cookiesStr, deviceId);
this.lastTokenRefreshTime.set(System.currentTimeMillis());
log.info("【{}】Token刷新调用完成currentToken={}", goofishId, currentToken != null ? "已获取" : "未获取");
} catch (Exception e) {
log.error("【{}】Token刷新过程出错: {}", goofishId, e.getMessage(), e);
@ -423,7 +439,7 @@ public class GoofishAccountWebsocket extends TextWebSocketHandler {
regHeaders.put("cache-header", "app-key token ua wv");
regHeaders.put("app-key", webSocketConfiguration.getAppKey());
regHeaders.put("token", currentToken);
regHeaders.put("ua", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0");
regHeaders.put("ua", webSocketConfiguration.getUa());
regHeaders.put("dt", "j");
regHeaders.put("wv", "im:3,au:3,sy:6");
regHeaders.put("sync", "0,0;0;0;");
@ -443,6 +459,24 @@ public class GoofishAccountWebsocket extends TextWebSocketHandler {
log.info("【{}】等待1秒...", goofishId);
Thread.sleep(1000);
log.info("【{}】准备发送 /getState 消息...", goofishId);
JSONObject getState = new JSONObject();
getState.put("lwp", "/r/SyncStatus/getState");
JSONObject stateHeader = new JSONObject();
stateHeader.put("mid", XianyuUtils.generateMid());
getState.put("headers", stateHeader);
JSONArray stateBody = new JSONArray();
JSONObject bodyTopic = new JSONObject();
bodyTopic.put("topic", "sync");
stateBody.put(bodyTopic);
getState.put("body", stateBody);
try {
session.sendMessage(new TextMessage(getState.toString()));
log.info("【{}】✅ /getState 消息已发送", goofishId);
} catch (Exception e) {
log.error("【{}】❌ 发送 /getState 消息失败: {}", goofishId, e.getMessage(), e);
throw e;
}
// 发送 /ackDiff 消息
log.info("【{}】准备发送 /ackDiff 消息...", goofishId);

View File

@ -1,7 +1,8 @@
package top.biwin.xianyu.goofish.websocket;
import lombok.Data;
import org.springframework.context.annotation.Configuration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* TODO
@ -10,7 +11,8 @@ import org.springframework.context.annotation.Configuration;
* @since 2026-02-01 10:01
*/
@Data
@Configuration(proxyBeanMethods = false, value = "goofish.websocket")
@Component
@ConfigurationProperties(prefix = "goofish.websocket")
public class WebSocketConfiguration {
private String appKey;
private String wsUrl;
@ -18,4 +20,5 @@ public class WebSocketConfiguration {
private Integer heartbeatInterval;
private Integer cleanupInterval;
private Integer cookieRefreshInterval;
private String ua;
}

View File

@ -0,0 +1,80 @@
package top.biwin.xianyu.goofish.websocket;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import top.biwin.xianyu.core.entity.GoofishAccountEntity;
import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.goofish.service.BrowserService;
import top.biwin.xianyu.goofish.service.GoofishApiService;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
/**
* TODO
*
* @author wangli
* @since 2026-02-02 22:00
*/
@Slf4j
@Component
public class WebSocketStarter {
private final Map<String, GoofishAccountWebsocket> sockets = new ConcurrentHashMap<>();
@Autowired
private GoofishAccountRepository goofishAccountRepository;
@Autowired
private BrowserService browserService;
@Autowired
private GoofishApiService goofishApiService;
@Autowired
private WebSocketConfiguration webSocketConfiguration;
@Autowired
@Qualifier("goofishAccountWebSocketExecutor")
private ScheduledExecutorService goofishAccountWebSocketExecutor;
@PostConstruct
public void init() {
log.info("Start all goofish account websocket ...");
List<GoofishAccountEntity> accounts = goofishAccountRepository.findAll();
accounts.stream()
.filter(i -> Objects.equals(i.getEnabled(), Boolean.TRUE))
.forEach(i -> {
startWebSocket(i.getId());
});
}
public void startWebSocket(String goofishId) {
if (sockets.containsKey(goofishId)) {
log.warn("【{}】WebSocket already running", goofishId);
return;
}
GoofishAccountWebsocket websocket = new GoofishAccountWebsocket(goofishId,
goofishAccountRepository,
browserService,
goofishApiService,
webSocketConfiguration,
goofishAccountWebSocketExecutor);
sockets.put(goofishId, websocket);
websocket.start();
}
public void closeWebSocket(String goofishId) {
getWebSocket(goofishId)
.ifPresent(socket -> {
socket.stop();
sockets.remove(goofishId);
});
}
public Optional<GoofishAccountWebsocket> getWebSocket(String goofishId) {
return Optional.ofNullable(sockets.getOrDefault(goofishId, null));
}
}

View File

@ -3,6 +3,7 @@ package top.biwin.xinayu.server;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
@ -18,6 +19,7 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = "top.biwin")
@EntityScan(basePackages = "top.biwin.xianyu.core.entity")
@EnableJpaRepositories(basePackages = "top.biwin.xianyu.core.repository")
//@EnableConfigurationProperties
public class XianyuFreedomApplication {
public static void main(String[] args) {
SpringApplication.run(XianyuFreedomApplication.class, args);

View File

@ -69,7 +69,10 @@ goofish:
appKey: 444e9908a51d1cb236a27862abc769c9
wsUrl: wss://wss-goofish.dingtalk.com/
tokenRefreshInterval: 72000
heartbeatInterval: 30
cookieRefreshInterval: 1200
heartbeatInterval: 15
ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0 DingTalk(2.2.0) OS(Mac OS/10.15.7) Browser(Edge/144.0.0.0) DingWeb/2.2.0 IMPaaS DingWeb/2.2.0'
cleanupInterval: 300
assistant:
static: