用 Puppeteer 生成网页预览图
问题
现在社交网络一般都支持网站设置预览图,有预览图的网页能更占据更大的展示空间,提高点击率。
GeekNote 此前已支持作者自己设置文章封面,并且默认将封面设为预览图。但不是所有作者都有空设置封面,影响传播效果。
于是我就想给网站加上自动生成预览图的功能,这个功能要怎么实现呢?
解决方案
基本的思路如下:
- 用 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 %>
如果工作正常,在社交网络分享链接的时候就会看到预览图。
讨论
至此自动生成预览图的功能已经实现了,但还有一些问题需要思考。
首先是安全问题。图片渲染的主要工作是由 Chrome/Chromium 完成,虽然本身有 sandbox 机制,但也要预防漏洞。安全起见,渲染的内容一定要过滤用户输入的内容。
其次是镜像体积。增加了 Chrome 和 Noto CJK 的依赖后,镜像体积增加了 600MB,非常臃肿。
考虑到这些问题,也许以后会把图片渲染抽出一个单独的服务运行,跟 Web 服务分离。目前还在观察。
以上就是用 Puppeteer 生成网页预览图的方法。如果你有其他想法,欢迎在评论区交流。