用 Puppeteer 生成网页预览图

问题

现在社交网络一般都支持网站设置预览图,有预览图的网页能更占据更大的展示空间,提高点击率。

GeekNote 此前已支持作者自己设置文章封面,并且默认将封面设为预览图。但不是所有作者都有空设置封面,影响传播效果。

pika-1667121262270-1x.png

于是我就想给网站加上自动生成预览图的功能,这个功能要怎么实现呢?

解决方案

基本的思路如下:

  • 用 HTML/CSS 设计预览图的内容。
  • 将 HTML/CSS 内容转换成图片。
  • 在网页 head 添加相关的 meta tag。

关键的一步在于怎么将 HTML/CSS 转换成图片。经过一些调研,我觉得 Puppeteer 是目前最好的选择。

Puppeteer 简介

Puppeteer 是 Chrome DevTools 团队维护的 Node.js 库,它可以通过 DevTools Protocol 操作 Chrome/Chromuim,实现截图、服务端渲染、自动化测试等功能。

Puppeteer 运行时需要 Node.js,而 GeekNote 是用 Ruby on Rails 开发的,我不想增加一个 node 运行时依赖。好在找到了一个 Ruby 版的 Puppeteer:puppeteer-ruby,我最终选择了这个库。

注:puppeteer-ruby 不是 Chrome DevTools 团队维护的。

以下是实现过程。

安装依赖

首先安装系统依赖:

apt-get install chromium fonts-noto-cjk

由于 noto cjk 字体的字形默认是日文,这里设置一个环境变量让系统默认选择中文:

export LANG=zh_CN.UTF-8

接着,在 Gemfile 中增加以下内容:

gem 'puppeteer-ruby'

然后安装:

bundle install

小试牛刀

先测试一下安装结果,新建一个测试文件 tmp/screenshot.rb,内容如下:

Puppeteer.launch do |browser|
  page = browser.new_page
  page.goto("https://geeknote.net/")
  page.viewport = Puppeteer::Viewport.new(width: 1280, height: 800, device_scale_factor: 2)
  page.screenshot(path: "tmp/screenshot.png")
end

然后通过 Rails runner 运行:

bin/rails runner tmp/screenshot.rb

如果一切正常,会看到生成了文件 tmp/screenshot.png,内容为网页截图。

如果跟我一样,开发时使用的是 Docker 环境,遇到了以下错误:

Running as root without --no-sandbox is not supported.

这是因为 Chrome 需要非 root 用户执行才能正常启用 sandbox。解决方法是把 Docker 容器内的用户改为非 root 用户。

其他问题可以参考 https://pptr.dev/troubleshooting

添加模版

要生成预览图模版,可以直接利用 Rails 的 View 层,这样方便开发预览。

在控制器内添加以下代码:

class PostsController < ApplicationController
  def social_image
    @post = Post.find params[:id]
  end
end

添加模版,此处省略样式相关的内容:

<div class="...">
  <h1><%= @post.title %></h1>
  <%= @post.user.name %>
</div>

添加路由:

resources :posts do
  member do
    get :social_image
  end
end

然后访问 /posts/:id/social_image,可以看到 HTML 形式的预览图。修改模版和样式,将它设计为自己需要的样子。

接下来要把模版转换为图片。

迭代一:即时生成图片

要生成预览图,一种方法是在控制器内即时生成,以下是实现:

def social_image
   respond_to do |format|
     format.html

     format.png do
       html = render_to_string formats: :html

       Puppeteer.launch do |browser|
         # 此处通过 future 让图片生成异步执行,否则会阻塞开发环境服务器。
         image = future do
           page = browser.new_page
           page.viewport = Puppeteer::Viewport.new(width: 1280, height: 720, device_scale_factor: 2)
           page.set_content html, timeout: 5000
           page.screenshot
         end
         send_data await(image), type: 'image/png', disposition: 'inline'
       end
     end
   end
end

这里利用了 Rails 的 render_to_string 方法,先渲染模版到字符串,再把字符串内容设置为 chromimum 的页面内容,然后截图,截图的数据通过 send_data 接口作为内容返回。

这种实现的好处是方便开发调试,可以立即查看图片效果。坏处是在 Controller 内执行耗时操作,容易阻塞 Web 服务。

于是就有了迭代二的方案。

迭代二:后台生成图片

新增一个后台任务:

class PostGenerateSocialImageJob < ApplicationJob
  queue_as :low

  def perform(post)
    # 设置 renderer 的 context
    renderer = PostsController.renderer.new http_host: ENV['HOST'], https: ENV['FORCE_SSL'].present?

    # 渲染模版
    html = renderer.render :social_image, assigns: { post: post }

    # 渲染图片
    Puppeteer.launch do |browser|
      image = future do
        page = browser.new_page
        page.viewport = Puppeteer::Viewport.new(width: 1280, height: 720, device_scale_factor: 2)
        page.set_content html, timeout: 5000
        page.screenshot
      end

      post.social_image.attach io: StringIO.new(await(image)), filename: "social_image.png", content_type: 'image/png'
    end
  end
end

渲染图片的逻辑跟迭代一类似,不同的是生成的图片会保存到文章的附件里。

在 Post 模型添加代码:

class Post
  has_one_attached :social_image

  after_save :generate_social_image, if: :saved_change_to_title?

  def generate_social_image
    PostGenerateSocialImageJob.perform_later(self)
  end
end

这里设置了 callback,在每次 Post 保存之后如果 title 有变动则重新生成预览图。

后台生成的好处是不会阻塞 Web 服务器,生成的时机可以根据需要调整。

设置页面 Meta

生成了预览图之后,最后一步是在页面设置相应的 meta tag:

<% content_for :head do %>
  ...
  
  <% if @post.social_image.attached? %>
    <meta property="og:image" content="<%= rails_blob_url @post.social_image %>">
    <meta name="twitter:image" content="<%= rails_blob_url @post.social_image %>">
  <% end %>

  ...
<% end %>

如果工作正常,在社交网络分享链接的时候就会看到预览图。

pika-1667121288784-1x.png

讨论

至此自动生成预览图的功能已经实现了,但还有一些问题需要思考。

首先是安全问题。图片渲染的主要工作是由 Chrome/Chromium 完成,虽然本身有 sandbox 机制,但也要预防漏洞。安全起见,渲染的内容一定要过滤用户输入的内容。

其次是镜像体积。增加了 Chrome 和 Noto CJK 的依赖后,镜像体积增加了 600MB,非常臃肿。

考虑到这些问题,也许以后会把图片渲染抽出一个单独的服务运行,跟 Web 服务分离。目前还在观察。

以上就是用 Puppeteer 生成网页预览图的方法。如果你有其他想法,欢迎在评论区交流。

4
2
1