This commit is contained in:
wangli 2026-01-18 00:43:10 +08:00
parent 619f8da08f
commit 3e1e1cfc13
9 changed files with 2048 additions and 740 deletions

View File

@ -2,10 +2,12 @@ package com.xianyu.autoreply.config;
import com.xianyu.autoreply.websocket.CaptchaWebSocketHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
@Configuration
@EnableWebSocket
@ -27,4 +29,29 @@ public class WebSocketConfig implements WebSocketConfigurer {
registry.addHandler(captchaWebSocketHandler, "/api/captcha/ws/*")
.setAllowedOrigins("*");
}
/**
* 配置WebSocket容器参数解决"消息过大"导致的1009错误
*
* 设置最大文本消息缓冲区10MB
* 设置最大二进制消息缓冲区10MB
* 设置会话空闲超时30分钟
*
* @return ServletServerContainerFactoryBean配置实例
*/
@Bean
public ServletServerContainerFactoryBean createWebSocketContainer() {
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
// 设置最大文本消息缓冲区大小为10MB默认8192字节太小会导致1009错误
container.setMaxTextMessageBufferSize(10 * 1024 * 1024);
// 设置最大二进制消息缓冲区大小为10MB
container.setMaxBinaryMessageBufferSize(10 * 1024 * 1024);
// 设置会话空闲超时时间毫秒30分钟无活动则关闭连接
container.setMaxSessionIdleTimeout(30L * 60 * 1000);
return container;
}
}

View File

