标签

#AI编程 #Java #SpringBoot #Selenium #网页截图 #对象存储

引言

上一节我们完成了AI应用生成模块的核心能力开发,实现了从提示词到代码的流式生成、应用与代码绑定、权限校验等核心功能。在实际平台化场景中,应用预览是提升用户体验、增强平台专业性的关键一环——如果每个生成的应用都有精美的预览封面,用户无需打开网页就能直观看到效果。

本节实训我们聚焦应用封面图自动生成功能,基于Selenium无头浏览器实现网页自动截图,结合腾讯云COS对象存储完成图片持久化,最终实现“部署应用→自动截图→上传云端→更新数据库→清理本地文件”的全流程闭环。下面从设计思路、技术选型、代码实现、测试验证四个维度,完整复盘整个开发过程。

一、模块整体设计思路

1. 核心价值

  • 所见即所得:直接截取应用运行时页面作为封面,真实还原效果
  • 自动化:无需人工干预,部署完成后异步自动生成,不阻塞主流程
  • 轻量化:截图压缩后上传,兼顾清晰度与存储成本
  • 可持久化:云端存储封面图,支持跨设备访问,本地不残留冗余文件

2. 技术选型对比

封面生成核心是网页截图,主流技术方案对比如下:

技术方案 依赖大小 启动时间 内存占用 JS支持 推荐度
Selenium ~50MB 3-5秒 200-500MB 完整支持 ★★★★★
Playwright ~100MB 1-2秒 100-300MB 完整支持 ★★★★☆
HtmlUnit ~30MB <1秒 20-50MB 有限支持 ★★☆☆☆
Puppeteer ~200MB 2-3秒 150-400MB 完整支持 ★★★☆☆
云服务API 0MB 网络延迟 0MB 服务商决定 ★★★☆☆

最终选型:Selenium
适配我们Java技术栈,成熟稳定、完整支持JS渲染,能兼容所有生成的前端页面,搭配WebDriverManager可自动管理浏览器驱动,大幅降低配置成本。

3. 核心业务流程

  1. 用户完成AI应用部署,生成可访问的应用URL
  2. 异步触发封面生成任务(不阻塞部署流程)
  3. Selenium无头浏览器访问应用URL,等待页面完全加载(含JS渲染)
  4. 截取页面完整截图,对图片进行压缩处理
  5. 将压缩后的图片上传至腾讯云COS对象存储
  6. 从COS获取图片访问URL,更新数据库中应用表的cover字段
  7. 清理本地临时截图文件,避免磁盘占用

二、核心代码改造与实现

1. 引入项目依赖

pom.xml中引入Selenium、WebDriverManager、腾讯云COS、Hutool等核心依赖:

<!-- Selenium 网页截图核心依赖 -->
<dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.33.0</version>
</dependency>
<!-- WebDriverManager 自动管理Chrome驱动 -->
<dependency>
    <groupId>io.github.bonigarcia</groupId>
    <artifactId>webdrivermanager</artifactId>
    <version>6.1.0</version>
</dependency>
<!-- 腾讯云COS 对象存储依赖 -->
<dependency>
    <groupId>com.qcloud</groupId>
    <artifactId>cos_api</artifactId>
    <version>5.6.227</version>
</dependency>
<!-- Hutool 工具库(文件、压缩、IO操作) -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.38</version>
</dependency>

2. 开发网页截图工具类

utils包下创建WebScreenshotUtils,实现驱动初始化、截图、压缩、文件清理等核心能力:

package com.pt.aicodeformal.utils;

import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import com.pt.aicodeformal.exception.BusinessException;
import com.pt.aicodeformal.exception.ErrorCode;
import io.github.bonigarcia.wdm.WebDriverManager;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.WebDriverWait;

import java.io.File;
import java.time.Duration;
import java.util.UUID;

@Slf4j
public class WebScreenshotUtils {

