[实现 N 个 API/网页爬虫] Node 的 HTTP 处理 - 请求与响应

  1. 本节目标:[各种手段请求爬取网页内容] 你请求,我应答,网络两头乐开花,Node 之所以成为服务器方案,离不开 HTTP 模块的能力之帆。

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图1

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图2

在 Node 里面,起一个 HTTP Server 非常简单,如官网示例:

  1. // 加载 http 模块
  2. const http = require('http')
  3. // 定义服务运行的主机名和端口号
  4. const hostname = '127.0.0.1'
  5. const port = 3000
  6. // 通过 http.createServer 方法创建一个服务实例
  7. // 同时传入回调函数,以接管后面进来的请求
  8. // 后面请求进来时,这个回调函数会被调用执行,同时会拿到两个参数,分别是 req 和 res
  9. // req 是可读流(通过 data 事件接收数据),res 是可写流(通过 write 写数据,end 结束输出)
  10. const server = http.createServer((req, res) => {
  11. // 设置返回的状态码 200 表示成功
  12. res.statusCode = 200
  13. // 设置返回的请求头类型 text/plain 表示普通文本
  14. res.setHeader('Content-Type', 'text/plain')
  15. // 对响应写入内容后,关闭可写流
  16. res.end('Hello World\n')
  17. })
  18. // 调用实例的 listen 方法把服务正式启动
  19. server.listen(port, hostname, () => {
  20. console.log(`Server running at http://${hostname}:${port}/`)
  21. })

HTTP 作为整个互联网数据通信中几乎最主流的协议,它本身就是巨大的知识库,无论是工作 1 年还是 10 年的工程师,每一次重温 HTTP 的整体知识相信都会有很多收获,从 HTTP/1.1 到 HTTP/2,从 HTTP 到 HTTPS,从 TCP 的握手到 cookie/session 的状态保持…,我们在接触和 HTTP 的时候,一开始很容易被吓唬到,扎进去学习的时候也确实枯燥乏味,比较好的办法,就是在工作中不断的使用它,不断的练习,随着使用中的一点点深入,我们会对 HTTP 越来越熟悉。

那么这一节,我们就挑 HTTP 模块在 Node 中的几个应用知识来学习,以代码练习为主,主要学习 HTTP 模块在 Node 中的使用。

简单的 HTTP 头常识

一个请求,通常会建立在两个角色之间,一个是客户端,一个是服务端,而且两者的身份可以互换的,比如一台服务器 A 向 服务器 B 发请求,那么 A 就是客户端,B 是服务端,反过来身份就变了,甚至如果 A 这台服务器自己向自己发一个请求,那么 A 里面发请求的程序就是客户端,响应请求的程序就是服务端了,所以大家可以打开思路,不用局限在端的形态上面。

我们简单的看下一个请求从浏览器发出,以及服务器返回,它们的头信息,我们去实现爬虫的时候,有时候需要构造假的请求头,或者解析响应头,这在特定场景下会有一定的参考作用,比如打开掘金的首页,我们针对这个网页 HTML 的 GET 请求,简单学习它里面的头信息知识:

  1. // 请求由 A 请求行、B 请求头组成
  2. // A 请求行由 3 端组成,HTTP Verb/URL Path/HTTP Version
  3. // 1. 标明请求方法是 GET,往往用作获取资源(图片、视频、文档等等)
  4. // 2. /timeline 是请求的资源路径,由服务器来决定如何响应该地址
  5. // 3. HTTP 协议版本是 1.1
  6. GET /timeline HTTP/1.1
  7. // B 如下都是请求头
  8. // 去往哪个域名(服务器)去获取资源
  9. Host: juejin.im
  10. // 保持连接,避免连接重新建立,减少通信开销提高效率
  11. Connection: keep-alive
  12. // HTTP 1.0 时代产物,no-cache 禁用缓存
  13. Pragma: no-cache
  14. // HTTP 1.1 时代产物,与 Pragma 一样控制缓存行为
  15. Cache-Control: no-cache
  16. // 浏览器自动升级请求,告诉服务器后续会使用 HTTPS 协议请求
  17. Upgrade-Insecure-Requests: 1
  18. // 上报用户代理的版本信息
  19. User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36
  20. // 声明接收哪种格式的数据内容
  21. Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
  22. // 声明所接受编码压缩的格式
  23. Accept-Encoding: gzip, deflate, br
  24. // 声明所接受的地区语言
  25. Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7
  26. // 捎带过去用户之前访问时候存储在客户端的 Cookie 信息,方便服务器识别记录
  27. Cookie: gr_user_id=1fa5e8a7b7c90; _ga=GA1.2.787252639.1488193773; ab={}; _gat=1; gr_session_id_89669d96c88aefbc_a009fcc=true
  28. ...

