This commit is contained in:
wangli 2026-01-28 00:37:55 +08:00
parent b6c2f7407b
commit 6273f35f53
10 changed files with 337 additions and 20 deletions

View File

@ -0,0 +1,100 @@
package top.biwin.xianyu.goofish;
/**
* TODO
* 常量
*
* @author wangli
* @since 2026-01-27 23:08
*/
public interface BrowserConstant {
String STEALTH_SCRIPT =
"// 隐藏webdriver属性\n" +
"Object.defineProperty(navigator, 'webdriver', {\n" +
" get: () => undefined,\n" +
"});\n" +
"\n" +
"// 隐藏自动化相关属性\n" +
"delete navigator.__proto__.webdriver;\n" +
"delete window.navigator.webdriver;\n" +
"delete window.navigator.__proto__.webdriver;\n" +
"\n" +
"// 模拟真实浏览器环境\n" +
"window.chrome = {\n" +
" runtime: {},\n" +
" loadTimes: function() {},\n" +
" csi: function() {},\n" +
" app: {}\n" +
"};\n" +
"\n" +
"// 覆盖plugins - 随机化\n" +
"const pluginCount = Math.floor(Math.random() * 5) + 3;\n" +
"Object.defineProperty(navigator, 'plugins', {\n" +
" get: () => Array.from({length: pluginCount}, (_, i) => ({\n" +
" name: 'Plugin' + i,\n" +
" description: 'Plugin ' + i\n" +
" })),\n" +
"});\n" +
"\n" +
"// 覆盖languages\n" +
"Object.defineProperty(navigator, 'languages', {\n" +
" get: () => ['zh-CN', 'zh', 'en'],\n" +
"});\n" +
"\n" +
"// 隐藏自动化检测 - 随机化硬件信息\n" +
"Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => [2, 4, 6, 8][Math.floor(Math.random()*4)] });\n" +
"Object.defineProperty(navigator, 'deviceMemory', { get: () => [4, 8, 16][Math.floor(Math.random()*3)] });\n" +
"\n" +
"// 伪装 Date\n" +
"const OriginalDate = Date;\n" +
"Date = function(...args) {\n" +
" if (args.length === 0) {\n" +
" const date = new OriginalDate();\n" +
" const offset = Math.floor(Math.random() * 3) - 1;\n" +
" return new OriginalDate(date.getTime() + offset);\n" +
" }\n" +
" return new OriginalDate(...args);\n" +
"};\n" +
"Date.prototype = OriginalDate.prototype;\n" +
"Date.now = function() {\n" +
" return OriginalDate.now() + Math.floor(Math.random() * 3) - 1;\n" +
"};\n" +
"\n" +
"// 伪装 RTCPeerConnection\n" +
"if (window.RTCPeerConnection) {\n" +
" const originalRTC = window.RTCPeerConnection;\n" +
" window.RTCPeerConnection = function(...args) {\n" +
" const pc = new originalRTC(...args);\n" +
" const originalCreateOffer = pc.createOffer;\n" +
" pc.createOffer = function(...args) {\n" +
" return originalCreateOffer.apply(this, args).then(offer => {\n" +
" offer.sdp = offer.sdp.replace(/a=fingerprint:.*\\r\\n/g, \n" +
" `a=fingerprint:sha-256 ${Array.from({length:64}, ()=>Math.floor(Math.random()*16).toString(16)).join('')}\\r\\n`);\n" +
" return offer;\n" +
" });\n" +
" };\n" +
" return pc;\n" +
" };\n" +
"}\n" +
"\n" +
"// 伪装 Notification 权限\n" +
"Object.defineProperty(Notification, 'permission', {\n" +
" get: function() {\n" +
" return ['default', 'granted', 'denied'][Math.floor(Math.random() * 3)];\n" +
" }\n" +
"});\n" +
"\n" +
"// 隐藏 Playwright 特征\n" +
"delete window.__playwright;\n" +
"delete window.__pw_manual;\n" +
"delete window.__PW_inspect;\n" +
"\n" +
"// 伪装 Permissions API\n" +
"const originalQuery = window.navigator.permissions.query;\n" +
"window.navigator.permissions.query = (parameters) => (\n" +
" parameters.name === 'notifications' ?\n" +
" Promise.resolve({ state: Notification.permission }) :\n" +
" originalQuery(parameters)\n" +
");\n";
}

View File

@ -1,4 +1,4 @@
package top.biwin.xianyu.goofish.service.api;
package top.biwin.xianyu.goofish.api;
import cn.hutool.crypto.SecureUtil;
import org.springframework.beans.factory.annotation.Value;

View File