    // 线程隔离:每个线程独有WebDriver,避免并发冲突
    private static final ThreadLocal<WebDriver> DRIVER_THREAD_LOCAL = new ThreadLocal<>();
    // 截图窗口默认尺寸
    private static final int DEFAULT_WIDTH = 1600;
    private static final int DEFAULT_HEIGHT = 900;
    // 图片压缩质量(30%,平衡清晰度和大小)
    private static final float COMPRESSION_QUALITY = 0.3f;

    /**
     * 初始化Chrome无头浏览器驱动
     */
    private static WebDriver initChromeDriver(int width, int height) {
        try {
            // 自动下载并匹配Chrome驱动
            WebDriverManager.chromedriver().setup();
            ChromeOptions options = new ChromeOptions();
            // 新版无头模式(替代旧版--headless)
            options.addArguments("--headless=new");
            // 环境兼容配置
            options.addArguments("--disable-gpu");
            options.addArguments("--no-sandbox");
            options.addArguments("--disable-dev-shm-usage");
            // 设置窗口大小
            options.addArguments(String.format("--window-size=%d,%d", width, height));
            // 禁用扩展、优化性能
            options.addArguments("--disable-extensions");
            // 设置用户代理
            options.addArguments("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36");

            WebDriver driver = new ChromeDriver(options);
            // 超时配置
            driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30));
            driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10));
            return driver;
        } catch (Exception e) {
            log.error("初始化Chrome浏览器失败", e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "初始化Chrome浏览器失败");
        }
    }

    /**
     * 获取当前线程驱动,复用不重复创建
     */
    private static WebDriver getDriver() {
        WebDriver driver = DRIVER_THREAD_LOCAL.get();
        if (driver == null) {
            driver = initChromeDriver(DEFAULT_WIDTH, DEFAULT_HEIGHT);
            DRIVER_THREAD_LOCAL.set(driver);
        }
        return driver;
    }

    /**
     * 关闭当前线程驱动并清理引用
     */
    public static void closeDriver() {
        WebDriver driver = DRIVER_THREAD_LOCAL.get();
        if (driver != null) {
            try {
                driver.quit();
            } catch (Exception e) {
                log.error("关闭浏览器进程异常", e);
            } finally {
                DRIVER_THREAD_LOCAL.remove();
            }
        }
    }

    /**
     * 等待页面完全加载(含JS渲染)
     */
    private static void waitForPageLoad(WebDriver driver) {
        try {
            WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
            wait.until(webDriver ->
                    ((JavascriptExecutor) webDriver).executeScript("return document.readyState")
                            .equals("complete")
            );
            // 额外等待2秒,确保动态内容渲染完成
            Thread.sleep(2000);
            log.info("页面加载完成");
        } catch (Exception e) {
            log.warn("等待页面加载超时,继续执行截图");
        }
    }

    /**
     * 生成网页截图并返回压缩后的本地路径
     * @param webUrl 网页访问URL
     * @return 压缩后图片路径
     */
    public static String saveWebPageScreenshot(String webUrl) {
        if (StrUtil.isBlank(webUrl)) {
            log.error("网页URL不能为空");
            return null;
        }
        WebDriver driver = null;
        try {
            // 创建临时目录
            String rootPath = System.getProperty("user.dir") + File.separator + "tmp" + File.separator + "screenshots"
                    + File.separator + UUID.randomUUID().toString().substring(0, 8);
            FileUtil.mkdir(rootPath);

            // 获取当前线程浏览器
            driver = getDriver();
            // 访问网页并等待加载
            driver.get(webUrl);
            waitForPageLoad(driver);

            // 截图并保存原始图片
            String originalPath = rootPath + File.separator + RandomUtil.randomNumbers(5) + ".png";
            byte[] screenshotBytes = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
            FileUtil.writeBytes(screenshotBytes, originalPath);

            // 压缩图片
            String compressedPath = rootPath + File.separator + RandomUtil.randomNumbers(5) + "_compressed.jpg";
            ImgUtil.compress(FileUtil.file(originalPath), FileUtil.file(compressedPath), COMPRESSION_QUALITY);

            // 删除原始图片
            FileUtil.del(originalPath);
            return compressedPath;
        } catch (Exception e) {
            log.error("网页截图失败:{}", webUrl, e);
            return null;
        } finally {
            // 释放驱动资源
            closeDriver();
        }
    }

    /**
     * 清理本地临时截图目录
     * @param filePath 截图文件路径
     */
    public static void cleanupTempFile(String filePath) {
        if (StrUtil.isBlank(filePath)) {
            return;
        }
        File file = new File(filePath);
        if (file.exists()) {
            File parentDir = file.getParentFile();
            FileUtil.del(parentDir);
            log.info("清理本地临时截图目录:{}", parentDir.getAbsolutePath());
        }
    }
}