当然还有 POST,HEAD, PUT, DELETE 等这些请求方法,他们甚至可以多传一些数据包,比如 POST 会多一个请求体,我们继续看下上面的这个请求头给到服务端,服务器返回的头是如何的:

  1. // 整体上, response header 跟 request header 格式都是类似的
  2. // 响应由 3 部分组成,A 响应行; B 响应头;C 响应体
  3. // A 响应行依然是 HTTP 协议与响应状态码,200 是响应成功
  4. HTTP/1.1 200 OK
  5. // 响应的服务器类型
  6. Server: nginx
  7. // 当前响应的时间
  8. Date: Wed, 21 Nov 2018 05:45:21 GMT
  9. // 返回的数据类型,字符编码集
  10. Content-Type: text/html; charset=utf-8
  11. // 数据传输模式
  12. Transfer-Encoding: chunked
  13. // 保持连接
  14. Connection: keep-alive
  15. // HTTP 协议的内容协商,比如如何响应缓存
  16. Vary: Accept-Encoding
  17. // 内容安全策略,检测和削弱恶意共计,学问很深
  18. Content-Security-Policy: default-src * blob: data:;script-src 'self' 'unsafe-eval' *.xitu.io *.juejin.im *.baidu.com *.google-analytics.com *.meiqia.com dn-growing.qbox.me *.growingio.com *.guard.qcloud.com *.gtimg.com;style-src 'self' 'unsafe-inline' *.xitu.io *.juejin.im *.growingio.com *.guard.qcloud.com *.gtimg.com;
  19. // 设置各种 Cookie 以及何时失效,这些 cookie 会存储在发出请求的客户端本地
  20. Set-Cookie: ab={}; path=/; expires=Thu, 21 Nov 2019 05:45:21 GMT; secure; httponly
  21. Set-Cookie: auth=eyJ0b2tlbiI6...; path=/; expires=Wed, 28 Nov 2018 05:45:21 GMT; secure; httponly
  22. Set-Cookie: QINGCLOUDELB=165e4274d60907...; path=/; HttpOnly
  23. // 控制该网页在浏览器的 frame 中展示,如果是 DENY 则同域名页面中也不允许嵌套
  24. X-Frame-Options: SAMEORIGIN
  25. // 控制预检请求的结果缓存时长
  26. Access-Control-Max-Age: 86400
  27. // 在规定时间内,网站请求都会重定向走 HTTPS 协议,也属于安全策略
  28. Strict-Transport-Security: max-age=31536000
  29. // 编码压缩格式的约定,gzip 是一种比较节省资源的压缩格式
  30. Content-Encoding: gzip
  31. // 告诉所有的缓存机制是否可以缓存及哪种类型
  32. Cache-control: private
  33. // 服务器输出的标识,不同服务器不同,可以从服务器上关闭不输出
  34. X-Powered-By-Defense: from pon-wyxm-tel-qs-qssec-kd55

关于头还有很多的知识大家可以自行学习,具体场景中我们甚至会定义自己特定业务域的请求头和响应头,我们继续回到 Node 的 HTTP 模块中。

向别的服务器请求数据 - http.get

我们在 Node 里面,向另外一台服务器发请求,这个请求可能是域名/IP,请求的内容也可能五花八门,那这个请求该怎么构造呢?

