记录保存网页的脚本优化过程

近期为了看文章开了网站会员,想把内容保存到本地,以便后续回顾,为此写了个脚本,记录一下思考的过程

方案一

长截图,然而一方面,长截图效果不太好,保存下来是图片,文字也不能选中,而且操作也麻烦。

方案二

chrome dev tool 提供了 capture snapshot 的功能,可以全屏长截图,或者是对节点进行截图。功能虽好,但依然存在手动的麻烦,并且存下来还是图片

方案三

使用 html2pdf,将网页存成 pdf。html2pdf 实际上依赖了 html2canvasjsPDF

存在几个问题:

  1. 原网页做了特殊处理,导致绘制 canvas 时不能正确还原展示样式
  2. 保存成 pdf 时 page-breaks 配置无效,导致文本从中间被断开
  3. pdf 实际上也是插入 cavans,所以实际体验和图片差不多
  4. 文章中的图片绘制到 canvas 时,会跨域导致无法绘制,需要将 img 标签里的 src 转成 object url 或者 base64

代码如下

var imgs = document.querySelectorAll('img')

for (let i = 0; i < imgs.length; i++) {
  var img = imgs[i]
  await fetch(new Request(img.src))
    .then((response) => response.blob())
    .then(function (myBlob) {
      var objectURL = URL.createObjectURL(myBlob)
      // 将 img 转为 objectURL, 防止跨域
      img.src = objectURL
    })
}

function addScript(url) {
  var script = document.createElement('script')
  script.type = 'application/javascript'
  script.src = url
  document.head.appendChild(script)
}

addScript(
  'https://raw.githack.com/eKoopmans/html2pdf/master/dist/html2pdf.bundle.js'
)

var pdfTitle = document.querySelector('.title').innerText

html2pdf()
  .set({
    filename: `${pdfTitle}.pdf`,
  })
  .from(document.body)
  .save()

方案四

利用无头浏览器例如 puppeteer (以下简称 pptr)来生成 pdf。这个方案可以完美避开方案三的问题,生成的 pdf 质量也是最好的。但是需要解决一个问题:登录问题。理想的方案是使用 pptr ,启动时即可实现登录,跳转到对应的文章,保存成 pdf。鉴于实现脚本登录的成本较大,将需求拆分,即登录是在 chrome 里手动完成的,之后将整个 html 保存为本地 html,pptr 读取本地 html 并转成 pdf 即可。

实现

思路

  1. 网站有比较多影响阅读的元素,如目录,用户信息等,需要将这些元素移除,仅保留内容和样式。
  2. 后台起一个 nodejs 搭建的本地服务,当处理完网页后,将整个 html 内容传输给服务器,服务器写入一个本地目录(这里我用 dist)保存起来
  3. 将所有需要保存的文章都存到本地后,运行生成 pdf 脚本,这个脚本读取 dist 目录所有文件,启动 pptr,依次将内容作为网页打开,保存成 pdf

上述就是第一个版本的做法

本地服务 server 代码

const Koa = require('koa')
const cors = require('@koa/cors')
const bodyParser = require('koa-bodyparser')
const fs = require('fs-extra')
const path = require('path')

const app = new Koa()
// 允许跨域
app.use(
  cors({
    allowHeaders: '*',
  })
)
app.use(bodyParser())

app.use(async (ctx) => {
  // 接收 { html, title } 参数
  const { title, html } = ctx.request.body
  const dir = path.join(__dirname, '../dist/')
  try {
    await fs.ensureDir(dir)
    // 注意 title 如果带有 / 会导致创建文件失败,将 / 替换成 |
    // 将 title 作为文件名
    await fs.writeFile(path.join(dir, `${title.replace('/', '|')}.html`), html)
    ctx.body = { code: 200 }
  } catch (error) {
    console.log(error)
  }
})

// 服务端口 3000
app.listen(3000)

网页登录后,在浏览器控制台输入如下 web,js 代码

// 主要内容部分
var mainEle = document.querySelector('.main')
// 文章标题
var title = document.querySelector('.title').innerText
document
  .querySelectorAll('script')
  .forEach((s) => s.remove())

var body = document.querySelector('body')
// 移除其他内容,仅保留主要内容
body.innerHTML = ''
body.appendChild(mainEle)

// 通过 fetch 将 html 及 title 传给服务端
await fetch('http://localhost:3000/', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title,
    html: document.documentElement.outerHTML,
  }),
})

pptr 脚本代码如下

const puppeteer = require('puppeteer')
const path = require('path')
const fs = require('fs-extra')

;(async () => {
  const dist = path.resolve(__dirname, '../dist')
  const htmls = await fs.readdir(dist)
  // 启动无头浏览器
  const browser = await puppeteer.launch()
  // 打开新页面
  const page = await browser.newPage()
  for (let i = 0; i < htmls.length; i++) {
    // 依次读取 html 文件
    const html = htmls[i]
    var contentHtml = await fs.readFile(path.join(dist, html), 'utf8')
    // 由于页面会有图片,{ 'waitUntil': 'networkidle0' } 即等待网络请求完成后
    await page.setContent(contentHtml, { 'waitUntil': 'networkidle0' })
    // 生成 pdf 文件,注意这个 api 需要在 headless 模式下才能调用
    await page.pdf({ path: path.join(dist, html.replace('.html', '.pdf')) })
  }
 	// 关掉浏览器
  await browser.close()
})()

优化1

由于 web,js 中的代码会修改网页,所以每次保存完成之后,需要刷新页面,点击下一页。这需要页面重新加载脚本,样式文件等。因此我们需要一个方法不去修改原网页,所幸,这里我们可以用 cloneNode 来复制整个网页的 dom 结构,这样我们就不用修改原来的 dom 了。

web-v2.js 代码如下

var doc = document.documentElement.cloneNode(true)
// ...

这里将 document 深拷贝,之后所有的操作都基于这个深拷贝,就不会影响到原页面

优化2

上述还有个问题就是需要手动点下一页,然后等待页面加载完成后,重新执行 web-v2.js 。这里也可以完全使用脚本来实现

Web-v3.js

async function fetchWeb() {
  // 深拷贝
  var doc = document.documentElement.cloneNode(true)
  var body = doc.querySelector('body')

  var mainEle = body.querySelector('.main')
  // 移除换行符号等特殊字符
  var title = body.querySelector('.title').innerText.replace(/\s/g, '')

  doc.querySelectorAll('script').forEach((s) => s.remove())
  body.innerHTML = ''
  body.appendChild(mainEle)

  await fetch('http://localhost:3000/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title,
      html: doc.outerHTML,
    }),
  })
}

// 延时函数
async function sleep(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve()
    }, time)
  })
}

// 跳转下一页
function next() {
  const btn = document.querySelector('.next')
  btn.click()
}

// total 为需要保存的页数
async function fetchAll(total) {
  for (let i = 0; i < total; i++) {
    console.log('开始')
    await fetchWeb()
    // 等待 1 s
    await sleep(1000)
    // 点击下一页
    next()
    // 等待 3s 页面基本都加载好了,这里其实可以做的更准确点,比如根据某个元素加载好后开始 fetchWeb
    await sleep(3000)
  }
}

至此,只需要在浏览器控制台上输入 web-v3 脚本一次,然后运行

// total 为需要保存的页面数量即可
fetchAll(total)