利用无头浏览器的拉取网页视频数据 ------ Selenium (Java)

571 阅读6分钟

一、Selenium简介

Selenium是一个用于测试网站交互性的自动化测试工具。它提供了一种使用编程语言控制浏览器的方法。

1、优点

  • Selenium支持多种浏览器,如Chrome、Firefox、Safari等
  • Selenium可以在不同的操作系统上运行,如Windows、Linux、macOS等
  • Selenium提供了丰富的API,可以模拟各种用户操作
  • 支持多种编程语言,如Python、Java、C#等
  • Selenium Grid可以跨多台机器进行分布式测试用例的执行

2、缺点

  • 运行Selenium需要浏览器控制权,可能受限于浏览器的沙箱模型
  • 相比直接与网站交互,Selenium需要启动一个完整的浏览器实例,运行速度较慢
  • 不同浏览器的不同版本需要单独的驱动程序,增加了配置的复杂度
  • Selenium对动态内容的处理能力比较弱
  • Selenium对JS的处理能力取决于浏览器本身对JS的支持程度

二、 Selenium使用

1、依赖

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.8.3</version>
</dependency>

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-chromium-driver</artifactId>
    <version>4.8.3</version>
</dependency>

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-api</artifactId>
    <version>4.8.3</version>
</dependency>

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-chrome-driver</artifactId>
    <version>4.8.3</version>
</dependency>

<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-remote-driver</artifactId>
    <version>4.8.3</version>
</dependency>

2、主要代码

注:

  • 此处选择的是谷歌浏览器,如果选择别的浏览器,则下载对应的浏览器驱动即可
    • selenium在浏览器和驱动的版本对应有要求,所以一定要确保本地浏览器和浏览器驱动一致
    • 查看本地谷歌浏览版本:在谷歌地址栏输入 chrome://settings/help 回车
  • 此处JDK版本为8
//此处将谷歌驱动文件地址设置为系统全局变量代码
static {
    System.setProperty("webdriver.chrome.driver", getChromedriverDir());
}
/**
 * 服务器默认驱动地址  一般为 /usr/bin/chromedriver
 */
public static final String CHROMEDRIVER_DEFAULT_DIR = "";

/**
 * 本地测试驱动地址  文件地址自定义
 */
public static final String CHROMEDRIVER_LOCAL_MAC_DIR = "";

/**
 * 由于本地系统和服务器系统可能不一样,文件地址也不一样
 * 这里用 SystemUtil.getOsName() 来识别系统判断文件地址
 */
public static String getChromedriverDir() {
    String dir = CHROMEDRIVER_DEFAULT_DIR;
    String osName = SystemUtil.getOsName();
    if (StringUtils.containsIgnoreCase(osName, "mac")) {
        dir = CHROMEDRIVER_LOCAL_MAC_DIR;
    } else  if (StringUtils.containsIgnoreCase(osName, "windows")) {
        //todo 增加其他系统文件地址
    }

    return dir;
}
//此处为执行代码

public static final ThreadPoolExecutor SELENIUM_TASK = new ThreadPoolExecutor(3, 3, 300, TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(100),
            new NamedThreadFactory("SELENIUM_TASK", false), new ThreadPoolExecutor.DiscardOldestPolicy());

public static String getVideoByUrl(String url, String redirectUrl) throws 
                     InterruptedException, ExecutionException {
        CompletableFuture<String> completableFuture = new CompletableFuture<>();
        //param是需要监控的链接所需主要参数,根据自己场景来定
        //如不需要则删除,如是别的类型参数,则自行调整
        String param = "";
        
        //这里用线程池的原因如下:
        //   1、ChromeDriver.get() (后面代码会出现)会阻塞进行页面的加载,
        //      此过程根据页面复杂程度决定时长,避免同时出现过多的加载请求,这里做一个缓冲
        //   2、此处为拉取网页视频数据到本地,涉及到访问视频地址、下载视频到本地的过程。在
        //      selenium监听拦截到指定路径之后,实际上还会继续后续的加载过程,而我们只需
        //      拿到数据下载到本地,并获取本地地址即可。所以这里利用多线程 + CompletableFuture
        //      来避免不必要的等待
        SELENIUM_TASK.submit(() -> {
            SeleniumUtil.seleniumCatchVideo(url, ViewsEnum.LISTEN_URL, 
                         param, completableFuture);
        });

         //completableFuture.get()是一个阻塞等待获取结果的方式,这么写是有风险的
         //但是由于 ChromeDriver.get() 加载页面的机制,也不太好设置超时时间
         //所以这里需要确保 completableFuture.complete() 要成功赋值
         //并且做好全局异常捕捉
        return completableFuture.get();
    }
