如何抓取页面所有内容

基本需求

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

  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,不过这有其它改进的方案,比如可以把同域名的路径做的更灵活一点,可以让接口消费者修改。

超越Ctrl+S保存页面所有资源的更多相关文章

  1. Hexo瞎折腾系列(5) - 使用hexo-neat插件压缩页面静态资源

    为什么要压缩页面静态资源 对于个人博客来说,优化页面的访问速度是很有必要的,如果打开你的个人站点,加载个首页就要十几秒,页面长时间处于空白状态,想必没什么人能够忍受得了吧.我个人觉得,如果能把页面的加 ...

  2. 巧用location.hash保存页面状态

    在我们的项目中,有大量ajax查询表单+结果列表的页面,由于查询结果是ajax返回的,当用户点击列表的某一项进入详情页之后,再点击浏览器回退按钮返回ajax查询页面,这时大家都知道查询页面的表单和结果 ...

  3. JS中用execCommand("SaveAs")保存页面兼容性问题解决方案

    开发环境:ASP.NET MVC,其他环境仅供参考. 问题描述:在开发中遇到这样的需求,保存页面,通常使用JavaScript的saveAs进行保存,各浏览器对saveAs支持,见下表. 代码一:初始 ...

  4. [IOS]UIWebView实现保存页面和读取服务器端json数据

    如何通过viewView保存访问过的页面?和如何获取并解析服务器端发送过来的json数据?通过一个简单的Demo来学习一下吧! 操作步骤: 1.创建SingleViewApplication应用,新建 ...

  5. 用putty玩linux的时候由于以前用window 习惯写完东西按一下ctrl+s 保存

    问题描述:用putty玩linux的时候由于以前用window 习惯写完东西按一下ctrl+s 保存,但是在putty一按下就不能再输入了.后来查找到:ctrl+s 是putty的一个命令大概是这样子 ...

  6. 使用location.hash保存页面状态

    hash 属性是一个可读可写的字符串,该字符串是 URL 的锚部分(从 # 号开始的部分). 语法 location.hash 在我们的项目中,有大量ajax查询表单+结果列表的页面,由于查询结果是a ...

  7. js使用ctrl+s保存表单提升用户体验

    本质上是监控ctrl+s 然后触发相应事件 <script language="JavaScript"> //Ctrl+s保存 document.onkeydown=f ...

  8. 前端js保存页面为图片下载到本地

    前端js保存页面为图片下载到本地 手机端点击下载按钮将页面保存成图片到本地 前端js保存页面为图片下载到本地的坑 html2canvas 识别 svg 解决方案 方案 html2canvas.js:可 ...

  9. 保存页面数据的场所----Hidden、ViewState、ControlState

    1.使用隐藏域Session.Application和Cache都是保存在服务器内存中的.一般来说我们是无权访问客户端的机器,把数据直接保存在客户端的(Cookie是一个例外,不过Cookie只能保存 ...

随机推荐

  1. Gradle 1.12用户指南翻译——第三十五章. Sonar 插件

    本文由CSDN博客万一博主翻译,其他章节的翻译请参见: http://blog.csdn.net/column/details/gradle-translation.html 翻译项目请关注Githu ...

  2. 数据包接收系列 — IP协议处理流程(二)

    本文主要内容:在接收数据包时,IP协议的处理流程. 内核版本:2.6.37 Author:zhangskd @ csdn blog 我们接着来看数据包如何发往本地的四层协议. ip_local_del ...

  3. 【38】java的集合框架(容器框架)

    Collection接口 Collection是最基本的集合接口,一个Collection代表一组Object,即Collection的元素(Elements).一些 Collection允许相同的元 ...

  4. Mac OS X下64位汇编与Linux下64位汇编的一些不同

    1 首先系统调用号大大的不同:mac64和linux32的系统调用号也不同(虽然局部可能有相同) 2 mac64的系统调用号在: /usr/include/sys/syscall.h 可以查到,但是调 ...

  5. 摄像头ov2685中关于sensor id 设置的相关的寄存器地址

    OV2685 : CHIP_ID address : 0x300A    default : 0x26 address : 0x300B    default : 0x85 address : 0x3 ...

  6. 深入了解Collections

    在 Java集合类框架里有两个类叫做Collections(注意,不是Collection!)和Arrays,这是JCF里面功能强大的工具,但初学者往往会忽视.按JCF文档的说法,这两个类提供了封装器 ...

  7. IT轮子系列(四)——使用Jquery+formdata对象 上传 文件

    前言 在MVC 中文件的上传,一般都采用控件: <h2>IT轮子四——文件上传</h2> <div> <input type="file" ...

  8. CSS的display:table

    好久都没有写博客了,似乎总是觉得少了些什么-- 刚好最近在工作中遇到了一个新的东西display:table,这个也是css的布局的一种,而且又是display的,之前已经写过了display的fle ...

  9. activeMq的入门程序

    生产者 1.导入相关依赖 2.交给Spring管理,写入相关配置JmsTemplate @RunWith(SpringJUnit4ClassRunner.class) @ContextConfigur ...

  10. java之jsp内置对象

    1.out对象 <% out.println("金鳞岂是池中物,<br>"); out.println("一遇风云变化龙.<br>" ...