如何生成一本书(web2pdf)

看书有个习惯,非技术书在 Kindle 上看,技术书在电脑或 iPad 上以 pdf 格式看。但经常看到一些写得好的博客,这对我来说很不友好。自制力太弱了,让我打开一个网页,五分钟以后就能看到已经打开二十个了…对于 pdf 来说,结合 MarginNotes 学习是非常不错的选择,那么就动手把 web 转成 pdf 吧。

环境信息
macOS Catalina
Chrome 78.0.3904.108
puppeteer 3.1.0
node 12.16.1


常见的有以下几种场景,按自定义程度不同排序:

  1. 页面较少,并且整个网页直接下载(网页无登录)
  2. 需要剔除一些网页元素(比如广告,不需要的控件等)
  3. 页面多,需要自动爬取目录自动获取链接
  4. 有登录操作
  5. 完全自定义页面样式(比如作者样式花哨,想用 Gitbook 样式等)

除此之外,还有两个可选操作:

  1. 自动生成目录
  2. 合并多个页面到一个 pdf

已开源,欢迎贡献(web2pdf)。

直接下载

最近在学习 macOS 自动化,刚好看到一个不错的网站(The macOS Automation Sites),其中 AppleScript 的入门文章挺有意思。所以后面大多数案例会以这个网站为例。

除此之外,再忍不住安利一下(不能白嫖)之前下载的灯塔大佬的 Go 语言设计与实现,真是吾辈楷模。

整个页面内容如下图,只有绿框的正文内容是必要的,目录、相关阅读、导航栏等信息都可以删除(目录可以用来获取余下文章的链接)。

先从最基础的直接下载开始。

打印

最简单的方式,直接用 Chrome CMD+P 打印页面,Destination 为 Save as PDF 即可。

Headless Chrome

页面多的情况下,不可能一个一个点,最简单的自动化方式是挑选一门熟悉的脚本语言,然后执行下载命令。那么,问题是 Save as PDF 对应的命令是什么?

Headless Chrome is shipping in Chrome 59. It’s a way to run the Chrome browser in a headless environment. Essentially, running Chrome without chrome! It brings all modern web platform features provided by Chromium and the Blink rendering engine to the command line.
A headless browser is a great tool for automated testing and server environments where you don’t need a visible UI shell. For example, you may want to run some tests against a real web page, create a PDF of it, or just inspect how the browser renders an URL.

通俗的说,Chrome 59 以后发布了 Headless Chrome,Headless 即没有 GUI 交互页面,这对测试、爬虫等场景非常友好,省去了诸多不必要的渲染。在此之前,大多是基于 PhantomJS 的,现在官方发布了 Headless Chrome 无疑是福音(而且 PhantomJS 是基于旧版本的 WebKit)。

官网 的第二个案例,就是 Create a PDF

对于 AppleScript 教程的下载,最简单的方式:

#!/usr/bin/env ruby
# encoding: utf-8

# 手动复制每个链接,或者自动解析出来的链接
list = [
"https://macosxautomation.com/applescript/firsttutorial/index.html",
"https://macosxautomation.com/applescript/firsttutorial/01.html",
"https://macosxautomation.com/applescript/firsttutorial/02.html"
]

save_dir = File.expand_path("~/Desktop/pdf/")
list.each_with_index do |url, index|
# Chrome 地址,也可以像文档中那样取一个别名
# alias chrome="/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
chrome = '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome'
save_path = File.join(save_dir, "#{index}.pdf")
# 执行脚本
system "#{chrome} --headless --disable-gpu --print-to-pdf=#{save_path} #{url}"
end

Tips: 如果不经常导出 pdf,直接在 Chrome Console 手动执行 JQuery 获取目录链接也是可以的(比手动一个个复制快,也比临时写脚本解析页面快):

// 注入 JQ
var script = document.createElement('script');
script.src = "https://ajax.googleapis.com/ajax/libs/jquery/1.6.3/jquery.min.js";
document.getElementsByTagName('head')[0].appendChild(script);
// 根据目录所在节点,输出所有链接
var list = $(".navbox a"); list.each(i => {
console.log(list[i].href)
})

需要修改页面元素(Puppeteer)

从之前导出的 pdf 看,有几个问题:

  1. 有很多不必要的元素(导航栏、相关阅读)
  2. 正文只占到页面的 3/4,还有 1/4 由于导航栏占用,即使隐藏也会出现留白
  3. 有页眉页脚

这些需求需要对 headless Chrome 做更定制操作。对此,Chrome 团队推出了 Puppeteer(Node 库),可以更精细操作 headless Chrome。

Puppeteer is a Node library developed by the Chrome team. It provides a high-level API to control headless (or full) Chrome.

Puppeteer 直接下载

再回到上一个例子,如果用 Puppeteer 来实现:

const puppeteer = require('puppeteer')
const gen = module.exports = {}
gen.fromURL = async (url, path) => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
await page.pdf({path, format: 'A4'});
await browser.close();
}

// 调用
const path = require('path');
const gen = require('./gen');
(async () => {
const url = 'https://macosxautomation.com/applescript/firsttutorial/index.html'
const filePath = path.join(process.env.HOME, '/Desktop/pdf/as-01.pdf')
await gen.fromURL(url, filePath)
})();

