超越Ctrl+S保存页面所有资源

时间:2023-03-09 01:03:51
超越Ctrl+S保存页面所有资源

如何抓取页面所有内容

基本需求

抓取页面所有内容主要包括一下内容:

  1. 页面内元素

页面元素包含服务端直接返回的元素,动态构建的元素

  1. 页面内所有资源

页面所有资源包含本页面所在域资源以及第三方域资源,同主域的资源也认为第三方域资源,这种资源一般是以绝对路径的方式标识,同域下资源主要有三种表现方式 (以https://www.baidu.com举例)

a). 相对路径

<image src="./image/logo.png" />

b). 绝对路径

<image src="https://www.baidu.com/image/logo.png" />

c). 绝对路径2

<image src="//www.baidu.com/image/logo.png" />

这种表示方式会自动根据浏览器打开该页面的协议请求时加入协议(protocol),本地保存后,基于file协议打开同样会加入file:前缀。

当前实现方案

基本流程

  1. 服务端http get 页面

  2. 根据服务端响应的html,遍历需要加载的其它资源,比如javascript、image、css、font、media等资源

  3. 处理html、javascript、css 等文件,进行资源路径替换,保证页面本地化后能正常打开

不足之处

  1. http get 只能拿到原始内容,需要依赖后期再浏览器中加载之后的再渲染(比如依赖本地化的js再次请求数据进行页面构建 或者 直接生成dom进行页面构建)

  2. 请求后得到的资源文件依赖原本相对路径,如果处理有较高的技术难度,比如使用AMD、CMD等模式加载的文件。由于当前方案抓取资源时对当前资源目录层次全部铺平了(纵向目录已经不存在了,相对路径也会变化),所以需要动态修改(拿应用了AMD加载模式的页面举例)require.config.js 文件的内容,否则会导致页面js 无法正常加载,页面无法正常渲染。

  3. 对非html页面直接获取的资源,获取的难度较大,这种非html页面直接获取的资源包括,css 文件中引入的字体资源文件以及图片资源文件,js资源文件中引入的资源文件,比如上述2 中描述的AMD、CMD模式实现的按需加载。

新的实现方案

puppeteer是操作chromnium的上层node api,当浏览器打开一个页面是,可以简单理解细分为如下过程:

  1. 通知浏览器发起请求
  2. 浏览器发起请求
  3. 浏览器获取响应内容
  4. 浏览器把响应内容交给上层渲染引擎
  5. 渲染引擎处理

在整个过程中,puppeteer提供了一种机制让我们有机会拦截到2和3这两个阶段,基于这点,我们可以做更多的事情,比如我们可以拦截页面的所有请求,可以截获所有的响应,而不用关注请求的去向,因为只要请求发出去了,就能受我们的控制,另外,由于是使用浏览器本身,所以跟直接http get 页面最大的区别在于前者是渲染后的,后者是原始的,前者对SPA或者依靠脚本构建的应用比较友好。

使用puppeteer实现完全能处理原始方案的不足,新的实现思路如下:

  1. 拦截所有网络请求,对资源请求以及构建dom相关请求进行处理

  2. 对同域名下资源进行相对路径处理,在本地创建对应的相对路径

  3. 对不同域名下资源(第三方资源)以第三方域名为名建立新的目录,用来存储第三方资源

  4. 资源处理,处理html资源,css资源以及javascript文件中绝对路径为相对路径(这里绝对路径是指直接引入的cdn等模式路径,相对路径是指对cdn域名本地化目录后的路径)

核心代码说明

基于上述新的方案,实现的核心代码如下,代码中加入了详细的注释,不再做过多解释,有疑问欢迎留言讨论