这时候可以使用简单 http.get/https.get 方法,比如我们去请求一个 Node LTS JSON 文件,从浏览器里直接打开就可以自动下载,在 Node 里面就可以这样做:

  1. // 加载 https 模块,https 的底层依然是 http
  2. const https = require('https')
  3. // 请求的目标资源
  4. const url = 'https://nodejs.org/dist/index.json'
  5. // 发起 GET 请求,回调函数中会拿到一个来自服务器的响应可读流 res
  6. https.get(url, (res) => {
  7. // 声明一个 字符串
  8. let data = ''
  9. // 每次可读流数据搬运过来,都是一个 buffer 数据块,每次通过 data 事件触发
  10. res.on('data', (chunk) => {
  11. // 把所有的 buffer 都拼一起
  12. data += chunk
  13. })
  14. res.on('end', () => {
  15. // 等待可读流接收完毕,就拿到了完整的 buffer
  16. // 通过 toString 把 buffer 转成字符串打印出来
  17. console.log(data.toString())
  18. })
  19. }).on('error', (e) => {
  20. console.log(e)
  21. })

会拿到这样的一坨 JSON 字符串:

  1. [{"version":"v11.2.0","date":"2018-11-15","files":..},...]

通过 Promise 包装一个 http.get 请求

上面的这个请求,比较简单也比较硬,是通过回调和事件的形式来完成数据的接收,一旦有多个存在依赖关系的异步请求,就会写着难受一点,如果想让这个代码更友好一些,我们可以这样做:

  1. const https = require('https')
  2. const url = 'https://nodejs.org/dist/index.json'
  3. // 声明一个普通函数,它接收 url 参数,执行后返回一个 Promise 实例
  4. const request = (url) => {
  5. return new Promise((resolve, reject) => {
  6. https.get(url, (res) => {
  7. let data = ''
  8. res.on('data', (chunk) => {
  9. data += chunk
  10. })
  11. res.on('end', () => {
  12. // 通过 Promise 的 resolve 来回调结果
  13. resolve(data.toString())
  14. })
  15. }).on('error', (e) => {
  16. reject(e)
  17. })
  18. })
  19. }
  20. // 在执行时候,可以使用 .then() 方法来链式调用,避免回调嵌套
  21. request(url)
  22. .then(data => {
  23. console.log(data)
  24. })

通过 async function 来替代请求的链式传递

上面有了 Promise 的封装后,我们可以直接用 async function 继续完善下这个 Promise,进一步避免 then 的回调包裹,比如改成这样子:

  1. const https = require('https')
  2. const url = 'https://nodejs.org/dist/index.json'
  3. // 改成一个 async 异步函数
  4. const request = async (url) => {
  5. return new Promise((resolve, reject) => {
  6. https.get(url, (res) => {
  7. let data = ''
  8. res.on('data', (chunk) => {
  9. data += chunk
  10. })
  11. res.on('end', () => {
  12. resolve(data.toString())
  13. })
  14. }).on('error', (e) => {
  15. reject(e)
  16. })
  17. })
  18. }
  19. // 声明一个异步函数,await 和 async 要配对使用
  20. async function run () {
  21. // 以同步的方式来写异步逻辑
  22. const data = await request(url)
  23. console.log(data)
  24. }
  25. // run 方法执行后本身也是一个 Promise,可以通过 then 链式调用
  26. run()

写法稍微改了一下,整个调用链就清晰了很多,而且 async function 的执行性能也要比 Promise 好的。

通过三方库 axios/request 来替代 http.get

以上都是用原生的 API 来直接实现,优点是不依赖三方库,拿到的响应就是天然的 Stream 流,那么一点点不方便的地方是,它回调中的响应 res 是 http.ClientRequest 流对象,需要自己监听 data 事件来手动组装 bufer 块,甚至还需要自己解析 JSON 数据格式,处理解析异常等,另外还不能原生支持 Promise,需要自己包装。在实际的工作场景中,我们为了开发效率,会考虑不深入这么底层的细节,直接采用三方库,比如 request/axios 等,我们通过 request 来实现一下:

  1. // 首先 npm i request
  2. const request = require('request')
  3. const url = 'https://nodejs.org/dist/index.json'
  4. // 直接通过 request 提供的 get 方法发起请求,从回调函数中拿到结果
  5. request.get(url, (error, response, body) => {
  6. const json = JSON.parse(body)
  7. console.log(body)
  8. })

