需求和方案
接到个研发需求任务,需要为网页版的类似文档的内容,能够提供后台服务自动下载成PDF的功能。原先前端是有让用户自行点击下载成为PDF的功能。当前这个功能如果后端服务也需要拥有的化,我的第一个反应是,后端是有数据的,找设计要下文档的排版,然后用个PDF的三方库,直接按照排版手搓一个PDF文件。我网上找了JAVA的PDF库,Apache PDFBOX,是个开源的JAVA库,可以用于创建、修改和解析PDF文档。
pom.xml
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.27</version>
</dependency>
但是当我上手开始写PDF的时候,发现要实现网页版的复杂样式还是很复杂的。
我想了第二种思路,就通过数据自动化生成HTML,然后通过工具直接转化为PDF文件,我哼哧哼哧在网上找了一些三方库,尝试使用,比如下面的openhtmltopdf-pdfbox是一个开源且免费的库,用于将 HTML 转换为 PDF。它使用 Apache PDFBox 库来生成 PDF 文档。支持多种 HTML 和 CSS 特性。
pom.xml
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.0.10</version>
</dependency>
用起来发现,他对于比较复杂的html或者CSS语法还是识别不够准确,HTML能够在浏览器上很好的展示样式,但是转成PDF就导致样式错乱,直接没法下手,抓耳挠腮。
我又在思考还有什么比较好的方法呢?我突然想到为啥前端同学可以做到样式不变的下载成为PDF文件?我就和前端同学讨论了原理和方法。原来他们是使用Canvas通过页面截图的方式来生成对应的PDF文件。
这个方案倒是启发了我,后端有没有一个工具,能够访问对应HTML的URL,然后通过在某个区域进行截图的方式,存储为图片,然后再将图片转成PDF文件呢。这样我就不需要感知HTML复杂的样式设计,直接粗暴的将浏览器上的展示样式直接生搬硬套成PDF文件,这样的好处是用户在页面上手动下载的PDF和我后端服务生成的PDF结构样式都是统一的,都是基于原有的HTML页面。
这个类似于爬虫的快照方案,把目标网页的指定区域截图抓下来。我就找到了Selenium。Selenium 是一种用于自动化 Web 浏览器的工具。它允许您控制浏览器,就像您自己在使用浏览器一样。您可以使用 Selenium 来模拟用户在浏览器中的行为,例如点击按钮、填写表单、验证文本等。Selenium 最常用于自动化 Web 测试。您可以使用 Selenium 来测试 Web 应用程序的功能、可用性和性能。Selenium 还可以用于其他目的,例如数据抓取、屏幕截图和浏览器自动化。
Selenium 的主要优点包括:
- 跨平台支持: Selenium 可以在 Windows、macOS 和 Linux 等多种平台上运行。
- 支持多种浏览器: Selenium 支持 Chrome、Firefox、Edge、Safari 等多种浏览器。
- 易于使用: Selenium 提供了简单易用的 API,使您可以轻松地编写自动化脚本。
- 开源且免费: Selenium 是一个开源且免费的工具,您可以自由地下载和使用它。
所以最后选定的方案就是:
「访问URL ==》 指定区域截图为PNG ==》将PNG转成PDF」
后面我就介绍了在我本机MAC上和Linux服务器上搭建Selenium+Chrome的方案
MAC本地解决方案
1、添加Maven依赖
pom.xml
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>3.141.59</version>
</dependency>
虽然你能够在 mvnrepository.com/artifact/or...
上看到很多4.XX的版本,但是实际运行下来会有Class Not Found的报错
bash
java.lang.NoClassDefFoundError: org/openqa/selenium/internal/Require
所以找了这个最稳定,下载量最大的版本进行安装。
2、需要本地安装ChromeDriver
由于我们本地都有Chrome浏览器,所以我就打算让程序访问本地的Chrome程序渲染对应的URL链接。那么这个时候就需要ChromeDriver了,他是谷歌浏览器的驱动程序,可以用于自动化测试和网页爬虫等任务。
因为ChromeDriver的版本要和Chrome浏览器的版本适配匹配,所以得先打开你本地的Chrome查看对应的版本。
我的版本是120.X , 所以需要到最新如下的地址下载最新的版本 googlechromelabs.github.io/chrome-for-...
由于我的电脑是MAC M1 Pro,需要选择对应的ARM架构
如果是比较低的版本,可以选择国内的镜像下载 :registry.npmmirror.com/binary.html... , 这个地址最高版本能够下载到114.X
然后解压下载的zip文件,将里面的chromedriver可执行文件,拖到/usr/local/bin路径下,如下
然后双击该文件,会出现报错信息:
根据提示分析,该应用违背了Mac系统的叫做GateKeeper的安全机制, 就是当打开没有签名的Mac应用时,就会出现'App can't be opened because it is from an unidentified developer'的错误。 当删除这个属性,就可以去除app的隔离性,正常运行。知道了问题根源所在,那么就在命令行下执行:
bash
xattr -d com.apple.quarantine chromedriver
执行后,再双击chromedriver文件,如果出现如下successfully字样,则说明已经安装成功。
最后在给软件授权
bash
sudo chmod 777 /usr/local/bin/chromedriver
这样可以使用代码来访问了
3、JAVA代码触发
java
package com.pai.server.common.utils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
/**
* WebDriverUtil
* 网页元素快照工具类
*
* @version : V1.0
* @date : 2023/12/21 2:05 PM
*/
public class WebDriverUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(WebDriverUtil.class);
/**
* 网页元素快照成PNG文件
*/
public static WebElementStruct snapshotWebElement2Png(String url, String pngFilePath, String webElementClassName, int waitLoadSeconds) {
// 可以切换到本地的WebDriver
WebDriver driver = initLocalWebDriver();
try {
LOGGER.info("网页元素快照开始!url = " + url + ",pngFilePath = " + pngFilePath + ",webElementClassName = " + webElementClassName + ",waitLoadSeconds = " + waitLoadSeconds);
// ChromeDrive开始加载页面
driver.get(url);
// 本地的WebDriver可以使用Sleep来等待页面加载完毕
Thread.sleep(10000);
// 获取需要截图的元素,以及对应的长宽和坐标
WebElement element = driver.findElement(By.className(webElementClassName));
int elementWidth = element.getSize().getWidth();
int elementHeight = element.getSize().getHeight();
int elementX = element.getLocation().getX();
int elementY = element.getLocation().getY();
WebElementStruct webElementStruct = new WebDriverUtil().new WebElementStruct();
webElementStruct.x = elementX;
webElementStruct.y = elementY;
webElementStruct.width = elementWidth;
webElementStruct.height = elementHeight;
System.out.println("查询到的WebElementStruct = " + webElementStruct);
// 重新设置对应的浏览器窗口大小,将元素正好在浏览器中展示出来
int windowWidth = elementWidth;
int windowHeight = elementHeight + elementY;
driver.manage().window().setSize(new Dimension(windowWidth, windowHeight));
// 快照存储
File srcFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
FileUtils.copyFile(srcFile, new File(FilenameUtils.normalize(pngFilePath)));
LOGGER.info("网页元素快照成功!url = " + url + ",pngFilePath = " + pngFilePath + ",webElementClassName = " + webElementClassName + ",waitLoadSeconds = " + waitLoadSeconds);
return webElementStruct;
} catch (Throwable e) {
LOGGER.error("网页元素快照失败!url = " + url + ",pngFilePath = " + pngFilePath + ",webElementClassName = " + webElementClassName + ",waitLoadSeconds = " + waitLoadSeconds, e);
throw new RuntimeException(e);
} finally {
driver.quit();
}
}
/**
* 初始化本地测试的WebDriver
*
* @return
*/
private static WebDriver initLocalWebDriver() {
LOGGER.info(">>>>>>>> 开始设置本地WebDriver <<<<<<<<");
ChromeOptions options = new ChromeOptions();
options.addArguments("disable-infobars");
options.addArguments("--headless");
options.addArguments("--dns-prefetch-disable");
options.addArguments("--no-referrers");
options.addArguments("--disable-gpu");
options.addArguments("--disable-audio");
options.addArguments("--no-sandbox");
options.addArguments("--ignore-certificate-errors");
options.addArguments("--allow-insecure-localhost");
// 设置chrome的页面Load机制
options.setPageLoadStrategy(PageLoadStrategy.EAGER);
// 设置驱动
// 通过系统类型,获取chrome驱动位置
String chromeDriver = "/usr/local/bin/chromedriver";
System.setProperty("webdriver.chrome.driver", chromeDriver);
// 无头模式
System.setProperty("java.awt.headless", "true");
WebDriver driver = new ChromeDriver(options);
LOGGER.info(">>>>>>>> 结束设置本地WebDriver <<<<<<<<");
return driver;
}
/**
* 网页元素区域
*/
public class WebElementStruct {
private int x;
private int y;
private int width;
private int height;
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
}
public static void main(String[] args) {
String url = "https://app.pinvo.io/preview/invoice?t_id=IITSS98A7JpNePgNJyFDVzNdl286a3YljplC3P+2S84=&nw_k=33431d08-8347-4bb8-9111-e3a89b455ad9";
String imagePath = "/Users/shaoshuai.shao/Desktop/html.png";
snapshotWebElement2Png(url, imagePath, "pinvo-invoice-pdf", 10);
}
}
Linux服务器解决方案
1、Chrome + Chromedriver部署
本地我们还有Chrome浏览器可以访问对应的URL,服务器上我们就需要安装和部署类似于浏览器的能力,最简单最直接的思考路径就是有没有现成的Docker可以部署?
顺着线索找到了selenium/standalone-chrome项目
你可以在对应的tags目录下,找到对应的你所需要的版本
Docker部署脚本命令:
bash
docker run --name selenium-chrome -p 4444:4444 --shm-size=2g selenium/standalone-chrome:120.0-chromedriver-120.0
如上,我选择了Chrome版本为120.0,ChromeDriver也是版本为120.0的docker版本
由于我在AWS上进行部署,所以将服务器的4444端口打开即可;如果是阿里云的服务器上进行部署,貌似端口4444不可用,可以映射到别的端口。
这样服务端就轻松部署完毕。
2、调试遇到些问题
其中在调试过程中,还是遇到了一些问题,在请求服务端的时候,会报如下的错误:
bash
[1556179366.141][SEVERE]: bind() failed: Cannot assign requested address (99)
后来查询问题的原因是需要在JAVA代码中,Options里面新增如下参数。
JAVA
options.addArguments('--disable-dev-shm-usage')
--disable-dev-shm-usage 参数用于禁用 Chrome 中的 DevTools 共享内存。这可以减少 Chrome 使用的内存量,从而提高性能。
DevTools 共享内存是一个特殊的内存区域,用于在 Chrome DevTools 和 Chrome 浏览器之间共享数据。它通常用于存储快照、日志和其他调试数据。
在某些情况下,DevTools 共享内存可能会导致 Chrome 使用过多的内存。例如,如果您在 Chrome 中打开了很多标签页,或者您正在使用内存密集型的 DevTools 功能,那么 DevTools 共享内存可能会增长到非常大。
禁用 DevTools 共享内存可以防止 Chrome 使用过多的内存。这对于内存有限的计算机来说非常有用。
3、JAVA代码
对于代码编写,可以查看官网的解释
www.selenium.dev/documentati...
java
package com.pai.server.common.utils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.openqa.selenium.*;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.remote.RemoteWebDriver;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
/**
* WebDriverUtil
* 网页元素快照工具类
*
* @version : V1.0
* @date : 2023/12/21 2:05 PM
*/
public class WebDriverUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(WebDriverUtil.class);
/**
* 网页元素快照成PNG文件
*/
public static WebElementStruct snapshotWebElement2Png(String url, String pngFilePath, String webElementClassName, int waitLoadSeconds) {
// 使用远程的WebDriver
WebDriver driver = initRemoteWebDriver();
try {
LOGGER.info("网页元素快照开始!url = " + url + ",pngFilePath = " + pngFilePath + ",webElementClassName = " + webElementClassName + ",waitLoadSeconds = " + waitLoadSeconds);
// ChromeDrive开始加载页面
driver.get(url);
// 等待页面加载完毕
WebDriverWait wait = new WebDriverWait(driver, waitLoadSeconds);
wait.until(ExpectedConditions.presenceOfElementLocated(By.className(webElementClassName)));
// 获取需要截图的元素,以及对应的长宽和坐标
WebElement element = driver.findElement(By.className(webElementClassName));
int elementWidth = element.getSize().getWidth();
int elementHeight = element.getSize().getHeight();
int elementX = element.getLocation().getX();
int elementY = element.getLocation().getY();
WebElementStruct webElementStruct = new WebDriverUtil().new WebElementStruct();
webElementStruct.x = elementX;
webElementStruct.y = elementY;
webElementStruct.width = elementWidth;
webElementStruct.height = elementHeight;
System.out.println("查询到的WebElementStruct = " + webElementStruct);
// 重新设置对应的浏览器窗口大小,将元素正好在浏览器中展示出来
int windowWidth = elementWidth;
int windowHeight = elementHeight + elementY;
driver.manage().window().setSize(new Dimension(windowWidth, windowHeight));
// 快照存储
File srcFile = ((TakesScreenshot) driver).getScreenshotAs(OutputType.FILE);
FileUtils.copyFile(srcFile, new File(FilenameUtils.normalize(pngFilePath)));
LOGGER.info("网页元素快照成功!url = " + url + ",pngFilePath = " + pngFilePath + ",webElementClassName = " + webElementClassName + ",waitLoadSeconds = " + waitLoadSeconds);
return webElementStruct;
} catch (Throwable e) {
LOGGER.error("网页元素快照失败!url = " + url + ",pngFilePath = " + pngFilePath + ",webElementClassName = " + webElementClassName + ",waitLoadSeconds = " + waitLoadSeconds, e);
throw new RuntimeException(e);
} finally {
driver.quit();
}
}
private static WebDriver initRemoteWebDriver() {
LOGGER.info(">>>>>>>> 开始设置RemoteWebDriver <<<<<<<<");
try {
ChromeOptions options = new ChromeOptions();
options.addArguments("disable-infobars");
options.addArguments("--headless");
options.addArguments("--dns-prefetch-disable");
options.addArguments("--no-referrers");
options.addArguments("--disable-gpu");
options.addArguments("--disable-audio");
options.addArguments("--no-sandbox");
options.addArguments("--ignore-certificate-errors");
options.addArguments("--allow-insecure-localhost");
options.addArguments("--disable-dev-shm-usage");
Map<String, Integer> timeouts = new HashMap<>();
timeouts.put("implicit", 10000);
options.setCapability("timeouts", timeouts);
// 设置chrome的页面Load机制
options.setPageLoadStrategy(PageLoadStrategy.EAGER);
WebDriver driver = new RemoteWebDriver(new URL("IP:4444"), options);
LOGGER.info(">>>>>>>> 完成设置RemoteWebDriver <<<<<<<<");
return driver;
} catch (Throwable e) {
LOGGER.error("初始化远程WebDriver失败", e);
throw new RuntimeException(e);
}
}
/**
* 网页元素区域
*/
public class WebElementStruct {
private int x;
private int y;
private int width;
private int height;
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
}
public static void main(String[] args) {
String url = "https://app.pinvo.io/preview/invoice?t_id=IITSS98A7JpNePgNJyFDVzNdl286a3YljplC3P+2S84=&nw_k=33431d08-8347-4bb8-9111-e3a89b455ad9";
String imagePath = "/Users/shaoshuai.shao/Desktop/html.png";
snapshotWebElement2Png(url, imagePath, "pinvo-invoice-pdf", 10);
}
}
引用
Mac系统安装chromedriver遇到的问题和解决办法_the version of chrome cannot be detected. trying w-CSDN博客
ChromeDriver谷歌驱动最新版安装118/119/120-CSDN博客
基于Java+selenium+Chrome,实现截取html页面内容并保存为图片 - _天青色烟雨 - 博客园