@ -1,4 +1,4 @@
package top.biwin.xianyu.goofish.service.api;
package top.biwin.xianyu.goofish.api;
/**
* TODO

View File

@ -1,4 +1,4 @@
package top.biwin.xianyu.goofish.service.api;
package top.biwin.xianyu.goofish.api.impl;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
@ -8,6 +8,7 @@ import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import top.biwin.xianyu.goofish.api.GoofishAbstractApi;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

View File

@ -12,13 +12,17 @@ import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
@ -30,6 +34,10 @@ import java.util.concurrent.ConcurrentHashMap;
@Component
@Slf4j
public class BrowserService {
@Value("${browser.persistence-data}")
private String persistenceData;
@Value("${browser.location}")
private String browserLocation;
@Value("${browser.ua}")
private String ua;
// 为每个账号维护持久化浏览器上下文用于Cookie刷新
@ -116,6 +124,9 @@ public class BrowserService {
public Map<String, String> verifyQrLoginCookies(Map<String, String> qrCookies, String accountId) {
log.info("【QR Login】Verifying cookies for account: {}", accountId);
// TODO 这里可以抽象 newContext 暂时先放这里
// newContext 就像打开了一个无痕/隐身窗口
// launchPersistentContext() 就像打开了一个带有完整用户配置Profile的常规浏览器窗口
try (BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setUserAgent(ua)
)) {
@ -173,4 +184,84 @@ public class BrowserService {
return new HashMap<>(); // Failed
}
}
/**
*
* @param goofishId
* @param showBrowser 对应 headless
* @param slowMo 每个动作间的间隔
* @param goofishCookieStr
* @return
*/
public BrowserContext loadPersistentContext(String goofishId, Boolean showBrowser, Double slowMo, String goofishCookieStr) {
BrowserContext browserContext = persistentContexts.get(goofishId);
if (Objects.nonNull(browserContext)) {
// TODO 可以尝试验证是否还生效
// Page testPage = existingContext.newPage();
// testPage.close();
return browserContext;
}
try {
String userDataDir = persistenceData + "user_" + goofishId;
Path userDataPath = Paths.get(userDataDir);
Files.createDirectories(userDataPath);
log.debug("【{}-loadPersistentContext】创建浏览器持久化目录: {}, headless model: {}", goofishId, userDataDir, showBrowser);
List<String> args = new ArrayList<>();
args.add("--no-sandbox");
args.add("--disable-setuid-sandbox");
args.add("--disable-dev-shm-usage");
args.add("--disable-blink-features=AutomationControlled");
args.add("--disable-web-security");
args.add("--disable-features=VizDisplayCompositor");
args.add("--lang=zh-CN");
args.add("--start-maximized");
BrowserType.LaunchPersistentContextOptions options = new BrowserType.LaunchPersistentContextOptions()
.setHeadless(!showBrowser)
.setSlowMo(Objects.nonNull(slowMo) ? slowMo : 0)
.setArgs(args)
.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)
.setExecutablePath(Paths.get(browserLocation))
.setIgnoreHTTPSErrors(true);
log.debug("【{}-loadPersistentContext】创建持久化浏览器上下文", goofishId);
BrowserContext context = playwright.chromium().launchPersistentContext(userDataPath, options);
if (StringUtils.hasText(goofishCookieStr)) {
log.debug("【{}-loadPersistentContext】添加指定 Cookies: {}", goofishId, goofishCookieStr);
List<Cookie> playwrightCookies = new ArrayList<>();
for (String cookiePart : goofishCookieStr.split(";")) {
String[] kv = cookiePart.trim().split("=", 2);
if (kv.length == 2) {
playwrightCookies.add(new Cookie(kv[0], kv[1]).setDomain(".goofish.com").setPath("/"));
}
}
context.addCookies(playwrightCookies);
log.debug("【{}-loadPersistentContext】已设置初始 Cookie: {} 个", goofishId, playwrightCookies);
}
persistentContexts.put(goofishId, context);
return context;
} catch (Exception e) {
log.error("【{}-loadPersistentContext】创建持久化上下文失败", goofishId, e);
throw new RuntimeException("创建持久化浏览器上下文失败", e);
}
}
/**
* 该方法一般是在 loadPersistentContext 函数后面调用设置 cookie
*/
public void addPersistentContextCookie(String goofishId, List<Cookie> cookies) {
BrowserContext browserContext = persistentContexts.get(goofishId);
if (Objects.isNull(browserContext)) {
throw new IllegalStateException("未找到" + goofishId + "的浏览器持久化数据");
}
if (CollectionUtils.isEmpty(cookies)) {
return;
}
browserContext.addCookies(cookies);
}
}

View File

@ -6,7 +6,7 @@ import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.goofish.service.api.GoofishApi;
import top.biwin.xianyu.goofish.api.GoofishApi;
import javax.annotation.Nullable;
import java.util.List;

View File