3. 腾讯云COS配置

3.1 配置文件(application.yml)
# 腾讯云COS配置
cos:
  client:
    host: cos.ap-shanghai.myqcloud.com
    secret-id: 你的腾讯云secretId
    secret-key: 你的腾讯云secretKey
    region: ap-shanghai
    bucket: 你的存储桶名称
3.2 COS配置类(CosClientConfig.java)
package com.pt.aicodeformal.config;

import com.qcloud.cos.COSClient;
import com.qcloud.cos.ClientConfig;
import com.qcloud.cos.auth.BasicCOSCredentials;
import com.qcloud.cos.auth.COSCredentials;
import com.qcloud.cos.region.Region;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Data
@Configuration
@ConfigurationProperties(prefix = "cos.client")
public class CosClientConfig {
    private String host;
    private String secretId;
    private String secretKey;
    private String region;
    private String bucket;

    @Bean
    public COSClient cosClient() {
        COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
        ClientConfig clientConfig = new ClientConfig(new Region(region));
        return new COSClient(cred, clientConfig);
    }
}
3.3 COS工具类(CosManager.java)
package com.pt.aicodeformal.manager;

import cn.hutool.core.util.StrUtil;
import com.pt.aicodeformal.config.CosClientConfig;
import com.pt.aicodeformal.exception.BusinessException;
import com.pt.aicodeformal.exception.ErrorCode;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.PutObjectRequest;
import com.qcloud.cos.model.PutObjectResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.io.File;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

@Slf4j
@Component
public class CosManager {
    @Resource
    private CosClientConfig cosClientConfig;
    @Resource
    private COSClient cosClient;

    /**
     * 上传文件到COS并返回访问URL
     */
    public String uploadFile(String key, File file) {
        if (StrUtil.isBlank(key) || file == null || !file.exists()) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "上传参数无效");
        }
        try {
            PutObjectRequest request = new PutObjectRequest(cosClientConfig.getBucket(), key, file);
            PutObjectResult result = cosClient.putObject(request);
            String url = String.format("%s/%s", cosClientConfig.getHost(), key);
            log.info("文件上传COS成功:{} -> {}", file.getName(), url);
            return url;
        } catch (Exception e) {
            log.error("文件上传COS失败", e);
            throw new BusinessException(ErrorCode.SYSTEM_ERROR, "文件上传COS失败");
        }
    }

    /**
     * 生成COS存储路径(按日期分层)
     */
    public String generateScreenshotKey(String fileName) {
        String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
        return String.format("/screenshots/%s/%s", datePath, fileName);
    }
}

4. 截图服务封装

package com.pt.aicodeformal.service;

public interface ScreenshotService {
    /**
     * 生成网页截图、上传COS、清理本地文件
     * @param webUrl 网页URL
     * @return 封面图访问URL
     */
    String generateAndUploadScreenshot(String webUrl);
}
package com.pt.aicodeformal.service.impl;

import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import com.pt.aicodeformal.exception.BusinessException;
import com.pt.aicodeformal.exception.ErrorCode;
import com.pt.aicodeformal.manager.CosManager;
import com.pt.aicodeformal.service.ScreenshotService;
import com.pt.aicodeformal.utils.WebScreenshotUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.io.File;

@Slf4j
@Service
public class ScreenshotServiceImpl implements ScreenshotService {
    private final CosManager cosManager;