request 库可以让整个请求变得更简单,只不过它只支持回调形式,不支持 Promise,会有一点不方便,当然也可以用基于它封装的其他三方库,比如 request-promise 来实现 Promise,或者 request-promise-native 这样的原生 Promise,我们同样可以选择另外一个也有流行的库 - axios,这个库的优点是浏览器和 Node 服务端都可以通用,另外也不需要关心 JSON 化的处理,它向下依赖的包也很少,同时支持 Promise:

  1. const axios = require('axios')
  2. const url = 'https://nodejs.org/dist/index.json'
  3. const run = async url => {
  4. try {
  5. const res = await axios.get(url)
  6. console.log(res.data)
  7. } catch (error) {}
  8. }
  9. run(url)

拿到的结果是一个 JSON 化后的数据格式,更加易用友好,简单了解下 API 和三方库,我们来做一些稍微复杂点的练习,比如爬取网页源码。

结合 http.get 和 cheerio 来爬取分析网页源码

请求一个网页就是获取一个远程 HTML 文件内容,跟上面我们获取 JSON 没有本质区别,拿到网页源码后,可以通过 cheerio 来加载源码遍历 DOM 节点,选择目标的 HTML 元素,从而获得期望的内容,比如文本或者链接,我们拿 Node 的 Code + Learn 页面为例,分析页面的 DOM 节点,左侧的菜单都是 aside 元素里的 li,每个 li 是一个 a 标签,我们只需要获取 a 标签就行了,这个通过 cheerio 可以很轻松的做到:

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图3

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图4

我们拿到源码后,可以从 DOM 中拿到左侧的菜单内容,那么代码可以这样写:

  1. const https = require('https')
  2. const $ = require('cheerio')
  3. const url = 'https://nodejs.org/en/get-involved/code-and-learn/'
  4. // 这里可以借助 request 三方库实现
  5. const request = async (url) => {
  6. return new Promise((resolve, reject) => {
  7. https.get(url, (res) => {
  8. let html = ''
  9. res.on('data', (chunk) => {
  10. html += chunk
  11. })
  12. res.on('end', () => {
  13. resolve(html.toString())
  14. })
  15. }).on('error', (e) => {
  16. reject(e)
  17. })
  18. })
  19. }
  20. async function run () {
  21. // 拿到网页源码内容
  22. const html = await request(url)
  23. // 遍历查找出目标元素节点
  24. const items = $('aside a', html)
  25. const menus = []
  26. // 遍历节点对象,调用 text 方法取出文本内容
  27. items.each(function() {
  28. menus.push($(this).text())
  29. })
  30. console.log(menus)
  31. }
  32. run()

打印结果应该就是一个数组了:

  1. [ 'Get Involved',
  2. 'Code + Learn',
  3. 'Collab Summit',
  4. 'Contribute',
  5. 'Code of Conduct' ]

编程练习 1 - 实现 Node API 对比工具

有了上面的准备工作,我们可以挑战下难一点的任务,Node 版本之间有差异这个我们是知道的,即便是对于同一个模块的同一个 API,也会随着版本而发生变化,甚至有的 API 是逐渐废弃掉不再使用,我们实现一个爬虫,来指定某些 Node 版本,爬取它们里面会逐步废弃的 API 都有哪些,先来看下效果:

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图5

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图6