@ -27,9 +27,12 @@ public class BrowserService {
private final CookieRepository cookieRepository;
private Playwright playwright;
private Browser browser;
// 为每个账号维护持久化浏览器上下文用于Cookie刷新
private final Map<String, BrowserContext> persistentContexts = new ConcurrentHashMap<>();
private final Map<String, BrowserContext> persistentContexts = new ConcurrentHashMap<>();
// 为每个账号维护同步锁防止并发创建持久化上下文
private final Map<String, Object> contextLocks = new ConcurrentHashMap<>();
@Autowired
public BrowserService(CookieRepository cookieRepository) {
@ -42,7 +45,7 @@ public class BrowserService {
log.info("Initializing Playwright...");
playwright = Playwright.create();
log.info("Playwright created.");
// Initialize global browser for refreshCookies usages
List<String> args = new ArrayList<>();
args.add("--no-sandbox");
@ -54,9 +57,9 @@ public class BrowserService {
args.add("--mute-audio");
args.add("--disable-blink-features=AutomationControlled");
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(args);
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(args);
String osName = System.getProperty("os.name").toLowerCase();
String osArch = System.getProperty("os.arch").toLowerCase();
@ -67,7 +70,7 @@ public class BrowserService {
}
}
browser = playwright.chromium().launch(launchOptions);
} catch (Exception e) {
log.error("Failed to initialize Playwright", e);
throw new RuntimeException("Failed to initialize Playwright", e);
@ -77,10 +80,10 @@ public class BrowserService {
@PreDestroy
private void close() {
log.info("Releasing Playwright resources...");
// 关闭所有持久化上下文
closeAllPersistentContexts();
if (browser != null) {
browser.close();
}
@ -90,6 +93,17 @@ public class BrowserService {
log.info("Playwright resources released.");
}
/**
* 获取共享的Browser实例
* 供CaptchaHandler等服务复用避免多实例冲突
*/
public Browser getSharedBrowser() {
if (browser == null) {
throw new IllegalStateException("Browser尚未初始化请检查Playwright初始化逻辑");
}
return browser;
}
// ---------------- Password Login Logic ----------------
private final Map<String, Map<String, Object>> passwordLoginSessions = new ConcurrentHashMap<>();
@ -133,9 +147,9 @@ public class BrowserService {
BrowserType.LaunchPersistentContextOptions options = new BrowserType.LaunchPersistentContextOptions()
.setHeadless(!showBrowser)
.setArgs(args)
.setViewportSize(1920, 1080)
// .setViewportSize(1920, 1080)
// 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
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.setUserAgent("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")
.setLocale("zh-CN")
.setAcceptDownloads(true)
.setIgnoreHTTPSErrors(true);
@ -143,51 +157,51 @@ public class BrowserService {
String osName = System.getProperty("os.name").toLowerCase();
String osArch = System.getProperty("os.arch").toLowerCase();
if (osName.contains("mac") && osArch.contains("aarch64")) {
Path chromePath = Paths.get("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
if (chromePath.toFile().exists()) {
options.setExecutablePath(chromePath);
}
Path chromePath = Paths.get("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
if (chromePath.toFile().exists()) {
options.setExecutablePath(chromePath);
}
}
log.info("【Login Task】Launching browser context with userDataDir: {}", userDataDir);
context = playwright.chromium().launchPersistentContext(java.nio.file.Paths.get(userDataDir), options);
Page page = context.pages().isEmpty() ? context.newPage() : context.pages().get(0);
page.addInitScript(BrowserStealth.STEALTH_SCRIPT);
session.put("message", "正在导航至登录页...");
log.info("【Login Task】Navigating to https://www.goofish.com/im");
page.navigate("https://www.goofish.com/im");
// Wait for network idle to ensure frames loaded
try {
page.waitForLoadState(LoadState.NETWORKIDLE, new Page.WaitForLoadStateOptions().setTimeout(10000));
} catch (Exception e) {
log.warn("【Login Task】Network idle timeout, proceeding...");
log.warn("【Login Task】Network idle timeout, proceeding...");
}
Thread.sleep(2000);
// 1. Check if already logged in
if (checkLoginSuccessByElement(page)) {
log.info("【Login Task】Already logged in detected immediately.");
handleLoginSuccess(page, context, accountId, account, password, showBrowser, userId, session);
return;
log.info("【Login Task】Already logged in detected immediately.");
handleLoginSuccess(page, context, accountId, account, password, showBrowser, userId, session);
return;
}
session.put("message", "正在查找登录表单...");
log.info("【Login Task】Searching for login frame...");
// 2. Robust Frame Search (Main Page OR Frames)
Frame loginFrame = findLoginFrame(page);
// Retry logic for finding frame
if (loginFrame == null) {
log.info("【Login Task】Login frame not found, waiting 3s and retrying...");
Thread.sleep(3000); // Wait more
loginFrame = findLoginFrame(page);
}
if (loginFrame != null) {
log.info("【Login Task】Found login form in frame: {}", loginFrame.url());
// Switch to password login
@ -197,11 +211,11 @@ public class BrowserService {
if (switchLink == null || !switchLink.isVisible()) {
switchLink = loginFrame.querySelector("a.password-login-tab-item");
}
if (switchLink != null && switchLink.isVisible()) {
log.info("【Login Task】Clicking password switch link...");
switchLink.click();
Thread.sleep(1000);
log.info("【Login Task】Clicking password switch link...");
switchLink.click();
Thread.sleep(1000);
} else {
log.info("【Login Task】Password switch link not found or not visible, assuming possibly already on password tab or different layout.");
}
@ -211,92 +225,93 @@ public class BrowserService {
session.put("message", "正在输入账号密码...");
log.info("【Login Task】Inputting credentials for user: {}", account);
// Clear and Fill with human delay
loginFrame.fill("#fm-login-id", "");
loginFrame.fill("#fm-login-id", "");
Thread.sleep(200);
loginFrame.type("#fm-login-id", account, new Frame.TypeOptions().setDelay(100)); // Type like human
Thread.sleep(500 + new Random().nextInt(500));
loginFrame.fill("#fm-login-password", "");
Thread.sleep(200);
loginFrame.type("#fm-login-password", password, new Frame.TypeOptions().setDelay(100));
Thread.sleep(500 + new Random().nextInt(500));
try {
ElementHandle agreement = loginFrame.querySelector("#fm-agreement-checkbox");
if (agreement != null && !agreement.isChecked()) {
log.info("【Login Task】Checking agreement checkbox...");
agreement.click();
}
} catch (Exception e) {}
} catch (Exception e) {
}
session.put("message", "正在点击登录...");
log.info("【Login Task】Clicking submit button...");
loginFrame.click("button.fm-button.fm-submit.password-login");
Thread.sleep(3000);
Thread.sleep(3000);
} else {
if (checkLoginSuccessByElement(page)) {
log.info("【Login Task】Login frame not found but seems logged in.");
handleLoginSuccess(page, context, accountId, account, password, showBrowser, userId, session);
return;
}
log.error("【Login Task】Login form NOT found after retries. Page title: {}, URL: {}", page.title(), page.url());
session.put("status", "failed");
session.put("message", "无法找到登录框 (URL: " + page.url() + ")");
// Capture screenshot for debugging if possible? (not easy to send back via session map safely)
return;
if (checkLoginSuccessByElement(page)) {
log.info("【Login Task】Login frame not found but seems logged in.");
handleLoginSuccess(page, context, accountId, account, password, showBrowser, userId, session);
return;
}
log.error("【Login Task】Login form NOT found after retries. Page title: {}, URL: {}", page.title(), page.url());
session.put("status", "failed");
session.put("message", "无法找到登录框 (URL: " + page.url() + ")");
// Capture screenshot for debugging if possible? (not easy to send back via session map safely)
return;
}
// Post-login / Slider Loop
session.put("message", "正在检测登录状态与滑块...");
log.info("【Login Task】Entering post-submission monitor loop...");
long startTime = System.currentTimeMillis();
long maxWaitTime = 450 * 1000L;
long maxWaitTime = 450 * 1000L;
if (!showBrowser) maxWaitTime = 60 * 1000L;
boolean success = false;
while (System.currentTimeMillis() - startTime < maxWaitTime) {
if (checkLoginSuccessByElement(page)) {
log.info("【Login Task】Login Success Detected!");
success = true;
break;
}
boolean sliderFound = solveSliderRecursively(page);
if (sliderFound) {
session.put("message", "正在处理滑块验证...");
log.info("【Login Task】Slider solved, page reloading for check...");
Thread.sleep(3000);
page.reload();
Thread.sleep(2000);
continue;
}
session.put("message", "正在处理滑块验证...");
log.info("【Login Task】Slider solved, page reloading for check...");
Thread.sleep(3000);
page.reload();
Thread.sleep(2000);
continue;
}
String content = page.content();
if (content.contains("验证") || content.contains("安全检测") || content.contains("security-check")) {
log.warn("【Login Task】Security verification required (SMS/Face).");
session.put("status", "verification_required");
session.put("message", "需要二次验证(短信/人脸),请手动在浏览器中完成");
if (!showBrowser) {
session.put("status", "failed");
session.put("message", "需要验证但处于无头模式,无法手动处理");
return;
}
}
if (content.contains("账号名或登录密码不正确") || content.contains("账密错误")) {
log.error("【Login Task】Invalid credentials detected.");
session.put("status", "failed");
session.put("message", "账号名或登录密码不正确");
return;
}
String content = page.content();
if (content.contains("验证") || content.contains("安全检测") || content.contains("security-check")) {
log.warn("【Login Task】Security verification required (SMS/Face).");
session.put("status", "verification_required");
session.put("message", "需要二次验证(短信/人脸),请手动在浏览器中完成");
if (!showBrowser) {
session.put("status", "failed");
session.put("message", "需要验证但处于无头模式,无法手动处理");
return;
}
}
Thread.sleep(2000);
if (content.contains("账号名或登录密码不正确") || content.contains("账密错误")) {
log.error("【Login Task】Invalid credentials detected.");
session.put("status", "failed");
session.put("message", "账号名或登录密码不正确");
return;
}
Thread.sleep(2000);
}
if (success) {
@ -314,20 +329,20 @@ public class BrowserService {
} finally {
if (context != null) {
log.info("【Login Task】Closing browser context.");
context.close();
context.close();
}
}
}
// Updated robust findFrame logic matching Python's selector list
private Frame findLoginFrame(Page page) {
String[] selectors = {
"#fm-login-id",
"input[name='fm-login-id']",
"input[placeholder*='手机号']",
"input[placeholder*='邮箱']",
".fm-login-id",
"#J_LoginForm input[type='text']"
"#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
@ -338,28 +353,30 @@ public class BrowserService {
return page.mainFrame();
}
if (page.querySelector(s) != null) { // Fallback check availability even if not visible yet
log.info("【Login Task】Found login element in Main Frame (hidden?): {}", s);
return page.mainFrame();
log.info("【Login Task】Found login element in Main Frame (hidden?): {}", s);
return page.mainFrame();
}
} catch (Exception e) {}
} catch (Exception e) {
}
}
// 2. Check All Frames
for (Frame frame : page.frames()) {
for (String s : selectors) {
try {
if (frame.isVisible(s)) {
log.info("【Login Task】Found login element in Frame ({}): {}", frame.url(), s);
return frame;
}
if (frame.querySelector(s) != null) {
log.info("【Login Task】Found login element in Frame ({}) (hidden?): {}", frame.url(), s);
return frame;
}
} catch (Exception e) {}
if (frame.isVisible(s)) {
log.info("【Login Task】Found login element in Frame ({}): {}", frame.url(), s);
return frame;
}
if (frame.querySelector(s) != null) {
log.info("【Login Task】Found login element in Frame ({}) (hidden?): {}", frame.url(), s);
return frame;
}
} catch (Exception e) {
}
}
}
return null;
}
@ -369,10 +386,10 @@ public class BrowserService {
if (element != null && element.isVisible()) {
log.info("【Login Task】Success Indicator Found: .rc-virtual-list-holder-inner");
Object childrenCount = element.evaluate("el => el.children.length");
if (childrenCount instanceof Number && ((Number)childrenCount).intValue() > 0) {
if (childrenCount instanceof Number && ((Number) childrenCount).intValue() > 0) {
return true;
}
return true;
return true;
}
if (page.url().contains("goofish.com/im") && page.querySelector("#fm-login-id") == null) {
log.info("【Login Task】Success Indicator: On IM page and login input is gone.");
@ -383,15 +400,15 @@ public class BrowserService {
}
return false;
}
private void handleLoginSuccess(Page page, BrowserContext context, String accountId, String account, String password, boolean showBrowser, Long userId, Map<String, Object> session) {
session.put("message", "登录成功正在获取Cookie...");
log.info("【Login Task】Login Success confirmed. Extracting cookies...");
List<com.microsoft.playwright.options.Cookie> cookies = new ArrayList<>();
int retries = 10;
boolean unbFound = false;
while (retries-- > 0) {
cookies = context.cookies();
unbFound = cookies.stream().anyMatch(c -> "unb".equals(c.name) && c.value != null && !c.value.isEmpty());
@ -399,11 +416,14 @@ public class BrowserService {
log.info("【Login Task】Crucial 'unb' cookie found!");
break;
}
try { Thread.sleep(1000); } catch (Exception e) {}
try {
Thread.sleep(1000);
} catch (Exception e) {
}
}
if (!unbFound) {
log.warn("【Login Task】Login seemed successful but 'unb' cookie missing for {}. Cookies found: {}", accountId, cookies.size());
log.warn("【Login Task】Login seemed successful but 'unb' cookie missing for {}. Cookies found: {}", accountId, cookies.size());
}
StringBuilder sb = new StringBuilder();
@ -411,9 +431,9 @@ public class BrowserService {
sb.append(c.name).append("=").append(c.value).append("; ");
}
String cookieStr = sb.toString();
log.info("【Login Task】Total Cookies captured: {}", cookies.size());
Cookie cookie = cookieRepository.findById(accountId).orElse(new Cookie());
cookie.setId(accountId);
cookie.setValue(cookieStr);
@ -423,7 +443,7 @@ public class BrowserService {
cookie.setUserId(userId);
cookie.setEnabled(true);
cookieRepository.save(cookie);
session.put("status", "success");
session.put("message", "登录成功");
session.put("username", account);
@ -443,56 +463,59 @@ public class BrowserService {
try {
String[] sliderSelectors = {"#nc_1_n1z", ".nc-container", ".nc_scale", ".nc-wrapper"};
ElementHandle sliderButton = null;
boolean containerFound = false;
for (String s : sliderSelectors) {
if (frame.querySelector(s) != null && frame.isVisible(s)) {
containerFound = true;
break;
containerFound = true;
break;
}
}
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());
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);
List<BrowserTrajectoryUtils.TrajectoryPoint> trajectory =
BrowserTrajectoryUtils.generatePhysicsTrajectory(distance);
double startX = box.x + box.width / 2;
double startY = box.y + box.height / 2;
frame.page().mouse().move(startX, startY);
frame.page().mouse().down();
for (BrowserTrajectoryUtils.TrajectoryPoint p : trajectory) {
frame.page().mouse().move(startX + p.x, startY + p.y);
if (p.delay > 0.001) {
try { Thread.sleep((long)(p.delay * 1000)); } catch (Exception e) {}
}
}
frame.page().mouse().up();
Thread.sleep(1000);
if (!sliderButton.isVisible()) {
log.info("【Login Task】Slider solved (button disappeared)!");
return true;
}
return true;
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);
List<BrowserTrajectoryUtils.TrajectoryPoint> trajectory =
BrowserTrajectoryUtils.generatePhysicsTrajectory(distance);
double startX = box.x + box.width / 2;
double startY = box.y + box.height / 2;
frame.page().mouse().move(startX, startY);
frame.page().mouse().down();
for (BrowserTrajectoryUtils.TrajectoryPoint p : trajectory) {
frame.page().mouse().move(startX + p.x, startY + p.y);
if (p.delay > 0.001) {
try {
Thread.sleep((long) (p.delay * 1000));
} catch (Exception e) {
}
}
}
frame.page().mouse().up();
Thread.sleep(1000);
if (!sliderButton.isVisible()) {
log.info("【Login Task】Slider solved (button disappeared)!");
return true;
}
return true;
}
} catch (Exception e) {
log.warn("【Login Task】Error solving slider: {}", e.getMessage());
@ -505,69 +528,84 @@ public class BrowserService {
* Cookie会自动保存到UserData目录类似真实浏览器行为
*/
public Map<String, String> refreshCookies(String cookieId) {
log.info("Cookie Refresh】开始刷新Cookie for id: {}", cookieId);
log.info("{}-Cookie Refresh】开始刷新Cookie for id: {}", cookieId, cookieId);
Cookie cookie = cookieRepository.findById(cookieId).orElse(null);
if (cookie == null || cookie.getValue() == null) {
log.error("Cookie Refresh】无法刷新Cookie不存在: {}", cookieId);
return Collections.emptyMap();
log.error("{}-Cookie Refresh】无法刷新Cookie不存在: {}", cookieId, cookieId);
return Collections.emptyMap();
}
Page page = null;
try {
// 1. 获取或创建持久化上下文Cookie自动从UserData加载
BrowserContext context = getPersistentContext(cookieId);
log.info("【Cookie Refresh】已获取持久化上下文: {}", cookieId);
// 2. 创建新页面并访问闲鱼
page = context.newPage();
log.info("【{}-Cookie Refresh】已获取持久化上下文: {}", cookieId, cookieId);
// 2. 创建新页面并访问闲鱼增加容错处理
try {
page = context.newPage();
} catch (Exception e) {
log.error("【{}-Cookie Refresh】创建Page失败上下文可能已损坏强制重建", cookieId, e);
closeAndRemoveContext(cookieId);
// 重新获取上下文
context = getPersistentContext(cookieId);
page = context.newPage();
}
addStealthScripts(page);
String targetUrl = "https://www.goofish.com/im";
log.info("【Cookie Refresh】导航到: {}", targetUrl);
log.info("{}-Cookie Refresh】导航到: {}", cookieId, targetUrl);
try {
page.navigate(targetUrl, new Page.NavigateOptions()
.setTimeout(20000)
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
} catch (Exception e) {
log.warn("Cookie Refresh】导航超时尝试降级...");
log.warn("{}-Cookie Refresh】导航超时尝试降级...", cookieId);
try {
page.navigate(targetUrl, new Page.NavigateOptions()
.setTimeout(30000)
.setWaitUntil(WaitUntilState.LOAD));
} catch (Exception ex) {
log.warn("Cookie Refresh】降级导航也超时继续执行");
log.warn("{}-Cookie Refresh】降级导航也超时继续执行", cookieId);
}
}
// 3. 等待页面加载
try { Thread.sleep(3000); } catch (Exception e) {}
try {
Thread.sleep(3000);
} catch (Exception e) {
}
// 4. 重新加载页面以触发Cookie刷新
log.info("【Cookie Refresh】重新加载页面...");
log.info("{}-Cookie Refresh】重新加载页面...", cookieId);
try {
page.reload(new Page.ReloadOptions()
.setTimeout(20000)
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
page.reload(new Page.ReloadOptions()
.setTimeout(20000)
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
} catch (Exception e) {
log.warn("【{}-Cookie Refresh】重新加载超时继续执行", cookieId);
}
try {
Thread.sleep(2000);
} catch (Exception e) {
log.warn("【Cookie Refresh】重新加载超时继续执行");
}
try { Thread.sleep(2000); } catch (Exception e) {}
// 5. 获取刷新后的Cookie从持久化上下文中获取
List<com.microsoft.playwright.options.Cookie> newCookies = context.cookies();
log.info("Cookie Refresh】获取到 {} 个Cookie", newCookies.size());
log.info("{}-Cookie Refresh】获取到 {} 个Cookie", cookieId, newCookies.size());
// 6. 构建Cookie Map
Map<String, String> newCookieMap = new HashMap<>();
for (com.microsoft.playwright.options.Cookie c : newCookies) {
newCookieMap.put(c.name, c.value);
}
// 7. 验证必要Cookie
if (!newCookieMap.containsKey("unb")) {
log.warn("Cookie Refresh】刷新后的Cookie缺少'unb'字段,可能已失效");
return Collections.emptyMap();
log.warn("{}-Cookie Refresh】刷新后的Cookie缺少'unb'字段,可能已失效", cookieId);
return Collections.emptyMap();
}
// 8. 构建Cookie字符串并保存到数据库
@ -576,31 +614,31 @@ public class BrowserService {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("; ");
}
String newCookieStr = sb.toString();
// 9. 更新数据库
if (!newCookieStr.equals(cookie.getValue())) {
cookie.setValue(newCookieStr);
cookieRepository.save(cookie);
log.info("Cookie Refresh】✅ Cookie已更新并保存到数据库: {}", cookieId);
log.info("{}-Cookie Refresh】✅ Cookie已更新并保存到数据库: {}", cookieId, cookieId);
} else {
log.info("Cookie Refresh】Cookie未变化无需更新数据库");
log.info("{}-Cookie Refresh】Cookie未变化无需更新数据库", cookieId);
}
// 10. Cookie已自动保存到UserData目录持久化
log.info("Cookie Refresh】✅ Cookie刷新完成已持久化到磁盘: {}", cookieId);
log.info("{}-Cookie Refresh】✅ Cookie刷新完成已持久化到磁盘: {}", cookieId, cookieId);
return newCookieMap;
} catch (Exception e) {
log.error("Cookie Refresh】❌ 刷新Cookie异常: {}", cookieId, e);
log.error("{}-Cookie Refresh】❌ 刷新Cookie异常: {}", cookieId, cookieId, e);
return Collections.emptyMap();
} finally {
// 关闭页面但保持上下文保持持久化状态
if (page != null) {
try {
page.close();
log.debug("Cookie Refresh】页面已关闭: {}", cookieId);
log.debug("{}-Cookie Refresh】页面已关闭: {}", cookieId, cookieId);
} catch (Exception e) {
log.error("Cookie Refresh】关闭页面失败", e);
log.error("{}-Cookie Refresh】关闭页面失败", cookieId, e);
}
}
}
@ -612,11 +650,12 @@ public class BrowserService {
*/
public Map<String, String> verifyQrLoginCookies(Map<String, String> qrCookies, String accountId) {
log.info("【QR Login】Verifying cookies for account: {}", accountId);
try (BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.setViewportSize(1920, 1080))) {
.setUserAgent("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")
// .setViewportSize(1920, 1080)
)) {
// 1. Add Cookies
List<com.microsoft.playwright.options.Cookie> playwrightCookies = new ArrayList<>();
for (Map.Entry<String, String> entry : qrCookies.entrySet()) {
@ -625,18 +664,18 @@ public class BrowserService {
.setPath("/"));
}
context.addCookies(playwrightCookies);
// 2. Navigate to verify
Page page = context.newPage();
try {
log.info("【QR Login】Navigating to goofish.com to verify login...");
page.navigate("https://www.goofish.com/");
page.waitForLoadState();
// Wait for some login indicator
// Python uses: page.wait_for_selector(".mod-user-info", timeout=5000) or similar checks
// Let's try to detect if we are logged in.
// Using the selector from checkLoginSuccessByElement as a reference
try {
page.waitForSelector(".rc-virtual-list-holder-inner", new Page.WaitForSelectorOptions().setTimeout(5000));
@ -644,11 +683,11 @@ public class BrowserService {
} catch (Exception e) {
log.warn("【QR Login】Could not find standard user element. Checking cookie presence again.");
}
// 3. Capture refreshed cookies
List<com.microsoft.playwright.options.Cookie> freshCookies = context.cookies();
boolean unbFound = freshCookies.stream().anyMatch(c -> "unb".equals(c.name));
if (unbFound) {
log.info("【QR Login】Verification passed. UNB found. Total cookies: {}", freshCookies.size());
Map<String, String> resultMap = new HashMap<>();
@ -657,51 +696,66 @@ public class BrowserService {
}
return resultMap;
} else {
log.warn("【QR Login】Verification failed - UNB cookie missing after navigation.");
log.warn("【QR Login】Verification failed - UNB cookie missing after navigation.");
}
} catch (Exception e) {
log.error("【QR Login】Error during browser verification navigation", e);
}
} catch (Exception e) {
log.error("【QR Login】Error creating browser context for verification", e);
}
return null; // Failed
}
// ================== 持久化浏览器上下文管理 ==================
/**
* 获取或创建账号的持久化浏览器上下文
* 使用持久化上下文可以将Cookie保存到磁盘类似真实浏览器行为
*/
private BrowserContext getPersistentContext(String cookieId) {
// 如果已存在直接返回
// 获取或创建该账号的同步锁
Object lock = contextLocks.computeIfAbsent(cookieId, k -> new Object());
// 使用同步锁防止并发创建同一个上下文
synchronized (lock) {
return getPersistentContextInternal(cookieId);
}
}
/**
* 内部方法实际执行获取或创建上下文的逻辑
*/
private BrowserContext getPersistentContextInternal(String cookieId) {
// 如果已存在进行深度验证
BrowserContext existingContext = persistentContexts.get(cookieId);
if (existingContext != null) {
try {
// 验证上下文是否仍然有效
existingContext.pages();
log.debug("【Cookie Refresh】复用已存在的持久化上下文: {}", cookieId);
// 深度验证尝试创建临时Page测试上下文是否仍然有效
Page testPage = existingContext.newPage();
testPage.close();
log.debug("【{}-Cookie Refresh】🤖持久化上下文仍然有效复用: {}", cookieId, cookieId);
return existingContext;
} catch (Exception e) {
// 上下文已失效移除并重新创建
log.warn("【Cookie Refresh】持久化上下文已失效重新创建: {}", cookieId);
persistentContexts.remove(cookieId);
log.warn("【{}-Cookie Refresh】持久化上下文已失效,强制重建: {}. 错误: {}", cookieId, cookieId, e.getMessage());
closeAndRemoveContext(cookieId);
// 继续创建新上下文
}
}
// 创建新的持久化上下文
try {
String userDataDir = "browser_data/cookie_refresh/" + cookieId;
java.nio.file.Path userDataPath = java.nio.file.Paths.get(userDataDir);
// 确保目录存在
java.nio.file.Files.createDirectories(userDataPath);
log.info("Cookie Refresh】创建UserData目录: {}", userDataDir);
log.info("{}-Cookie Refresh】创建UserData目录: {}", cookieId, userDataDir);
// 配置启动选项
List<String> args = new ArrayList<>();
args.add("--no-sandbox");
@ -710,16 +764,17 @@ public class BrowserService {
args.add("--disable-gpu");
args.add("--disable-blink-features=AutomationControlled");
args.add("--lang=zh-CN");
Cookie cookie = cookieRepository.findById(cookieId).orElse(null);
BrowserType.LaunchPersistentContextOptions options = new BrowserType.LaunchPersistentContextOptions()
.setHeadless(true)
.setHeadless(Objects.isNull(cookie) || !Objects.equals(cookie.getShowBrowser(), 1))
.setArgs(args)
.setViewportSize(1920, 1080)
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
// .setViewportSize(1920, 1080)
.setUserAgent("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")
.setLocale("zh-CN")
.setAcceptDownloads(false)
.setIgnoreHTTPSErrors(true);
// macOS ARM架构特殊处理
String osName = System.getProperty("os.name").toLowerCase();
String osArch = System.getProperty("os.arch").toLowerCase();
@ -729,12 +784,11 @@ public class BrowserService {
options.setExecutablePath(chromePath);
}
}
log.info("Cookie Refresh】创建持久化浏览器上下文: {}", cookieId);
log.info("{}-Cookie Refresh】创建持久化浏览器上下文: {}", cookieId, cookieId);
BrowserContext context = playwright.chromium().launchPersistentContext(userDataPath, options);
// 首次创建时需要设置Cookie
Cookie cookie = cookieRepository.findById(cookieId).orElse(null);
if (cookie != null && cookie.getValue() != null) {
// 解析并添加Cookie
List<com.microsoft.playwright.options.Cookie> playwrightCookies = new ArrayList<>();
@ -748,20 +802,65 @@ public class BrowserService {
}
}
context.addCookies(playwrightCookies);
log.info("Cookie Refresh】已设置初始Cookie: {} 个", playwrightCookies.size());
log.info("{}-Cookie Refresh】已设置初始Cookie: {} 个", cookieId, playwrightCookies.size());
}
// 缓存上下文
persistentContexts.put(cookieId, context);
return context;
} catch (Exception e) {
log.error("Cookie Refresh】创建持久化上下文失败: {}", cookieId, e);
log.error("{}-Cookie Refresh】创建持久化上下文失败: {}", cookieId, cookieId, e);
throw new RuntimeException("创建持久化浏览器上下文失败", e);
}
}
/**
* 关闭并移除持久化上下文
*/
private void closeAndRemoveContext(String cookieId) {
BrowserContext ctx = persistentContexts.remove(cookieId);
if (ctx != null) {
try {
ctx.close();
log.info("【{}-Cookie Refresh】已关闭失效的持久化上下文: {}", cookieId, cookieId);
} catch (Exception e) {
log.warn("【{}-Cookie Refresh】关闭失效上下文时出错: {}", cookieId, cookieId, e);
}
}
// 删除整个 UserData 目录包括 SingletonLock 文件
try {
String userDataDir = "browser_data/cookie_refresh/" + cookieId;
java.nio.file.Path userDataPath = java.nio.file.Paths.get(userDataDir);
if (java.nio.file.Files.exists(userDataPath)) {
deleteDirectory(userDataPath);
log.info("【{}-Cookie Refresh】已删除UserData目录: {}", cookieId, userDataDir);
}
} catch (Exception e) {
log.warn("【{}-Cookie Refresh】删除UserData目录失败: {}", cookieId, e.getMessage());
}
}
/**
* 递归删除目录
*/
private void deleteDirectory(java.nio.file.Path path) throws java.io.IOException {
if (java.nio.file.Files.isDirectory(path)) {
try (java.util.stream.Stream<java.nio.file.Path> stream = java.nio.file.Files.walk(path)) {
stream.sorted(java.util.Comparator.reverseOrder())
.forEach(p -> {
try {
java.nio.file.Files.delete(p);
} catch (java.io.IOException e) {
log.warn("删除文件失败: {}", p, e);
}
});
}
}
}
/**
* 关闭指定账号的持久化上下文
*/
@ -770,13 +869,13 @@ public class BrowserService {
if (context != null) {
try {
context.close();
log.info("Cookie Refresh】已关闭持久化上下文: {}", cookieId);
log.info("{}-Cookie Refresh】已关闭持久化上下文: {}", cookieId, cookieId);
} catch (Exception e) {
log.error("Cookie Refresh】关闭持久化上下文失败: {}", cookieId, e);
log.error("{}-Cookie Refresh】关闭持久化上下文失败: {}", cookieId, cookieId, e);
}
}
}
/**
* 关闭所有持久化上下文
*/
@ -792,7 +891,7 @@ public class BrowserService {
}
persistentContexts.clear();
}
private void addStealthScripts(Page page) {
page.addInitScript(BrowserStealth.STEALTH_SCRIPT);

View File

@ -0,0 +1,211 @@
package com.xianyu.autoreply.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.xianyu.autoreply.entity.Cookie;
import com.xianyu.autoreply.repository.CookieRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 订单状态处理器简化版
* 对应Python的OrderStatusHandler类
* 用于处理系统消息和红色提醒消息中的订单状态更新
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderStatusHandler {
private final CookieRepository cookieRepository;
// 线程锁用于保护并发访问
private final ReentrantLock lock = new ReentrantLock();
// 状态映射
private static final Map<String, String> STATUS_MAPPING = new HashMap<String, String>() {{
put("processing", "处理中");
put("pending_ship", "待发货");
put("shipped", "已发货");
put("completed", "已完成");
put("refunding", "退款中");
put("cancelled", "已关闭");
}};
// 消息类型与状态的映射
private static final Map<String, String> MESSAGE_STATUS_MAPPING = new HashMap<String, String>() {{
put("[买家确认收货,交易成功]", "completed");
put("[你已确认收货,交易成功]", "completed");
put("[你已发货]", "shipped");
put("你已发货", "shipped");
put("[你已发货,请等待买家确认收货]", "shipped");
put("[我已付款,等待你发货]", "pending_ship");
put("[我已拍下,待付款]", "processing");
put("[买家已付款]", "pending_ship");
put("[付款完成]", "pending_ship");
put("[已付款,待发货]", "pending_ship");
put("[退款成功,钱款已原路退返]", "cancelled");
put("[你关闭了订单,钱款已原路退返]", "cancelled");
}};
/**
* 处理系统消息并更新订单状态
* 对应Python: handle_system_message()
*
* @param message 原始消息数据
* @param sendMessage 消息内容
* @param cookieId Cookie ID
* @param msgTime 消息时间
* @return true=处理了订单状态更新false=未处理
*/
public boolean handleSystemMessage(JSONObject message, String sendMessage, String cookieId, String msgTime) {
lock.lock();
try {
// 检查消息是否在映射表中
if (!MESSAGE_STATUS_MAPPING.containsKey(sendMessage)) {
return false;
}
String newStatus = MESSAGE_STATUS_MAPPING.get(sendMessage);
// 提取订单ID
String orderId = extractOrderId(message);
if (orderId == null) {
log.warn("[{}] 【{}】{}无法提取订单ID跳过处理", msgTime, cookieId, sendMessage);
return false;
}
// 更新订单状态简化版 - 实际项目中应调用数据库
log.info("[{}] 【{}】{},订单 {} 状态应更新为{}",
msgTime, cookieId, sendMessage, orderId, STATUS_MAPPING.get(newStatus));
// 实际项目应调用: db_manager.insert_or_update_order(order_id, order_status, cookie_id)
return true;
} catch (Exception e) {
log.error("[{}] 【{}】处理系统消息订单状态更新时出错: {}", msgTime, cookieId, e.getMessage(), e);
return false;
} finally {
lock.unlock();
}
}
/**
* 处理红色提醒消息并更新订单状态
* 对应Python: handle_red_reminder_message()
*
* @param message 原始消息数据
* @param redReminder 红色提醒内容
* @param userId 用户ID
* @param cookieId Cookie ID
* @param msgTime 消息时间
* @return true=处理了订单状态更新false=未处理
*/
public boolean handleRedReminderMessage(JSONObject message, String redReminder, String userId,
String cookieId, String msgTime) {
lock.lock();
try {
// 只处理交易关闭的情况
if (!"交易关闭".equals(redReminder)) {
return false;
}
// 提取订单ID
String orderId = extractOrderId(message);
if (orderId == null) {
log.warn("[{}] 【{}】交易关闭无法提取订单ID跳过处理", msgTime, cookieId);
return false;
}
// 更新订单状态为已关闭
log.info("[{}] 【{}】交易关闭,订单 {} 状态应更新为已关闭", msgTime, cookieId, orderId);
// 实际项目应调用: db_manager.insert_or_update_order(order_id, order_status='cancelled', cookie_id)
return true;
} catch (Exception e) {
log.error("[{}] 【{}】处理红色提醒消息时出错: {}", msgTime, cookieId, e.getMessage(), e);
return false;
} finally {
lock.unlock();
}
}
/**
* 从消息中提取订单ID
* 对应Python: extract_order_id()
*
* @param message 消息对象
* @return 订单ID提取失败返回null
*/
private String extractOrderId(JSONObject message) {
try {
// 方法1: 从button的targetUrl中提取orderId
if (message.containsKey("1") && message.get("1") instanceof JSONObject) {
JSONObject message1 = message.getJSONObject("1");
if (message1.containsKey("6") && message1.get("6") instanceof JSONObject) {
JSONObject message16 = message1.getJSONObject("6");
if (message16.containsKey("3") && message16.get("3") instanceof JSONObject) {
JSONObject message163 = message16.getJSONObject("3");
String contentJsonStr = message163.getString("5");
if (contentJsonStr != null) {
try {
JSONObject contentData = JSON.parseObject(contentJsonStr);
// 从button的targetUrl提取
String targetUrl = contentData.getJSONObject("dxCard")
.getJSONObject("item")
.getJSONObject("main")
.getJSONObject("exContent")
.getJSONObject("button")
.getString("targetUrl");
if (targetUrl != null) {
Pattern pattern = Pattern.compile("orderId=(\\d+)");
Matcher matcher = pattern.matcher(targetUrl);
if (matcher.find()) {
return matcher.group(1);
}
}
} catch (Exception e) {
// 忽略解析错误继续尝试其他方法
}
}
}
}
}
// 方法2: 在整个消息字符串中搜索订单ID模式
String messageStr = message.toJSONString();
String[] patterns = {
"orderId[=:](\\d{10,})",
"order_detail\\?id=(\\d{10,})",
"\"id\"\\s*:\\s*\"?(\\d{10,})\"?",
"bizOrderId[=:](\\d{10,})"
};
for (String patternStr : patterns) {
Pattern pattern = Pattern.compile(patternStr);
Matcher matcher = pattern.matcher(messageStr);
if (matcher.find()) {
return matcher.group(1);
}
}
return null;
} catch (Exception e) {
log.error("提取订单ID失败: {}", e.getMessage());
return null;
}
}
}

View File

@ -0,0 +1,98 @@
package com.xianyu.autoreply.service;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 暂停管理器
* 对应Python的AutoReplyPauseManager类
* 用于管理聊天会话的暂停状态手动发送消息后暂停10分钟自动回复
*/
@Slf4j
@Component
public class PauseManager {
// 暂停时长 - 默认10分钟
private static final long PAUSE_DURATION_SECONDS = 10 * 60;
// 存储暂停的chat_id和暂停结束时间 {chatId: pauseEndTime}
private final Map<String, Long> pausedChats = new ConcurrentHashMap<>();
/**
* 暂停指定chat_id的自动回复
* 对应Python: pause_chat(chat_id, cookie_id)
*
* @param chatId 聊天ID
* @param cookieId Cookie ID用于日志
*/
public void pauseChat(String chatId, String cookieId) {
long pauseEndTime = System.currentTimeMillis() + (PAUSE_DURATION_SECONDS * 1000);
pausedChats.put(chatId, pauseEndTime);
log.info("【{}】已暂停chat_id {} 的自动回复持续10分钟", cookieId, chatId);
}
/**
* 检查chat_id是否处于暂停状态
* 对应Python: is_chat_paused(chat_id)
*
* @param chatId 聊天ID
* @return true=暂停中false=未暂停
*/
public boolean isChatPaused(String chatId) {
Long pauseEndTime = pausedChats.get(chatId);
if (pauseEndTime == null) {
return false;
}
// 检查是否已过期
if (System.currentTimeMillis() >= pauseEndTime) {
// 已过期移除
pausedChats.remove(chatId);
return false;
}
return true;
}
/**
* 获取剩余暂停时间
* 对应Python: get_remaining_pause_time(chat_id)
*
* @param chatId 聊天ID
* @return 剩余暂停时间如果未暂停则返回0
*/
public long getRemainingPauseTime(String chatId) {
Long pauseEndTime = pausedChats.get(chatId);
if (pauseEndTime == null) {
return 0;
}
long remaining = (pauseEndTime - System.currentTimeMillis()) / 1000;
return Math.max(0, remaining);
}
/**
* 清理过期的暂停记录
* 对应Python: cleanup_expired_pauses()
*/
public void cleanupExpiredPauses() {
long currentTime = System.currentTimeMillis();
pausedChats.entrySet().removeIf(entry -> currentTime >= entry.getValue());
}
/**
* 取消指定chat_id的暂停
*
* @param chatId 聊天ID
*/
public void resumeChat(String chatId) {
pausedChats.remove(chatId);
log.info("已取消chat_id {} 的暂停状态", chatId);
}
}

View File

@ -484,7 +484,7 @@ public class QrLoginService {
private Headers generateHeaders() {
return new Headers.Builder()
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.add("User-Agent", "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")
.add("Accept", "application/json, text/plain, */*")
.add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
.add("Referer", "https://passport.goofish.com/")

View File

@ -20,15 +20,20 @@ public class XianyuClientService {
private final ReplyService replyService;
private final CaptchaHandler captchaHandler;
private final BrowserService browserService;
private final PauseManager pauseManager;
private final OrderStatusHandler orderStatusHandler;
private final Map<String, XianyuClient> clients = new ConcurrentHashMap<>();
@Autowired
public XianyuClientService(CookieRepository cookieRepository, ReplyService replyService,
CaptchaHandler captchaHandler, BrowserService browserService) {
CaptchaHandler captchaHandler, BrowserService browserService,
PauseManager pauseManager, OrderStatusHandler orderStatusHandler) {
this.cookieRepository = cookieRepository;
this.replyService = replyService;
this.captchaHandler = captchaHandler;
this.browserService = browserService;
this.pauseManager = pauseManager;
this.orderStatusHandler = orderStatusHandler;
}
@PostConstruct
@ -47,7 +52,8 @@ public class XianyuClientService {
log.warn("Client {} already running", cookieId);
return;
}
XianyuClient client = new XianyuClient(cookieId, cookieRepository, replyService, captchaHandler, browserService);
XianyuClient client = new XianyuClient(cookieId, cookieRepository, replyService,
captchaHandler, browserService, pauseManager, orderStatusHandler);
clients.put(cookieId, client);
client.start();
}

View File

@ -2,8 +2,10 @@ package com.xianyu.autoreply.service.captcha;
import com.microsoft.playwright.options.BoundingBox;
import com.microsoft.playwright.options.Cookie;
import com.xianyu.autoreply.service.BrowserService;
import com.xianyu.autoreply.service.captcha.model.CaptchaResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import com.microsoft.playwright.*;
@ -11,16 +13,21 @@ import java.util.*;
/**
* 滑块验证处理器 - 基于Playwright
* 通过依赖注入复用BrowserService的Playwright实例避免多实例冲突
*/
@Slf4j
@Component
public class CaptchaHandler {
private Playwright playwright;
private Browser browser;
private final BrowserService browserService;
private BrowserContext context;
private Page page;
@Autowired
public CaptchaHandler(BrowserService browserService) {
this.browserService = browserService;
}
/**
* 处理滑块验证
*
@ -60,8 +67,8 @@ public class CaptchaHandler {
dragSlider(sliderElement, distance, cookieId);
// 检查是否成功
Thread.sleep(2000);
boolean success = checkSuccess(cookieId);
Thread.sleep(10000);
boolean success = checkSuccess(verificationUrl, cookieId);
if (success) {
// 提取cookies
@ -83,44 +90,70 @@ public class CaptchaHandler {
}
/**
* 初始化浏览器
* 初始化浏览器复用共享Browser实例但创建临时的Context和Page
*/
private void initBrowser(String cookieId) {
log.info("【{}】初始化Playwright浏览器...", cookieId);
log.info("【{}】初始化浏览器上下文复用共享Browser实例...", cookieId);
playwright = Playwright.create();
browser = playwright.chromium().launch(new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(Arrays.asList(
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-blink-features=AutomationControlled",
"--disable-gpu",
"--disable-web-security"
))
);
// 创建上下文
context = browser.newContext(new Browser.NewContextOptions()
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
.setViewportSize(1920, 1080)
);
// 创建页面
page = context.newPage();
// 注入反检测脚本
injectStealthScript(cookieId);
log.info("【{}】浏览器初始化完成", cookieId);
try {
// BrowserService 获取共享的 Browser 实例
Browser sharedBrowser = browserService.getSharedBrowser();
if (sharedBrowser == null) {
String errorMsg = "BrowserService.getSharedBrowser() 返回 nullPlaywright 可能未正确初始化";
log.error("【{}】{}", cookieId, errorMsg);
throw new IllegalStateException(errorMsg);
}
log.debug("【{}】成功获取共享Browser实例", cookieId);
// 创建临时的非持久化 BrowserContext不使用 UserData避免 SingletonLock 冲突
context = sharedBrowser.newContext(new Browser.NewContextOptions()
.setUserAgent("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")
// 注意不设置 UserData这样就不会创建持久化上下文
);
if (context == null) {
String errorMsg = "创建 BrowserContext 失败,返回 null";
log.error("【{}】{}", cookieId, errorMsg);
throw new IllegalStateException(errorMsg);
}
log.debug("【{}】成功创建浏览器上下文", cookieId);
// 创建页面
page = context.newPage();
if (page == null) {
String errorMsg = "创建 Page 失败,返回 null";
log.error("【{}】{}", cookieId, errorMsg);
throw new IllegalStateException(errorMsg);
}
log.debug("【{}】成功创建页面对象", cookieId);
// 添加反检测脚本
injectStealthScript(cookieId);
log.info("【{}】✅ 浏览器上下文初始化完成Context: {}, Page: {}",
cookieId, context != null, page != null);
} catch (Exception e) {
log.error("【{}】❌ 初始化浏览器失败", cookieId, e);
// 确保资源清理
cleanup(cookieId);
throw new RuntimeException("初始化浏览器失败: " + e.getMessage(), e);
}
}
/**
* 注入反检测脚本
*/
private void injectStealthScript(String cookieId) {
if (this.page == null) {
log.warn("【{}】⚠️ Page为null无法注入反检测脚本", cookieId);
return;
}
String script = """
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
@ -144,6 +177,14 @@ public class CaptchaHandler {
*/
private void navigateToCaptchaPage(String url, String cookieId) {
log.info("【{}】导航到验证页面...", cookieId);
// 防御性检查确保 page 已经被正确初始化
if (this.page == null) {
String errorMsg = "Page对象为null浏览器可能未正确初始化";
log.error("【{}】{}", cookieId, errorMsg);
throw new IllegalStateException(errorMsg);
}
page.navigate(url, new Page.NavigateOptions().setTimeout(30000));
log.info("【{}】页面加载完成", cookieId);
}
@ -191,42 +232,180 @@ public class CaptchaHandler {
}
/**
* 拖动滑块
* 拖动滑块使用贝塞尔曲线模拟人类行为
*/
private void dragSlider(ElementHandle slider, int distance, String cookieId) throws InterruptedException {
log.info("【{}】开始拖动滑块...", cookieId);
log.info("【{}】滑块移动距离: {}px", cookieId, distance);
log.info("【{}】开始拖动滑块(使用贝塞尔曲线模拟人类行为)...", cookieId);
BoundingBox box = slider.boundingBox();
double startX = box.x + box.width / 2;
double startY = box.y + box.height / 2;
// 移动到滑块
page.mouse().move(startX, startY);
page.mouse().down();
// ========== 鼠标预热移动模拟真实人类鼠标轨迹 ==========
log.info("【{}】执行鼠标预热移动...", cookieId);
// 简单拖动后续优化为贝塞尔曲线
int steps = 3;
for (int i = 1; i <= steps; i++) {
double x = startX + (distance * i / (double) steps);
page.mouse().move(x, startY);
// 1. 先移动到页面随机位置模拟用户浏览页面
double randomX1 = 200 + Math.random() * 400; // 200-600px范围
double randomY1 = 100 + Math.random() * 200; // 100-300px范围
page.mouse().move(randomX1, randomY1);
page.mouse().click(randomX1, randomY1);
Thread.sleep(100 + (long)(Math.random() * 200));
// 2. 再移动到接近滑块的位置但不是精确位置
double approachX = startX - 50 - Math.random() * 100; // 滑块左侧50-150px
double approachY = startY + (Math.random() - 0.5) * 100; // 上下浮动50px
List<Point> approachTrack = generateBezierTrack(randomX1, randomY1, approachX - randomX1);
// 快速移动到接近位置模拟找到滑块的过程
for (int i = 0; i < Math.min(approachTrack.size(), 20); i += 2) { // 只取部分点移动更快
Point p = approachTrack.get(i);
// Y轴使用插值
double approachProgress = i / 20.0;
double currentY = randomY1 + (approachY - randomY1) * approachProgress;
page.mouse().move(p.x, currentY);
Thread.sleep(8 + (long)(Math.random() * 5));
}
log.info("【{}】预热移动完成,准备拖动滑块", cookieId);
// ========== 随机等待模拟人类反应时间 ==========
Thread.sleep(200 + (long)(Math.random() * 300));
// 移动到滑块起始位置带一点随机偏移
double offsetY = (Math.random() - 0.5) * 5; // ±2.5px的Y轴偏移
page.mouse().move(startX, startY + offsetY);
Thread.sleep(80 + (long)(Math.random() * 120));
// 按下鼠标
page.mouse().down();
Thread.sleep(50 + (long)(Math.random() * 50));
// 使用贝塞尔曲线生成轨迹点
List<Point> track = generateBezierTrack(startX, startY + offsetY, distance);
// 按轨迹移动鼠标
for (int i = 0; i < track.size(); i++) {
Point p = track.get(i);
page.mouse().move(p.x, p.y);
// 动态延迟开始快中间慢结束稍快
double progress = i / (double) track.size();
long delay;
if (progress < 0.3) {
// 前30%快速移动
delay = 5 + (long)(Math.random() * 8);
} else if (progress < 0.8) {
// 中间50%减速
delay = 15 + (long)(Math.random() * 12);
} else {
// 最后20%稍加速完成
delay = 8 + (long)(Math.random() * 10);
}
Thread.sleep(delay);
}
// ========== 过冲和回退模拟人类精细调整 ==========
Point lastPoint = track.get(track.size() - 1);
// 70%概率过冲人类经常拖多一点再调整
if (Math.random() < 0.7) {
// 过冲 3-8px
double overshoot = 3 + Math.random() * 5;
page.mouse().move(lastPoint.x + overshoot, lastPoint.y + (Math.random() - 0.5) * 3);
Thread.sleep(50 + (long)(Math.random() * 80));
// 回退到准确位置
page.mouse().move(lastPoint.x, lastPoint.y);
Thread.sleep(30 + (long)(Math.random() * 50));
}
// 到达终点后短暂停顿模拟人类确认
Thread.sleep(150 + (long)(Math.random() * 200));
// 松开鼠标
page.mouse().up();
log.info("【{}】滑块拖动完成", cookieId);
// 等待验证结果
Thread.sleep(1500);
}
/**
* 使用贝塞尔曲线生成滑块轨迹
* 模拟人类拖动带随机抖动非线性速度变化
*/
private List<Point> generateBezierTrack(double startX, double startY, double distance) {
List<Point> points = new ArrayList<>();
// 终点坐标带一点随机偏移避免过于精确
double endX = startX + distance + (Math.random() - 0.5) * 3;
double endY = startY + (Math.random() - 0.5) * 8; // Y轴有更大的偏移
// 控制点使轨迹呈弧形模拟手臂自然弧度
double controlX1 = startX + distance * 0.3 + (Math.random() - 0.5) * 15;
double controlY1 = startY - 10 - Math.random() * 15; // 向上弧
double controlX2 = startX + distance * 0.7 + (Math.random() - 0.5) * 15;
double controlY2 = startY + 5 + (Math.random() - 0.5) * 10; // 稍微向下
// 生成轨迹点50-80个点之间
int numPoints = 50 + (int)(Math.random() * 30);
for (int i = 0; i <= numPoints; i++) {
double t = i / (double) numPoints;
// 三次贝塞尔曲线公式
double x = Math.pow(1 - t, 3) * startX +
3 * Math.pow(1 - t, 2) * t * controlX1 +
3 * (1 - t) * Math.pow(t, 2) * controlX2 +
Math.pow(t, 3) * endX;
double y = Math.pow(1 - t, 3) * startY +
3 * Math.pow(1 - t, 2) * t * controlY1 +
3 * (1 - t) * Math.pow(t, 2) * controlY2 +
Math.pow(t, 3) * endY;
// 添加随机抖动模拟手部微颤
if (i > 0 && i < numPoints) { // 起点和终点不抖动
x += (Math.random() - 0.5) * 2.5; // X轴抖动 ±1.25px
y += (Math.random() - 0.5) * 3; // Y轴抖动 ±1.5px更大
}
points.add(new Point(x, y));
}
return points;
}
/**
* 检查验证是否成功
* 通过判断当前页面地址是否已经不是验证页面地址来确定验证是否成功
*
* @param verificationUrl 验证页面URL
* @param cookieId 账号ID
* @return 验证是否成功
*/
private boolean checkSuccess(String cookieId) {
private boolean checkSuccess(String verificationUrl, String cookieId) {
try {
// 等待成功提示
page.waitForSelector(".nc-lang-cnt:has-text('验证通过')",
new Page.WaitForSelectorOptions().setTimeout(5000));
log.info("【{}】检测到验证成功提示", cookieId);
return true;
// 获取当前页面URL
String currentUrl = page.url();
log.info("【{}】当前页面URL: {}", cookieId, currentUrl);
log.info("【{}】验证页面URL: {}", cookieId, verificationUrl);
// 判断当前页面地址是否已经不是验证页面地址
boolean urlChanged = !currentUrl.equals(verificationUrl);
if (urlChanged) {
log.info("【{}】✅ 页面已跳转,验证成功!", cookieId);
return true;
} else {
log.warn("【{}】⚠️ 页面未跳转,仍在验证页面", cookieId);
return false;
}
} catch (Exception e) {
log.warn("【{}】未检测到验证成功", cookieId);
log.error("【{}】❌ 检查验证状态时发生异常", cookieId, e);
return false;
}
}
@ -255,24 +434,35 @@ public class CaptchaHandler {
/**
* 清理资源
* 注意只清理自己创建的Context和Page不关闭共享的Browser
*/
private void cleanup(String cookieId) {
try {
if (page != null) {
page.close();
page = null;
}
if (context != null) {
context.close();
context = null;
}
if (browser != null) {
browser.close();
}
if (playwright != null) {
playwright.close();
}
// 不关闭共享的Browser和Playwright它们由BrowserService管理
log.info("【{}】浏览器资源已清理", cookieId);
} catch (Exception e) {
log.warn("【{}】清理资源时出错", cookieId, e);
}
}
/**
* 辅助类表示一个坐标点
*/
private static class Point {
double x;
double y;
Point(double x, double y) {
this.x = x;
this.y = y;
}
}
}

View File

@ -22,10 +22,10 @@ spring:
database-platform: org.hibernate.community.dialect.SQLiteDialect
hibernate:
ddl-auto: ${app.ddl-auto:update}
show-sql: true
show-sql: false # Set to false to disable SQL logging
properties:
hibernate:
format_sql: true
format_sql: false # Set to false to disable SQL formatting
servlet:
multipart:
@ -36,5 +36,7 @@ logging:
level:
root: INFO
com.xianyu.autoreply: DEBUG
org.hibernate.SQL: INFO # Ensure Hibernate SQL logging is not set to DEBUG/TRACE
org.hibernate.type.descriptor.sql: INFO # Ensure Hibernate parameter logging is not set to DEBUG/TRACE
file:
name: logs/backend-java.log