Loading

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:

  1. 编写HTTP接口,返回PAC内容,contentType需指定为application/x-ns-proxy-autoconfig
  2. 指定本地文件,路径要以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等数据的隔离,过程中踩了不少坑,好在都顺利解决了。

posted @ 2025-11-07 10:01  马卡龙MK  阅读(11)  评论(0)    收藏  举报