本文介绍一下如何用 Rust 实现一个在无头浏览器里执行网页截图的服务,东西不多,想要入门 Rust 或有类似需求的同学可以看下~
创建项目
安装 Rust 的过程略过,我们直接从创建项目开始。
使用如下命令创建一个名为 rs-screenshot
的 Rust 项目:
cargo new rs-screenshot
安装依赖
在要实现的这个服务中,可以通过 HTTP GET 请求,携带一个查询参数 url
指定需要截图的网站的地址,如访问 https://<API-SERVER>.com?url=https://a.com
返回 https://a.com
页面的截图。
因此,安装依赖 axum
和 tokio
用于创建 HTTP 服务器,安装依赖 headless_chrome
执行无头浏览器的截图操作。
依次执行下面的命令行安装这些依赖:
cargo add axum
cargo add tokio --features full
cargo add headless_chrome -F fetch
说明:
cargo add
将会在当前项目下添加依赖,并记录在Cargo.toml
中--features
和-F
都是指定依赖需要开启的特性- 启用
headless_chrome
的fetch
特性,将会在无头浏览器首次运行时,检测到 Chrome 二进制文件不存在时,自动下载它
依赖安装完成之后,可以在 Cargo.toml
看到如下内容:
[dependencies]
axum = "0.7.5"
tokio = { version = "1.40.0", features = ["full"] }
headless_chrome = { version = "1.0.13", features = ["fetch"] }
在 headless_chrome
的 Github README 中,对应的依赖项写的是 headless_chrome = { git = "https://...", features = ["fetch"] }
,这里用的 git 其实不是很有必要。
实现 HTTP Server
由于我们的小工具允许根据 GET 请求的 url
参数决定要截图哪个网站,让我们先实现 HTTP Server 功能。
#[tokio::main]
async fn main() {
let router = Router::new()
.route("/", get(|| async { "Hello World!" }))
.route("/log-url", get(log_url));
let listener = tokio::net::TcpListener::bind("0.0.0.0:9000").await.unwrap();
println!("🚀 <http://localhost:9000>");
axum::serve(listener, router).await.unwrap();
}
async fn log_url(query: axum::extract::Query<HashMap<String, String>>) -> axum::response::Response {
let url = query.get("url");
if url.is_none() {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "text/plain;charset=UTF-8")
.body(Body::from("请传入 url".to_string()))
.unwrap()
} else {
Response::new(Body::from(url.unwrap().clone()))
}
}
在这个路由中,定义了 2 个路由:
GET /
返回字符串"Hello World!"
字符串GET /log-url
如果查询参数没有url
,返回404 Bad Request
,否则返回url
的内容
一些要点:
- axum 每个路由的处理函数都一定是异步函数
log_url()
的参数定义中query: axum::extract::Query<HashMap<String, String>>)
表示需要提取请求中的查询字符串,并将其转换为HashMap
类型,这也是 axum 框架和 Rust 强大之处的一个体现:路由处理函数参数定义部分,就可以指定需要哪些请求数据、并根据需要自动转换数据;很多其他常见的语言是不具备这个能力的- 路由处理函数一般都需要返回
Response
实例,构造Response
实例的方式多种多样,除了示例中的Response::builder()
和Response::new()
,还有into_response()
也是很常用的 Response
是允许接收一个泛型参数的,如果没有指定,可以根据路由处理函数的返回自动推断出泛型的具体类型
实现网页截图
实现网页截图的流程并不复杂,总共就 4 步:
- 打开浏览器
- 打开新的 Tab
- 导航至目标网站
- 截图
fn screenshot(url: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let launch_opts = LaunchOptions::default_builder()
.headless(true)
.devtools(false)
.sandbox(false)
.port(Some(8010))
// 这里宽高是 window 的,不是 view 的,注意概念区别
.window_size(Some((1600, 1200)))
.build()?;
// 如果不需要自定义配置项,可以用 Browser::default() 快速实例化一个实例
let browser = Browser::new(launch_opts)?;
let tab = browser.new_tab()?;
tab.navigate_to(url)?;
tab.wait_until_navigated()?;
let jpeg_data = tab.capture_screenshot(
Page::CaptureScreenshotFormatOption::Jpeg,
None,
None,
true
)?;
Ok(jpeg_data)
}
这个函数返回的类型是 Result<Vec<u8>, Box<dyn Error>>
类型,这里使用了动态错误类型 Box<dyn Error>
,这样子我们可以很方便的处理内部很多的 Result
数据,只需要使用 ?
,具体的错误如何处理、是否处理交给调用该函数的代码决定。
最终代码
让我们把上面这些功能全部组装起来,写出最终版本的代码。
use axum::{body::Body, http::StatusCode, response::Response, routing::get, Router};
use headless_chrome::{protocol::cdp::Page, Browser, LaunchOptions};
use std::collections::HashMap;
use std::error::Error;
#[tokio::main]
async fn main() {
let router = Router::new()
.route("/", get(|| async { "Hello World!" }))
.route("/screenshot", get(handle_screenshot));
let listener = tokio::net::TcpListener::bind("0.0.0.0:9000").await.unwrap();
println!("🚀 <http://localhost:9000>");
axum::serve(listener, router).await.unwrap();
}
async fn handle_screenshot(query: axum::extract::Query<HashMap<String, String>>) -> Response {
let url = query.get("url");
if url.is_none() {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.header("Content-Type", "text/plain;charset=UTF-8")
.body(Body::from("请传入 url".to_string()))
.unwrap();
}
match screenshot(url.unwrap()) {
Ok(img) => Response::builder().body(Body::from(img)).unwrap(),
Err(err) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from(err.to_string()))
.unwrap(),
}
}
fn screenshot(url: &str) -> Result<Vec<u8>, Box<dyn Error>> {
let launch_opts = LaunchOptions::default_builder()
.headless(true)
.devtools(false)
.sandbox(false)
.port(Some(8010))
// 这里宽高是 window 的,不是 view 的,注意概念区别
.window_size(Some((1600, 1200)))
.build()?;
// 如果不需要自定义配置项,可以用 Browser::default() 快速实例化一个实例
let browser = Browser::new(launch_opts)?;
let tab = browser.new_tab()?;
tab.navigate_to(url)?;
tab.wait_until_navigated()?;
let jpeg_data = tab
.capture_screenshot(Page::CaptureScreenshotFormatOption::Jpeg, None, None, true)?;
Ok(jpeg_data)
}
最终即可实现访问 http://localhost:9000/screenshot?url=xxx
,返回网址 xxx
对应页面截图。
在线体验
这里我也部署了个线上项目:
https://screenshot.shuttleapp.rs/screenshot?url=https://github.com
浏览器打开这个地址即可获取 https://github.com
的截图。大家可以把域名换成其他的,获取其他域名的网页截图。但是中文网页暂时不支持,因为服务器上没有安装中文字体。
其他问题
headless_chrome
在 docker、linux 上很容易会出现依赖缺失的情况。
如何确定是否依赖缺失了呢?
headless_chrome
会自动下载 Chrome binary(具体时机是在执行 Browser::new()
或者构造 LauncheOptions
之后),下载路径类似于: /root/.local/share/headless-chrome/linux-1095492/chrome-linux/chrome
手动执行这里的 chrome
文件,如果出现下面这种报错:
2024-08-30T07:49:58.116235Z [Runtime] 执行失败:
/root/.local/share/headless-chrome/linux-1095492/chrome-linux/chrome: error while loading shared libraries: libdrm.so.2: cannot open shared object file: No such file or directory
这就表示有依赖缺失了。
完整的所需依赖可以点击 这里 查看。
如果我们可以进入命令行界面,就手动执行命令,安装相关的依赖。
比如在 Debian 环境中,使用 apt
安装依赖。
如果无法进入命令行界面,例如 Rust 服务将会以云函数的形式部署在第三方云服务的平台上,也是有办法的。
因为部署 Rust 应用,肯定是要执行 cargo build
的,而这个时候如果项目根目录下有个 build.rs
文件,那么在执行构建的时候会自动执行这个构建脚本文件。我们就可以利用这个时机,安装必要的依赖。
// build.rs
use std::{borrow::Borrow, process::Command};
fn main() {
// 可以根据 hostname,实现只在特定平台上部署应用
// 因为云服务商提供的平台的 hostname 一般都会有一些特征
// let hostname = std::env::var("HOSTNAME").unwrap_or_default();
let output = Command::new("apt")
.args([
"install",
"-y",
"ca-certificates",
"fonts-liberation",
"libasound2",
...
])
.output()
.unwrap();
if !output.status.success() {
eprintln!("ERR: {}", String::from_utf8_lossy(output.stderr.borrow()));
}
}
注意, build.rs
输出的内容不会在命令行界面中打印,而是会保存在 target/debug/build/<pkg>/output
文件中。
关于构建脚本更多内容,可以参考 官方文档。