import cn.hutool.core.collection.ConcurrentHashSet;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ve.boot.viewextraction.enums.ViewsEnum;
import com.ve.boot.viewextraction.utils.fileutils.FileUtil;
import io.netty.handler.codec.http.HttpHeaders;
import lombok.extern.slf4j.Slf4j;
import net.lightbody.bmp.BrowserMobProxy;
import net.lightbody.bmp.BrowserMobProxyServer;
import net.lightbody.bmp.client.ClientUtil;
import net.lightbody.bmp.proxy.CaptureType;
import org.apache.commons.lang3.StringUtils;
import org.openqa.selenium.Proxy;
import org.openqa.selenium.WindowType;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.CapabilityType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.CollectionUtils;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;


/**
 * 创建浏览器代理
 * 针对某些网站,对于接口的请求数据、请求头数据不会直接展示,而是前后端分别组装合并,
 * 这种情况下利用代理可以获取有效请求数据
 */
private static BrowserMobProxy getBrowserMobProxy() {
    BrowserMobProxy browserMobProxy = new BrowserMobProxyServer();
    browserMobProxy.start();
    browserMobProxy.enableHarCaptureTypes(CaptureType.REQUEST_CONTENT, 
                                        CaptureType.RESPONSE_CONTENT);
    browserMobProxy.setHarCaptureTypes(CaptureType.RESPONSE_CONTENT);
    browserMobProxy.newHar("seleniumUtil");
    browserMobProxy.addHeader("Accept-Charset", "UTF-8");
    return browserMobProxy;
}

/**
 * 封装代理
 * 以下是部分条件是在测试是否可能提升加载速度而添加的部分条件以及处理一些服务器的问题
 * 服务器问题倒是处理了,但是页面加载速度的提升,也不是没有,四舍五入几乎为零吧
 * 也许是我的使用方式不对,如有不足之处还请指出。
 *
 * 哦,对了  不同版本的命令也会不一样...
 **/
private static ChromeOptions getChromeOptions(Proxy seleniumProxy) {
    ChromeOptions options = new ChromeOptions();
    options.setCapability(CapabilityType.PROXY, seleniumProxy);
    //不允许WebDriver使用自动化扩展
    options.setExperimentalOption("useAutomationExtension", false);
    //设置为无头模式,不用打开图形界面,解决了服务器加载失败的问题(注释掉会启动一个新的页面)
    options.addArguments("--headless");
    //最大化运行(全屏窗口), 取元素时需要设置
    options.addArguments("--start-maximized");
    //禁止沙箱模式运行,让谷歌驱动以root身份执行
    options.addArguments("--no-sandbox");
    //禁用GPU
    options.addArguments("--disable-gpu");
    //禁用谷歌浏览器上的扩展
    options.addArguments("--disable-extensions");
    //禁止页面图片加载
    Map<String, Object> prefs = new HashMap<>();
    prefs.put("profile.default_content_settings.images", 2);
    options.setExperimentalOption("prefs", prefs);
    //禁止js
    options.addArguments("--disable-javascript");
    //禁止显示 Chrome正在受到自动软件的控制
    options.addArguments("--disable-infobars");
    //禁止css
    options.addArguments("--disable-css-animations");
    //跳过安全证书认证
    options.setAcceptInsecureCerts(true);
    log.info("ChromeOptions info :" + JSONObject.toJSONString(options));
    return options;
}


