记录保存网页的脚本优化过程
近期为了看文章开了网站会员,想把内容保存到本地,以便后续回顾,为此写了个脚本,记录一下思考的过程
方案一
长截图,然而一方面,长截图效果不太好,保存下来是图片,文字也不能选中,而且操作也麻烦。
方案二
chrome dev tool 提供了 capture snapshot 的功能,可以全屏长截图,或者是对节点进行截图。功能虽好,但依然存在手动的麻烦,并且存下来还是图片
方案三
使用 html2pdf,将网页存成 pdf。html2pdf 实际上依赖了 html2canvas 与 jsPDF
存在几个问题:
- 原网页做了特殊处理,导致绘制 canvas 时不能正确还原展示样式
- 保存成 pdf 时 page-breaks 配置无效,导致文本从中间被断开
- pdf 实际上也是插入 cavans,所以实际体验和图片差不多
- 文章中的图片绘制到 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 即可。
实现
思路
- 网站有比较多影响阅读的元素,如目录,用户信息等,需要将这些元素移除,仅保留内容和样式。
- 后台起一个 nodejs 搭建的本地服务,当处理完网页后,将整个 html 内容传输给服务器,服务器写入一个本地目录(这里我用 dist)保存起来
- 将所有需要保存的文章都存到本地后,运行生成 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)