代码我们可以这样实现:

  1. const https = require('https')
  2. const Table = require('cli-table')
  3. const cheerio = require('cheerio')
  4. const link = (v, p) => `https://nodejs.org/dist/${v}/docs/api/${p}`
  5. async function crawlPage (version, path = '') {
  6. const url = link(version, path)
  7. return new Promise(function(resolve, reject) {
  8. https.get(url, (res) => {
  9. let buffer = ''
  10. res.on('data', (chunk) => {
  11. buffer += chunk
  12. })
  13. res.on('end', () => {
  14. resolve(buffer.toString())
  15. })
  16. }).on('error', function(e) {
  17. reject(e)
  18. })
  19. })
  20. }
  21. function findApiList (html) {
  22. const $ = cheerio.load(html)
  23. const items = $('#column2 ul').eq(1).find('a')
  24. const list = []
  25. items.each(function(item) {
  26. list.push({
  27. api: $(this).text(),
  28. path: $(this).attr('href')
  29. })
  30. })
  31. return list
  32. }
  33. function findDeprecatedList (html) {
  34. const $ = cheerio.load(html)
  35. const items = $('.stability_0')
  36. const list = []
  37. items.each(function(item) {
  38. list.push($(this).text().slice(0, 30))
  39. })
  40. return list
  41. }
  42. async function crawlNode (version) {
  43. const homePage = await crawlPage(version)
  44. const apiList = findApiList(homePage)
  45. let deprecatedMap = {
  46. // 'Command Line Options': ['']
  47. }
  48. const promises = apiList.map(async item => {
  49. const apiPage = await crawlPage(version, item.path)
  50. const list = findDeprecatedList(apiPage)
  51. return { api: item.api, list: list }
  52. })
  53. const deprecatedList = await Promise.all(promises)
  54. deprecatedList.forEach(item => {
  55. deprecatedMap[item.api] = item.list
  56. })
  57. return deprecatedMap
  58. }
  59. async function runTask (v1, v2, v3, v4) {
  60. const results = await Promise.all([
  61. crawlNode(v1),
  62. crawlNode(v2),
  63. crawlNode(v3),
  64. crawlNode(v4)
  65. ])
  66. const table = new Table({
  67. head: ['API Version', v1, v2, v3, v4]
  68. })
  69. const v1Map = results[0]
  70. const v2Map = results[1]
  71. const v3Map = results[2]
  72. const v4Map = results[3]
  73. const keys = Object.keys(v4Map)
  74. keys.forEach(key => {
  75. if ((v1Map[key] && v1Map[key].length)
  76. || (v2Map[key] && v2Map[key].length)
  77. || (v3Map[key] && v3Map[key].length)
  78. || (v4Map[key] && v4Map[key].length)) {
  79. table.push([
  80. key,
  81. (v1Map[key] || []).join('\n'),
  82. (v2Map[key] || []).join('\n'),
  83. (v3Map[key] || []).join('\n'),
  84. (v4Map[key] || []).join('\n')
  85. ])
  86. }
  87. })
  88. console.log(table.toString())
  89. }
  90. runTask('v4.9.1', 'v6.14.4', 'v8.11.4', 'v10.13.0')

给大家布置个作业,可以把代码再升级一下,看如何一次性拿到所有的 Node LTS 版本,同时把它们的 API 废弃清单都打印出来。

借助 puppeteer 来爬取有状态或者异步数据页面

有了上面的训练,我们知道了一些信心,只要我想要爬取的内容,都可以写一个工具快速拿过来分析,然而有时候会事与愿违,有的目标网站会有异步的内容,甚至会有反爬策略,我们甚至拿不到正确的 HTML 源码,甚至更高级的策略中,我们即便拿到正确的 HTML,却未必能正确解析出来,比如用雪碧图错位来表示数字等等,这个要具体问题具体分析,我们打开掘金的小册页面,试下爬取这个页面内容,然后统计下一共现在上架了多少本册子,线上销售额有多少,掘金小册开创了知识分享的一种新形势,在技术圈非常流行,通过爬取这些数据,我们应该能更感同身受,大家也可以多帮掘金来宣传小册子,帮助掘金团队和平台越做越好。

那我们可以写这样一段代码:

  1. // juejin-book.js
  2. // 在 node juejin-book.js 执行代码之前
  3. // 先 npm i cheerio request-promise 安装依赖模块
  4. const $ = require('cheerio')
  5. const rp = require('request-promise')
  6. const url = 'https://juejin.im/books'
  7. // 通过 request-promise 来爬取网页
  8. rp(url)
  9. .then(function(html) {
  10. // 利用 cheerio 来分析网页内容,拿到所有小册子的描述
  11. const books = $('.info', html)
  12. let totalSold = 0
  13. let totalSale = 0
  14. let totalBooks = books.length
  15. // 遍历册子节点,分别统计它的购买人数,和销售额总和
  16. books.each(function() {
  17. const book = $(this )
  18. const price = $(book.find('.price-text')).text().replace('¥', '')
  19. const count = book.find('.message').last().find('span').text().split('人')[0]
  20. totalSale += Number(price) * Number(count)
  21. totalSold += Number(count)
  22. })
  23. // 最后打印出来
  24. console.log(
  25. `共 ${totalBooks} 本小册子`,
  26. `共 ${totalSold} 人次购买`,
  27. `约 ${Math.round(totalSale / 10000)} 万`
  28. )
  29. })

