init
This commit is contained in:
parent
b6c2f7407b
commit
6273f35f53
@ -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";
|
||||
}
|
||||
@ -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;
|
||||
@ -1,4 +1,4 @@
|
||||
package top.biwin.xianyu.goofish.service.api;
|
||||
package top.biwin.xianyu.goofish.api;
|
||||
|
||||
/**
|
||||
* TODO
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
Reference in New Issue
Block a user