    public ScreenshotServiceImpl(CosManager cosManager) {
        this.cosManager = cosManager;
    }

    @Override
    public String generateAndUploadScreenshot(String webUrl) {
        if (StrUtil.isBlank(webUrl)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "网页URL不能为空");
        }
        log.info("开始生成网页截图:{}", webUrl);
        // 1. 生成本地截图
        String localPath = WebScreenshotUtils.saveWebPageScreenshot(webUrl);
        if (StrUtil.isBlank(localPath)) {
            throw new BusinessException(ErrorCode.OPERATION_ERROR, "本地截图生成失败");
        }
        try {
            // 2. 上传COS
            File screenshotFile = new File(localPath);
            String fileName = RandomUtil.randomNumbers(8) + "_compressed.jpg";
            String cosKey = cosManager.generateScreenshotKey(fileName);
            String cosUrl = cosManager.uploadFile(cosKey, screenshotFile);
            log.info("网页截图生成并上传成功:{} -> {}", webUrl, cosUrl);
            return cosUrl;
        } finally {
            // 3. 清理本地文件
            WebScreenshotUtils.cleanupTempFile(localPath);
        }
    }
}

5. 异步触发截图(应用部署后调用)

AppServicedeployApp方法末尾,添加异步截图逻辑:

@Resource
private ScreenshotService screenshotService;

@Override
public String deployApp(Long appId, User loginUser) {
    // ... 原有部署逻辑,生成应用访问URL ...
    String appDeployUrl = "http://localhost/" + deployKey + "/";
    // 异步生成封面(Java21虚拟线程,不阻塞主线程)
    Thread.startVirtualThread(() -> {
        try {
            String coverUrl = screenshotService.generateAndUploadScreenshot(appDeployUrl);
            // 更新数据库应用封面
            App app = new App();
            app.setId(appId);
            app.setCover(coverUrl);
            this.updateById(app);
            log.info("应用封面更新成功,appId:{}", appId);
        } catch (Exception e) {
            log.error("异步生成应用封面失败,appId:{}", appId, e);
        }
    });
    return appDeployUrl;
}

三、功能测试与验证

1. 环境准备

  1. 服务器安装Chrome浏览器(Selenium依赖)
  2. 腾讯云COS创建存储桶,配置secretId/secretKey
  3. 项目配置文件填入COS信息,启动SpringBoot项目

2. 测试步骤

  1. 调用addApp接口创建应用
  2. 调用chatToGenCode接口生成网页代码
  3. 调用deployApp接口部署应用,获取访问URL
  4. 等待10秒(异步任务执行)
  5. 查询数据库app表,cover字段已填充COS访问URL
  6. 浏览器打开cover对应URL,可正常查看封面图

3. 常见问题解决

  • Chrome驱动下载失败:配置国内镜像,添加系统属性
    static {
        System.setProperty("wdm.mirror", "https://npmmirror.com/mirrors/chromedriver/");
    }
    
  • 无头模式启动失败:Docker环境添加--no-sandbox参数,服务器关闭图形界面
  • COS上传失败:检查密钥权限、存储桶地域配置、网络连通性
  • 本地文件残留:确保finally块执行文件清理逻辑

四、总结与后续优化

1. 本次实训收获

  • 掌握Selenium无头浏览器实现网页自动截图的核心技术
  • 理解对象存储(腾讯云COS)的文件上传、路径管理流程
  • 学会Java21虚拟线程实现异步任务,避免阻塞主业务
  • 掌握临时文件清理、资源释放、并发安全的设计思路

2. 功能价值

封面生成功能让AI应用生成平台从“能用”升级到“好用”,大幅提升用户使用意愿,同时实现了截图自动化、存储云端化、流程闭环化,符合平台化产品的设计理念。

3. 后续优化方向

  1. 定时清理COS过期截图,节省存储成本
  2. 配置默认封面图,截图失败时兜底展示
  3. 结合AI智能路由,根据应用复杂度调整截图尺寸
  4. 增加截图质量动态配置,适配不同网络环境
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