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

如何抓取页面所有内容

基本需求

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

  1. 页面内元素

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

  1. 页面内所有资源

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

a). 相对路径

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

b). 绝对路径

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

c). 绝对路径2

1
<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域名本地化目录后的路径)

核心代码说明

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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,不过这有其它改进的方案,比如可以把同域名的路径做的更灵活一点,可以让接口消费者修改。