作者:令川 | 发布时间:2024-08-30

Rust 实现一个网页截图的服务

本文介绍一下如何用 Rust 实现一个在无头浏览器里执行网页截图的服务,东西不多,想要入门 Rust 或有类似需求的同学可以看下~

创建项目

安装 Rust 的过程略过,我们直接从创建项目开始。

使用如下命令创建一个名为 rs-screenshot 的 Rust 项目:

cargo new rs-screenshot

安装依赖

在要实现的这个服务中,可以通过 HTTP GET 请求,携带一个查询参数 url 指定需要截图的网站的地址,如访问 https://<API-SERVER>.com?url=https://a.com 返回 https://a.com 页面的截图。

因此,安装依赖 axumtokio 用于创建 HTTP 服务器,安装依赖 headless_chrome 执行无头浏览器的截图操作。

依次执行下面的命令行安装这些依赖:

cargo add axum
cargo add tokio --features full
cargo add headless_chrome -F fetch

说明:

依赖安装完成之后,可以在 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 个路由:

  1. GET / 返回字符串 "Hello World!" 字符串
  2. GET /log-url 如果查询参数没有 url ,返回 404 Bad Request ,否则返回 url 的内容

一些要点:

实现网页截图

实现网页截图的流程并不复杂,总共就 4 步:

  1. 打开浏览器
  2. 打开新的 Tab
  3. 导航至目标网站
  4. 截图
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 文件中。

关于构建脚本更多内容,可以参考 官方文档

目录 / Contents

空。

令川 · 记