Java Selenium 切换代理
背景
有多个代理IP,需要编写一个HTTP服务,使用浏览器通过指定的代理IP访问指定的页面,返回页面源代码。
selenium指定代理服务器需要新开一个driver,每次启动都比较慢,而且不能变,所以不太合适,要想办法实现代理的切换。
代理后端切换
代理服务器是自己用netty写的,是一台服务器上有多个公网IP,监听不同IP的同一个端口,使用入站IP去连接目标服务器。
目前想法是:修改为监听一个IP的不同端口,通过API接口修改对应端口的出站IP。
启动一个浏览器,代理指定为某个端口,需要切换代理时,调用接口切换,然后浏览器再打开网页。
想法很美好,但现实很残酷。
浏览器有连接池,修改端口对应的出站IP后,不能立即生效,需要几秒甚至十几秒,完全不可控。
PAC切换代理
启动浏览器时,用--proxy-pac-url可以指定pac地址。
关于PAC的介绍可以看这里:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Guides/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_PAC_file
大致就是返回一段JavaScript代码,代码中有一个方法
function FindProxyForURL(url, host) {
// ...
}
返回一个字符串,格式大概这样
PROXY 127.0.0.1:7890 // 使用代理
DIRECT // 直连
合起来就是这样
function FindProxyForURL(url, host) {
return "PROXY 127.0.0.1:7890"
}
有两种方式返回PAC:
- 编写HTTP接口,返回PAC内容,contentType需指定为
application/x-ns-proxy-autoconfig - 指定本地文件,路径要以
file:///开头,文件中的斜杠需为/,比如:file:///C:/proxy.pac
就算PAC内容变更了,浏览器也只会在启动时读取几次,后面不会再读取了。
要使新的PAC生效,需要用浏览器打开chrome://net-internals/#proxy,然后点击Re-apply settings按钮。
这样就会重新读取一次PAC并使用里面的代理,后面打开的网页就会使用新的代理。
浏览器上下文
通过搜索得知,浏览器有一个cdp协议,其中有一个方法可以创建隔离的浏览器上下文,可以指定代理服务器。
前提是Chromium内核的浏览器,chrome或者edge。
https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-createBrowserContext
实测打开速度相当快,有点像把浏览器的一个标签页拖出来分离成一个窗口一样。
selenium也支持cdp功能,编写一下简单的代码。
WebDriver driver = newDriver();
Runtime.getRuntime().addShutdownHook(new Thread(driver::quit));
DevTools devTools = ((HasDevTools) driver).getDevTools();
BrowserContextID browserContextId = devTools.send(
Target.createBrowserContext(
Optional.of(true),
Optional.of("127.0.0.1:7890"),
Optional.empty(),
Optional.empty()
)
);
System.out.println("browserContextId = " + browserContextId);
TargetID targetId = devTools.send(
Target.createTarget(
"https://2025.ip138.com/",
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.of(browserContextId),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.of(true),
Optional.empty()
)
);
System.out.println("targetId = " + targetId);
ThreadUtil.safeSleep(100 * 1000);
控制上下文
上面代码实现了创建上下文并且创建了一个tab标签页,但是要怎么获取网页源代码呢?
如果使用selenium的api,只能控制创建driver时新建的窗口,不能控制createBrowserContext()新建的窗口。
研究后发现,需要调用Target.attachToTarget获取sessionId,然后使用sessionId去操作。
selenium的提供的devTools中,可以通过调用createSession()保存attatchToTarget返回的sessionId,内部调用Connection.send() 方法时就会携带sessionId参数。
public void createSession(String windowHandle) {
if (connection.isClosed()) {
connection.reopen();
}
TargetID targetId = findTarget(windowHandle);
// Starts the session
// CDP creates a parent browser session when websocket connection is made
// Create session that is child of parent browser session and not child of already existing
// child page session
// Passing null for session id helps achieve that
// Child of already existing child page session throws an error when detaching from the target
// CDP allows attaching to child of child session but not detaching. Maybe it does not keep
// track of it.
cdpSession =
connection.sendAndWait(null, getDomains().target().attachToTarget(targetId), timeout);
try {
// We can do all of these in parallel, and we don't care about the result.
CompletableFuture.allOf(
// Set auto-attach to true and run for the hills.
connection.send(cdpSession, getDomains().target().setAutoAttach()),
// Clear the existing logs
connection
.send(cdpSession, getDomains().log().clear())
.exceptionally(
t -> {
LOG.log(Level.SEVERE, t.getMessage(), t);
return null;
}))
.get(timeout.toMillis(), MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Thread has been interrupted", e);
} catch (ExecutionException e) {
throw new DevToolsException(unwrapCause(e));
} catch (TimeoutException e) {
throw new org.openqa.selenium.TimeoutException(e);
}
}
public <X> CompletableFuture<X> send(SessionID sessionId, Command<X> command) {
long id = NEXT_ID.getAndIncrement();
CompletableFuture<X> result = new CompletableFuture<>();
if (command.getSendsResponse()) {
methodCallbacks.put(
id,
NamedConsumer.of(
command.getMethod(),
inputOrException -> {
if (inputOrException.isRight()) {
try {
X value = command.getMapper().apply(inputOrException.right());
result.complete(value);
} catch (Throwable e) {
LOG.log(
Level.WARNING,
String.format("Unable to map result for %s", command.getMethod()),
e);
result.completeExceptionally(e);
}
} else {
result.completeExceptionally(inputOrException.left());
}
}));
}
Map<String, Object> serialized = new LinkedHashMap<>();
serialized.put("id", id);
serialized.put("method", command.getMethod());
serialized.put("params", command.getParams());
if (sessionId != null) {
serialized.put("sessionId", sessionId);
}
StringBuilder json = new StringBuilder();
try (JsonOutput out = JSON.newOutput(json).writeClassName(false)) {
out.write(Map.copyOf(serialized));
}
LOG.log(getDebugLogLevel(), "-> {0}", json);
socket.sendText(json);
if (!command.getSendsResponse()) {
result.complete(null);
}
return result;
}
如果没有并发要求,直接使用devTools.createSession()就可以了,如果要多开,selenium并不支持,不过我们可以使用hack一点的方法来实现。
上下文多开
devTools会保存一个sessionId,后续操作都会使用这个sessionId,无法实现多线程并发。
devTools内部是调用Connection.send()方法,可以携带sessionId指定控制哪个窗口,那我们只需要保存sessionId,手动调用Connection.send()方法就可以了。
经过测试后发现,attachToTraget获取的sessionId,如果标签页不关闭,是不会变的。
那我们就通过反射获取devTools中的connection,然后开启不同的上下文窗口并保存sessionId即可。
@Log4j2
@Component
@RequiredArgsConstructor
public class SessionManager {
private final SeleniumConfig config;
private final Map<String, SessionID> sessionMap = new ConcurrentHashMap<>();
private final Map<String, BrowserContextID> browserContextMap = new ConcurrentHashMap<>();
private WebDriver driver;
private DevTools devTools;
@Getter
private Connection connection;
@PostConstruct
public void init() {
driver = createDriver();
devTools = ((HasDevTools) driver).getDevTools();
devTools.addListener(Target.detachedFromTarget(), (target) -> {
String sessionId = target.getSessionId().toString();
sessionMap.entrySet().stream()
.filter(e -> StrUtil.equals(e.getValue().toString(), sessionId))
.findFirst().map(Map.Entry::getKey)
.ifPresent(k -> {
sessionMap.remove(k);
log.info("remove detached target session");
});
});
connection = (Connection) ReflectUtil.getFieldValue(devTools, "connection");
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (driver != null) {
driver.quit();
}
}));
}
private WebDriver createDriver() {
BrowserType type = config.getBrowserType();
List<String> arguments = config.getBrowserArguments();
Map<String, Object> experimentOptions = config.getBrowserExperimentOptions();
WebDriver driver;
ChromiumOptions<?> options;
if (type == BrowserType.CHROME) {
options = new ChromeOptions();
} else {
options = new EdgeOptions();
}
if (CollUtil.isNotEmpty(arguments)) {
options.addArguments(arguments);
}
experimentOptions.forEach(options::setExperimentalOption);
if (type == BrowserType.CHROME) {
driver = new ChromeDriver((ChromeOptions) options);
} else {
driver = new EdgeDriver((EdgeOptions) options);
}
return driver;
}
public BrowserContextID getBrowserContextId(String proxyServer) {
return browserContextMap.computeIfAbsent(proxyServer, bc -> devTools.send(
Target.createBrowserContext(
Optional.of(true),
Optional.ofNullable(proxyServer),
Optional.empty(),
Optional.empty()
)
));
}
public SessionID getSessionId(String proxyServer) {
return sessionMap.computeIfAbsent(proxyServer, ps -> {
BrowserContextID browserContextId = getBrowserContextId(proxyServer);
log.debug("browserContextId: {}", browserContextId);
TargetID targetId = createTarget(browserContextId);
log.debug("targetId: {}", targetId);
SessionID sessionId = attachToTarget(targetId);
log.debug("sessionId: {}", sessionId);
return sessionId;
});
}
private TargetID createTarget(BrowserContextID browserContextId) {
return devTools.send(Target.createTarget(
"about:blank",
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.of(browserContextId),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty()
));
}
private void closeTarget(TargetID targetId) {
devTools.send(Target.closeTarget(targetId));
}
private SessionID attachToTarget(TargetID targetId) {
return connection.sendAndWait(
null,
devTools.getDomains()
.target()
.attachToTarget(new org.openqa.selenium.devtools.idealized.target.model.TargetID(targetId.toString())),
Duration.ofSeconds(30));
}
public <T> T withTarget(String proxyServer, Function<SessionID, T> function) {
BrowserContextID browserContextId = getBrowserContextId(proxyServer);
TargetID targetId = createTarget(browserContextId);
SessionID sessionId = attachToTarget(targetId);
try {
return function.apply(sessionId);
} finally {
closeTarget(targetId);
}
}
}
后续使用时需要加锁,同一个标签页不能被同时调用。
页面导航以及获取网页源代码
@Log4j2
@Service
@RequiredArgsConstructor
public class SeleniumService implements ISeleniumService {
private static final Duration TIMEOUT = Duration.ofSeconds(30);
private final SessionManager sessionManager;
private final Map<SessionID, WeakReference<Lock>> sessionLockMap = new ConcurrentHashMap<>();
@Override
public String getHtml(String proxyServer, String url) {
SessionID sessionId = sessionManager.getSessionId(proxyServer);
Lock lock = getLock(sessionId);
try {
lock.lock();
navigate(sessionId, url);
waitPageLoad(sessionId);
return getHtml0(sessionId);
} finally {
lock.unlock;
}
}
private Lock getLock(SessionID sessionId) {
return sessionLockMap.compute(sessionId, (key, ref) -> {
if (ref != null) {
Lock existingLock = ref.get();
if (existingLock != null) {
return ref;
}
}
return new WeakReference<>(new ReentrantLock());
}).get();
}
private void navigate(SessionID sessionId, String url) {
Connection connection = sessionManager.getConnection();
connection.sendAndWait(
sessionId,
Page.navigate(
url,
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty()
),
TIMEOUT
);
}
private void waitPageLoad(SessionID sessionId) {
long endTime = System.currentTimeMillis() + timeout;
while (System.currentTimeMillis() < endTime) {
ThreadUtil.safeSleep(60);
String state = getState(sessionId);
if (StrUtil.equals("complete", state)) {
return;
}
}
throw new SysException("等待页面超时");
}
private String getState(SessionID sessionId) {
Connection connection = sessionManager.getConnection();
Runtime.EvaluateResponse response = connection.sendAndWait(
sessionId,
Runtime.evaluate(
"document.readyState",
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.of(true),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty(),
Optional.empty()
),
TIMEOUT
);
return response.getResult().getValue().map(Object::toString).orElse(null);
}
private String getHtml0(SessionID sessionId) {
Connection connection = sessionManager.getConnection();
waitPageLoad(sessionId);
// 获取document
Node doc = connection.sendAndWait(
sessionId,
DOM.getDocument(
Optional.empty(),
Optional.empty()
),
TIMEOUT
);
// 获取html
return connection.sendAndWait(
sessionId,
DOM.getOuterHTML(
Optional.ofNullable(doc.getNodeId()),
Optional.empty(),
Optional.empty()
),
TIMEOUT
);
}
}
总结
通过创建隔离的浏览器上下文,实现了使用不同的代理服务器访问页面的功能,虽然方法有些hack,但实现效果比较好。即使使用代理后端切换的方法并解决了浏览器连接池的问题,还需要处理Cookie、LocalStorage、SessionStorage等数据的隔离,过程中踩了不少坑,好在都顺利解决了。

浙公网安备 33010602011771号