const puppeteer = require('puppeteer');
const URL = require('url');
const md5 = require('md5');
const fs = require('fs');
const util = require('util');
const path = require('path');
const shell = require('shelljs'); //资源保存目录
const BASEDIR = './asserts/'; const start = async () => { //初始化删除清理资源目录,仅测试阶段,因为当前目录为时间戳生成
shell.exec('rm -rf asserts/');
//因为所有网络请求都会拦截,处理请求和页面资源以及dom构建无关可忽略
//下面的域名是比较常见的前端采集域名 (有很多没有列出来的)
const blackList = [
'collect.ptengine.cn',
'collect.ptengine.jp',
'js.ptengine.cn',
'js.ptengine.jp',
'hm.baidu.com',
'api.growingio.com',
'www.google-analytics.com',
'script.hotjar.com',
'vars.hotjar.com'
];
//用来缓存第三方资源(包括css、javascript),在请求没有结束之前,无法获取完整的第三方资源列,无法保证css、javascript中内容替换完整,所以先缓存,请求结束后再统一替换
const resourceBufferMap = new Map();
//第三方资源服务(域名)列表
const thirdPartyList = {};
try {
const browser = await puppeteer.launch(); const page = await browser.newPage();
//启用请求拦截
await page.setRequestInterception(true);
//以博客园为例子进行页面抓取
let url = "https://www.cnblogs.com"
let docUrl = URL.parse(url);
//获取请求地址的域名,用来确定资源是否来自第三方
let originUrl = (docUrl.protocol + "//" + docUrl.hostname)
//@fixme 每次抓取生成的内容目录名称
let md5_prefix = md5(Date.now()); page.on('request', async (req) => {
const whitelist = ['image', 'script', 'stylesheet', 'document', 'font'];
//如果请求的是第三方域名,只考虑和页面构建相关的资源
if (req.url().indexOf(originUrl) == -1 && !whitelist.includes(req.resourceType())) {
return req.abort(); }
//采集黑名单中的内容不处理
if (blackList.indexOf(URL.parse(req.url()).host) != -1) {
return req.abort();
}
req.continue(); }); page.on('response', async res => {
let request = res.request(),
resourceUrl = request.url(),
urlObj = URL.parse(resourceUrl),
filePath = urlObj.pathname, //文件路径
dirPath = path.dirname(filePath), //目录路径
requestMethod = request.method().toUpperCase(), //请求方法
isSameOrigin = resourceUrl.includes(originUrl); //是否是同域名请求 //只考虑get请求资源,其它http verb 对文件资源请求较少
if (requestMethod === 'GET') {
//如果是同一个域名下的资源,则直接构建目录,下载文件
//创建路径的方式依据请求本身path结构,保证和原资源网站目录结构完整统一,这样即使有CMD、AMD规范的代码再次执行,require相对路径也不会出现问题。
let dirPathCreatedIfNotExists,
filePathCreatedIfNotExists; let hostname = urlObj.hostname; if (isSameOrigin) {
//构建同域名path
//同域名的资源 有时会以//www.xxx.com/images/logo.png 这种方式使用,所以,对这种资源需要特殊处理
thirdPartyList[`//${hostname}`] = '';
dirPathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, dirPath);
filePathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, filePath);
} else {
//第三方资源构建正则表达式,替换http、https、// 三种模式路径为本地目录路径
thirdPartyList[`(https?:)?//${hostname}`] = `/${hostname}`;
dirPathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, hostname, dirPath);
filePathCreatedIfNotExists = path.join(BASEDIR, md5_prefix, hostname, filePath);
}
//获取扩展名 如果获取不到 则认为不是资源文件
if (path.extname(filePathCreatedIfNotExists)) {
//路径不存在,直接创建多级目录
if (!fs.existsSync(dirPathCreatedIfNotExists)) {
shell.exec(`mkdir -p ${dirPathCreatedIfNotExists}`);
console.log('create dir');
}
if (res.ok()) {
if ((isSameOrigin && dirPath != '/') || !isSameOrigin) {
let needReplace = ['stylesheet', 'script'];
//@fixme toString 可能会有编码问题
let fileContent = (await res.buffer()).toString();
//第三方域名还获取,先缓存再处理
if (needReplace.includes(request.resourceType())) {
//js css 文件中可能包含需要替换的内容,需要处理
//所以暂时缓存不写入文件
resourceBufferMap.set(filePathCreatedIfNotExists, fileContent);
} else { fs.writeFileSync(filePathCreatedIfNotExists, await res.buffer());
}
}
}
} } }); await page.goto(url, {
waitUntil: 'networkidle0'
}); let content = await page.content(); //对css javascript文件 进行替换处理
resourceBufferMap.forEach((value, key) => {
value = applyReplace(value, thirdPartyList);
fs.writeFileSync(key, value);
}) // html 内容处理
content = applyReplace(content, thirdPartyList); fs.writeFileSync(`./asserts/${md5_prefix}/index.html`, content); await page.close();
await browser.close();
} catch (error) {
console.log(error);
} } function applyReplace(origin, regList) {
for (let prop in regList) {
//进行正则全局替换
let reg = new RegExp(prop, 'g')
origin = origin.replace(reg, regList[prop]);
}
return origin;
} start();

总结

上述方案能解决几乎所有原始方案无法解决的问题,但是也并非十全十美,首选,相比原始方案,增加了渲染的步骤,所以性能有所下降;其次如果用户网站比较特殊,比如https://www.xxx.com/admin 这个路径下资源,比如某css文件中有如下写法:'background:url('./xxx.bg.png')' ,这时路径会找不到,因为在资源路径替换阶段,会替换为hostname,即查找资源是会去根目录去找,导致路径not found,不过这有其它改进的方案,比如可以把同域名的路径做的更灵活一点,可以让接口消费者修改。