@ -0,0 +1,123 @@
package top.biwin.xianyu.goofish.service;
import com.microsoft.playwright.BrowserContext;
import com.microsoft.playwright.ElementHandle;
import com.microsoft.playwright.Frame;
import com.microsoft.playwright.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Objects;
import static top.biwin.xianyu.goofish.BrowserConstant.STEALTH_SCRIPT;
/**
* TODO
*
* @author wangli
* @since 2026-01-27 22:13
*/
@Component
@Slf4j
public class GoofishPwdLoginService {
@Autowired
private BrowserService browserService;
/**
*
* @param account
* @param password
* @param showBrowser 是否有头headless
* @param systemUsername 当前系统登录用户
*/
public void processPasswordLogin(String account, String password, Boolean showBrowser, String systemUsername) {
BrowserContext context = browserService.loadPersistentContext(account, showBrowser, 500D, 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);
// TODO 点击协议并登录
ElementHandle agreement = loginFrame.querySelector("#fm-agreement-checkbox");
if (agreement != null && !agreement.isChecked()) {
log.debug("【{}】正在检查协议复选框,并勾选......", account);
agreement.click();
}
log.info("【{}】单击登录按钮...", account);
loginFrame.click("button.fm-button.fm-submit.password-login");
} else {
log.debug("【{}】未找到密码登录框,可能是持久化登录...", account);
}
} else {
// 可能是快捷登录按钮页等待 10s 查看右上角有没有登录信息
}
// TODO 处理登录成功后的账号 cookie并存库更新persistentData 目录名称
log.debug("....");
}
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) {
}
}
// 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) {
}
}
}
return null;
}
}

View File

@ -1,4 +1,4 @@
package top.biwin.xianyu.goofish.qrcode;
package top.biwin.xianyu.goofish.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
@ -21,8 +21,6 @@ import org.springframework.stereotype.Component;
import top.biwin.xianyu.core.entity.GoofishAccountEntity;
import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.goofish.dto.QrLoginSessionDto;
import top.biwin.xianyu.goofish.service.BrowserService;
import top.biwin.xianyu.goofish.service.GoofishApiService;
import top.biwin.xinayu.common.dto.response.GoofishAccountSimpleInfo;
import top.biwin.xinayu.common.dto.response.GoofishQrStatusResponse;
import top.biwin.xinayu.common.dto.response.QrLoginResponse;
@ -185,7 +183,7 @@ public class QrLoginService {
// 6. 清理过期会话
cleanupExpiredSessions();
// 7. 获取会话状态
// 7. 调用闲鱼的 query.do 获取会话状态
GoofishQrStatusResponse statusInfo = getSessionStatus(sessionId);
log.info("【QR Login】获取扫码状态信息: {}", statusInfo);

View File

@ -10,7 +10,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.biwin.xianyu.core.entity.GoofishAccountEntity;
import top.biwin.xianyu.core.repository.GoofishAccountRepository;
import top.biwin.xianyu.goofish.qrcode.QrLoginService;
import top.biwin.xianyu.goofish.service.GoofishPwdLoginService;
import top.biwin.xianyu.goofish.service.QrLoginService;
import top.biwin.xinayu.common.dto.request.GoofishAddCookieRequest;
import top.biwin.xinayu.common.dto.request.GoofishPwdLoginRequest;
import top.biwin.xinayu.common.dto.response.BaseResponse;
@ -21,7 +22,6 @@ import top.biwin.xinayu.server.util.CurrentUserUtil;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
@ -37,6 +37,8 @@ public class GoofishAccountController {
private QrLoginService qrLoginService;
@Autowired
private GoofishAccountRepository goofishAccountRepository;
@Autowired
private GoofishPwdLoginService goofishPwdLoginService;
/**
* 获取所有Cookie的详细信息包括值和状态
@ -87,14 +89,12 @@ public class GoofishAccountController {
.build());
}
// TODO 自动化操作浏览器完成登陆并写 goofishAccount
// browserService.startPasswordLogin(
// request.getAccount_id(),
// request.getAccount(),
// request.getPassword(),
// request.getShow_browser() != null && request.getShow_browser(),
// userId
// );
goofishPwdLoginService.processPasswordLogin(request.getAccount(),
request.getPassword(),
request.getShow_browser() != null && request.getShow_browser(),
CurrentUserUtil.getCurrentUsername());
return ResponseEntity.ok(BaseResponse.builder()
.success(true)

View File

@ -40,8 +40,7 @@ spring:
logging:
level:
root: INFO
com.xianyu.autoreply: DEBUG
top.biwin.xinayu: DEBUG
top.biwin.xianyu: DEBUG
org.springframework.security: 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
@ -68,4 +67,9 @@ goofish:
appKey: 34839810
browser:
# chrome 浏览器软件安装位置
location: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
# 浏览器登录状态数据存储目录
persistence-data: browser_data/
# 浏览器 UA
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'