page.pdf 方法需要传入存储路径,以及一些 OptionsPDFOptions 对象),这些 Options 可以在 Chrome Print 的时候看到。比如:

  • Paper size: format: 'A4'
  • Margins: margin: {x, x, x, x}
  • Options-Background graphics: printBackground: true/false
  • Options-Headers and footers: displayHeaderFooter: true/false

除了在 Print 能看到的,还有 Print 没有的:

  • headerTemplate/footerTemplate: 页眉页脚模板
  • width/height: 宽高,和 css 互斥

修改样式并下载

按之前的想法,页面导航、目录这些都是多余的。通过 Inspect 可以找到这些元素的选择器,而且我们需要修改内容的宽度,剔除多余的留白,css 如下:

#header, #navpath, .note, #sidebar, .more, #globalnav, #globalsearch {
display: none;
}
#content {
width: auto;
}

注入 css,这需要用到 pageaddStyleTag(option: string) 方法。option 可以是 urlpathcontent,分别对应着 css 链接,本地 css 文件和 css 内容。考虑到可读性,我选择了本地文件,一个网站对应一个样式:

gen.fromCustomStyle = async (url, path, styleFilePath) => {
// 新增 addStyleTag 即可
await page.addStyleTag({path: styleFilePath})
}

// 调用
(async() => {
const style = path.join(__dirname, '../style/applescript.css')
await gen.fromCustomStyle(url, filePath, style)
})();

最后生成的效果很好,没有多余的元素:

到此,已经覆盖了绝大多数博客导出为 pdf 的场景。所以先来收个尾:如何将多个页面的 pdf 合并成一个。

页面合并

macOS 的 Finder 已经提供了 Quick Action,选中多个 pdf 右键就可以(Quick Action 好神奇,自己能写吗?当然可以,Automator 大法好)。


有登录操作

绝大多数的登录问题都可以通过传入本地 Cookie 搞定,在 puppeteer 中也有 page.setCookie 的 API:

gen.fromCustomCookie = async (url, path, cookie) => {
// 新增 setCookie 即可
await page.setCookie(cookie)
}

// 调用
(async() => {
const cookie = { name: 'xxx', value: 'xxxx', url }
await gen.fromCustomCookie(url, filePath, cookie)
})();

完全自定义页面样式

这种常见于:

  1. 原站样式太丑,想换一个新的
  2. 页面内容是异步加载的,所以 page.goto 的时候还没有内容,需要自己填充

第二种情况在付费网站中很常见(博客大多都是静态站),拿极客时间举例,文章是通过 https://xxx/serv/v1/article 接口返回的,response 是:

{
"data": {
"neighbors": {
"left": {
"article_title": "加餐 | 一个前端工程师到底需要掌握哪些技能?",
}
},
"article_content": "<p>你好,我是winter。<\/p><p>最初我答应“极客时间”的时候,其实心里想的是:反正我要做程序员教育,做一个专栏就当整理自己的知识也好。<\/p><p>你可以在各种文档和标准中找到它们或者它们的变体。有一些工程领域相关的知识,来自我工作中的实践,有一些也算是首创,但是我不认为这些知识属于我,我只是发现了它们。<\/p><!-- [[[read_end]]] --><p>所以我认为,知识是免费的,承载它们的教育产品才是收费的。<\/p>"
}
}

最为关键的 title 和 content 都在接口中返回了。距离生成 pdf 还差一个模板,决定用 ejs(顺手温习了一下 erb 和 smarty,又想起了看 Workpress 源码的那段日子)。下一步要做的是把模板扒下来并做一些精简,对于极客时间来说,需要:删除正文、删除左边栏、删除右边栏(当然一些元素也可以通过之前介绍的 css 注入来做),最后在 Chrome Elements 中把 HTML 复制到本地就可以。

<!-- 最重要的是 css 完全保留,以及 title 和 content 节点保留 -->
<html>
<head><style type="text/css">/* 一大堆 css -->*/</style></head>
<body>
<div id="app">
<!-- 各种 div -->
<h1 class="cZCVMzBP_0"><%= article_title %></h1>
<div class="_2SKlnZlt_0">
<div data-slate-editor="true" data-gramm="false" style="outline: none; white-space: pre-wrap; overflow-wrap: break-word;">
<%= article_content %>
</div>
</div>
</div>
</html>

剩下的就是样式微调,比如极客时间下发的 <p> 标签每个外面都还包了一个有样式 <div> 的处理,以及 width 的一些调整。

为什么有的页面只生成了一半?有些文章没有一次性返回,而是按需加载。这种情况下,也是需要自己填充的。

Markdown 文档导出

GitHub 上通常有很多高质量的文章,比如一些书或国外博客的翻译项目等,当然,还有各种大佬托管的 GitHub Pages。如果将源文件拉下来,一般是 Markdown 的。将 Markdown 生成 pdf,可以采用:

  • 将 md 转为 html,然后注入 css,md2html 可以用 showdown
  • 也可以利用 Gitbook,或者 GitHub Pages,生成一个网站,然后用之前的 web2pdf 直接生成 pdf

第一种自定义程度更高,但可能需要对 css 比较熟悉,或者套 hexo/jekyll 这类博客引擎直接生成静态 web,然后 web2pdf。第二种 Gitbook 这类其实类似,只是样式不用操心,本来他们的样式也很清爽。

参考文档