原文地址:https://geeknote.net/Rei/posts/1618
现在社交网络一般都支持网站设置预览图,有预览图的网页能更占据更大的展示空间,提高点击率。
GeekNote 此前已支持作者自己设置文章封面,并且默认将封面设为预览图。但不是所有作者都有空设置封面,影响传播效果。

于是我就想给网站加上自动生成预览图的功能,这个功能要怎么实现呢?
基本的思路如下:
关键的一步在于怎么将 HTML/CSS 转换成图片。经过一些调研,我觉得 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 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 %>
如果工作正常,在社交网络分享链接的时候就会看到预览图。

至此自动生成预览图的功能已经实现了,但还有一些问题需要思考。
首先是安全问题。图片渲染的主要工作是由 Chrome/Chromium 完成,虽然本身有 sandbox 机制,但也要预防漏洞。安全起见,渲染的内容一定要过滤用户输入的内容。
其次是镜像体积。增加了 Chrome 和 Noto CJK 的依赖后,镜像体积增加了 600MB,非常臃肿。
考虑到这些问题,也许以后会把图片渲染抽出一个单独的服务运行,跟 Web 服务分离。目前还在观察。
以上就是用 Puppeteer 生成网页预览图的方法。如果你有其他想法,欢迎在评论区交流。