最后打印的结果是:共 0 本小册子 共 0 人次购买 约 0 万,What? 小册子怎么可能数据都是 0 呢,一定是我爬的姿势不对,我们是可以通过分析请求头和响应头来模拟一次真实的网页访问,我们也可以通过网页爬取神器 - puppeteer 来获取网页内容,puppeteer 是谷歌开源的,可以通过命令行来启动一个 chrome 实例,从而真实访问网页,并且具备与网页的交互的能力,包括不限于点击,滚动,截屏等操作。

我们来写一个小例子,来截取下掘金的首页头部,在执行之前,首先安装 puppeteer:

  1. npm i puppeteer -S
  2. > puppeteer@1.10.0 install /Users/black/Downloads/node_modules/puppeteer
  3. > node install.js
  4. Downloading Chromium r599821 - 82.9 Mb [ ] 1% 1287.3s
  5. # 安装可能会比较耗时,大家可以多尝试几次
  1. // 把之前安装到 node_modules 下的 puppeteer 模块加载进来
  2. const puppeteer = require('puppeteer')
  3. // 在 getHomePage 函数里面,定制一系列任务,让他们顺序执行
  4. async function getHomePage (link) {
  5. // 启动一个 Chrome 引擎实例,加上 await 会一直等待它启动完成
  6. // 加上 headless: false 会打开一个浏览器,可以眼睁睁看这一切发生,如果是 true 则静默执行
  7. // const browser = await puppeteer.launch({headless: false})
  8. const browser = await puppeteer.launch()
  9. // 启动成功后,打开一个新页面
  10. const page = await browser.newPage()
  11. // 新页面里面输入目标网址,跳到这个网页,一直等待页面加载完成
  12. await page.goto(link)
  13. // 设置网页视窗的宽高
  14. await page.setViewport({width: 1080, height: 250})
  15. // 告诉 puppeteer 开始截图,直到截图完成,存储图片到当前目录
  16. await page.screenshot({path: Date.now() + '.png'})
  17. // 最后关闭浏览器,销毁所有变量
  18. await browser.close()
  19. return 'done!'
  20. }
  21. // 调用这个异步函数 getHomePage,传入待截图网站,任务开始执行
  22. getHomePage('https://juejin.im/books').then(v => {})

会得到这样的一个截图:

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图7

10案例九: [实现 N 个 API[网页爬虫] Node 的 HTTP 处理 - 请求与响应 - 图8

编程练习 2 - 实现掘金小册的统计工具

了解 puppeteer 后,我们就可以借助它来获取小册子页面内容了,代码可以这样写:

  1. const $ = require('cheerio')
  2. const puppeteer = require('puppeteer')
  3. const url = 'https://juejin.im/books'
  4. async function run () {
  5. const browser = await puppeteer.launch()
  6. const page = await browser.newPage()
  7. await page.goto(url, {waitUntil: 'networkidle2'})
  8. const html = await page.content()
  9. const books = $('.info', html)
  10. let totalSold = 0
  11. let totalSale = 0
  12. let totalBooks = books.length
  13. books.each(function() {
  14. const book = $(this)
  15. const price = $(book.find('.price-text')).text().replace('¥', '')
  16. const count = book.find('.message').last().find('span').text().split('人')[0]
  17. totalSale += Number(price) * Number(count)
  18. totalSold += Number(count)
  19. })
  20. console.log(
  21. `共 ${totalBooks} 本小册子`,
  22. `共 ${totalSold} 人次购买`,
  23. `约 ${Math.round(totalSale / 10000)} 万`
  24. )
  25. await browser.close()
  26. }
  27. run()

打印的结果是:共 20 本小册子 共 60800 人次购买 约 101 万,哇!截止 11 月下旬,小册的总销量已经破 100 万了,恭喜掘金,恭喜各位开发者,同时我们也期待掘金继续加油,销售额早日破千万,早日把知识传递给更多更多的开发者。