/**
 * 用于判断当前url已经加载拦截过,已经加载过的
 * 配合 VIDEO_URL_CACHE_MAP 直接返回对应视频链接,不再执行页面加载拉取流程
 */
private static final Set<String> URL_FILTER_SET = new ConcurrentHashSet<>();
private static final Map<String, String> VIDEO_URL_CACHE_MAP = 
                                                   new ConcurrentHashMap<>();

/**
 * 执行拉取视频数据
 * awemeIdRequestParam 参数非必需,根据实际情况来定
 */
public static void seleniumCatchVideo(String startUrl,
                                      String awemeIdRequestParam,
                                      CompletableFuture<String> completableFuture) {
        String urlAndCreateTime = VIDEO_URL_CACHE_MAP.get(ViewsEnum.LISTEN_URL + awemeIdRequestParam);
        if (StringUtils.isNotBlank(urlAndCreateTime)) {
            //todo 自行做对应的数据处理 并确保赋值给 completableFuture
            completableFuture.complete("...");
            return;
        }
        
        URL_FILTER_SET.remove(LISTEN_URL + awemeIdRequestParam);
        
        BrowserMobProxy browserMobProxy = getBrowserMobProxy();
        Proxy seleniumProxy = ClientUtil.createSeleniumProxy(browserMobProxy);
        ChromeDriver chromeDriver = new ChromeDriver(getChromeOptions(seleniumProxy));
        try {
            //打印请求数据
            logRequestFilterInfo(browserMobProxy);

            //网络响应回调函数
            //在这里拦截到要监听的url后,直接获取接口参数
            getNetCallbackData(browserMobProxy, awemeIdRequestParam, 
                                                completableFuture);

            // 加载网页  这里耗时最长,某些动态加载的页面,设置加载策略会拿不到数据
            // 暂时没有找到好的办法提升速度
            chromeDriver.get(startUrl);
        } finally {
            finish(chromeDriver);
        }
    }
    
    
    /**
     * 利用代理拦截请求数据,拿到数据进行下载
     **/
    private static void getNetCallbackData(BrowserMobProxy browserMobProxy,
                                           String awemeIdRequestParam,
                                           CompletableFuture<String> completableFuture {
        browserMobProxy.addResponseFilter((httpResponse, 
                                           httpMessageContents, 
                                           httpMessageInfo) -> {
            //LISTEN_URL 监听的url
            if (httpMessageInfo.getUrl().contains(LISTEN_URL)) {
                //避免同样的url重复请求
                String finalAimUrl = LISTEN_URL + awemeIdRequestParam;
                if (!URL_FILTER_SET.add(finalAimUrl)) {
                    return;
                }

                // 获取有效cookie 用于调接口
                HttpHeaders headers = httpMessageInfo.getOriginalRequest().headers();
                //todo 现在已经拿到接口请求的全部数据,可以再次请求接口
                //     此处要再次去调用接口的原因:某些网站会对数据进行压缩,此时 
                //     httpMessageContents 中的数据是乱码形式。需要重新调用接口
                //     获取数据进行对应的解压缩
                //     后续业务代码则自行实现
            }
        });
    }
    
    private static void finish(ChromeDriver chromeDriver, 
                               BrowserMobProxy browserMobProxy) {
        if (browserMobProxy.isStarted()) {
            browserMobProxy.stop();
        }
        
        //chromeDriver.quit()退出浏览器 和 chromeDriver.close()关闭当前tab 主要区别是后者进程依然存在
        //如果是复杂的情况下,建议使用 ChromeDriverService 
        chromeDriver.quit();
    }

以上为主要代码,如有不足之处,还请提出,谢谢。

三、 总结

主要注意的点:

1、驱动版本和本地浏览器版本要一致,建议用docker拉取selenium的谷歌镜像,自带对应版本的谷歌驱动和浏览器。(不建议自己下载驱动和浏览器到无图形界面服务器,会有不少坑。。。) 2、某些网站数据做了压缩,需要自行找到压缩方式进行解压缩,不然会拿到乱码数据 3、CompletableFuture 可以避免浪费一些时间,但是也需要做好全局异常的掌控,避免无法释放。