Jerremy's blog


  • 首页

  • 归档

简析hotjar录屏功能实现原理

发表于 2018-10-10

简析hotjar录屏功能实现原理

众所周知,hotjar中录屏功能是其重要的一个卖点,看着很牛X酷炫的样子,今天就简单的分析一下其可能实现(这里只根据其请求加上个人理解分析,并不代表hotjar中真实实现必然如此)的原理。

1、获取完整DOM内容

如果要实现完整的录屏功能,在客户端在没有客户允许的前提下,目前是无法做到的,所以只能考虑在服务端来实现,在服务端实现的第一步,就必然需要重现客户端的渲染结果,此时需要完整的发送客户端内容到服务端,在服务端进行完整的渲染。

从布玛的效果来看,获取DOM内容会涉及如下三个请求:

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
请求1 用来判断该页面内容在服务端是否存在
Request URL: https://in.hotjar.com/api/v1/sites/848493/pages/2338866123/content-id/34b2f50d09fbe08a4444e6691b1be779

Request Method: GET
Status Code: 200

//响应结果
{"exists": false}


请求2 预检请求 没啥说的,应该都知道干啥的
Request URL: https://in.hotjar.com/api/v1/sites/848493/url-hash/4f9e7b2f60ba35900e873ed12b1502ec/content
Request Method: OPTIONS
Status Code: 200

请求3 发送完整DOM内容
Request URL: https://in.hotjar.com/api/v1/sites/848493/url-hash/4f9e7b2f60ba35900e873ed12b1502ec/content
Request Method: POST
Status Code: 200

Request Payload
content: "{"docType":"<!DOCTYPE html>\n","rootId":1,"childre" .......
content_md5: "34b2f50d09fbe08a4444e6691b1be779"
page_id: 2338866123
page_url: "xxxxxxxxx.html"

//响应结果
{"page_content_id": 8784354270, "success": true}

从请求3 中可以看到,content部分其实就是对完整html的json化,这部分内容比较长,只贴出部分内容。

获取鼠标移动轨迹

只是获取完整DOM内容只是第一步,在hotjar的录屏功能中,还有一个是获取鼠标运动轨迹,想要绘制运动轨迹,必然要知悉鼠标在时间轴上的位置信息,所以hotjar中,必然要采集鼠标在不同时间点的位置信息,这个可以通过其websocket 请求

1
2
3
Request URL: wss://ws7.hotjar.com/api/v1/client/ws
Request Method: GET
Status Code: 101 Switching Protocols

在ws 请求过程中,会有mouse-move数据包的发送,其基本结构如下:

1
2
3
mouse_move: [{time: 106597, x: 215, y: 115}, {time: 106695, x: 181, y: 105}, {time: 106796, x: 134, y: 139},…]
page_visit_id: 14777325238
page_visit_key: "e9fa998e-5811-4d2f-81d2-bd296c7129af"

其中可以看到mouse_move 数据结构中,包含了时间轴上不断变化的坐标值(x,y),有了基于时间轴的xy坐标,我们绘制内容就变的不那么复杂了。

检测并发送DOM变化

除了鼠标运行轨迹之外,用户在页面上的所有行为都会被完整的记录下来,页面的任何变化也都被记录了下来,如果需要在服务端完整的重新演化这种变化,那么需要把完整的变化结构发送到服务器,让服务端进行变化回溯,hotjar是通过ws中发送mutation发送这种结构包的,当然要发送这种结构包,首先要先观测DOM变化,这里也有一种简单的方式(暂时不确定hotjar的实现)HTML5 DOM4级MutatioObserver方法,可以检查页面中的DOM是否发生变化,大家可以做一下简单的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//选择目标观测节点
let target = document.querySelector('目标节点选择器');

// 创建观察者对象
var observer = new window.MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
console.log(mutation);
});
});

// 观测项配置:
var config = { attributes: true, childList: true }

// 开始观测目标节点
observer.observe(target, config);

获取到变化的DOM结构(这种变化也是时序的变化,因为任何操作都可能导致变化,变化必然是有先后顺序的),然后通过ws发送到服务器,通过chrome network可以看到ws中mutation基本的包结构如下:

1
2
3
4
mouse_move: [{time: 118994, x: 404, y: 135}]
mutation: [{time: 118308,…}, {time: 118312,…}, {time: 118336, c: [{id: 7480,…}]}
page_visit_id: 14777325238
page_visit_key: "e9fa998e-5811-4d2f-81d2-bd296c7129af"

里面包含了DOM节点变化,其中包含变化节点如何变化的(通过节点的所有attribute来应用)

变化和轨迹回溯生成视频

最后一切数据准备完毕,需要生成视频了,生成视频当然涉及很多的计算,因为要演化和回溯用户的所有操作,我猜可能的思路是这样的:

  1. 在服务器启动浏览器 并 启动录屏软件(录屏软件只是猜测可能有其它多种方式)
  2. 根据页面发送的完整DOM进行初始化内容展示
  3. 按照时序合并鼠标轨迹和mutation包数据
  4. 根据时间轴自动操作改变DOM
  5. 访次结束完成录制

总结

hotjar中还涉及到更多的细节实现,里面很多内容也并没有考虑,比如发送view_port report_content包等都没什么在文章中体现出来,但这些并不影响主线分析,另外因为只是简要分析,所以并不涉及实现细节,有兴趣的欢迎留言讨论。

实现node端渲染图表的简单方案

发表于 2018-05-31

实现node端渲染图表的简单方案

这个题目有点小,本篇博客真正谈论的应该是服务端生成图表的简单方案,这里面有两个关键字:服务端 & 简单,我们知道基于js有很多的图表库,知名的如D3、echarts 、highcharts等等,对于做数据可视化方向的同学可能自己都做过此类chart的研发,无论从零构建还是使用已有的轮子,基本上都是基于js在做,因为大部分数据可视化产品都是to B的产品。

但是有些场景下,我们还是会需要服务端的渲染结果的,比如,需要给用户发送订阅邮件,邮件中包含了图表类展示,我们知道邮件内容可以支持html,但是只能支持最基本的html,图表类内容只能以图片资源的方式嵌入进去,由于图表是动态内容,所以需要我们在发送邮件之前根据用户特性内容去动态生成,这种情况下就会有对应的需要了;另外如果你的产品需要和类似slack这样的app 集成,做dashboard展示,也同样需要在服务端生成图表。

请注意服务端生成图表和编写服务端代码生成图表的细微区别,服务端编写代码生成图表并不一定是在服务端渲染图表,有可能只是是对客户端js的一种封装而已.

常规思路

  • 图表渲染的结果当前主要有两种(canvas绘制和svg渲染),以svg渲染为例来说明

svg本质上是xml,可以看到基于svg生成的图表其实就是生成一大坨的xml,如果服务端熟悉生成svg(xml)的规则,其实在服务端完全可以生成对应的xml(即svg图片),这种思路虽然没有问题,但是实现起来有些复杂,尤其在使用第三方chart 库的情况下,每种chart 对应的svg规则可能不同,如果官方没有提供对应服务端渲染方案,那么写起来还是比较费劲的。

  • 借用浏览器渲染

    在highcharts的官网可以看到不同平台的服务端导出实现,highcharts渲染后支持导出图片(svg、png、jpeg)以及pdf;默认情况下,点击导出的时候客户端会向highcharts服务器发送请求,然后服务器生成图片,响应到前端下载下来,但是这种并非是服务端渲染,而是前端发送渲染好的svg(xml)到服务器,服务端转换svg内容成图片文件,但是这种方式的前提是在浏览器端渲染完毕,服务端根据渲染结果做一些转换工作而已。

常规思路微调整

借用常规思路,我们了解到,在我们不熟悉chart库生成图表规则的前提下,我们并没有特别简单的方式来构建svg或者canvas图表,但是如果我们能在服务端直接把渲染的结果截图保存下来也基本实现了我们的方案,但是渲染chart最方便的方式是通过浏览器,此时我们便可以借用headless浏览器来实现,puppeteer正是google headless浏览器的上层node api,通过node 可以操控浏览器,node和浏览器能在同一个编程环境中,让我们在服务端借用浏览器成为一个很好思路。

要实现这么一个库,并且简单好用,那么就要保持和原chart库同样的配置,对于实现的消费者来说,最简单的调用应该就是render(options) ,options为所用第三方chart库的配置项,render方法是node端方法,图表需要浏览器渲染,我们需要一种机制在调用render方法是传递的options参数,传递给浏览器,在浏览器端拿到对应的参数进行渲染,所以基本实现步骤如下:

  1. 传递参数到node层render函数中

  2. 接收到render中option参数传递给浏览器的window对象

  3. 浏览器运行时从window对象中获取options渲染对应的结果

  4. 执行截图操作,保存渲染结果

可以用如下伪代码表示:

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
const puppeteer =require('puppeteer');

const render= async (options)=>{

//创建浏览器实例
const browser = await puppeteer.launch({
args:['--no-sandbox']
});
//创建page对象
const page = await browser.newPage();
//设置page内容
await page.setContent(`
//省略部分代码
<div id="container" style="width:600px;hight=400px"></div>
...
`);

//传递options对象到evaluate函数中,挂载到window对象的全局属性中
await page.evaluate((options)={
window.chart={
options
}
},options);

//这里以百度echarts为例说明 ,注入echarts库到页面
await page.addScriptTag({
url:'https://cdnxxx.echarts库'
})

//echarts 初始化脚本注入页面
await page.addScriptTag({
content:`
(function (window) {
let option =window.chart.options; //浏览器环境下获取window对象中chart的配置项进行初始化
var myChart = window.echarts.init(document.getElementById('container'), null, {
renderer: 'svg'
});
myChart.setOption(option);
})(this);

`
});

let $el = await page.$('#container');
let buffer = await $el.screenshot({
type: 'png',
path:'xxx.png'
});

await page.close();
await browser.close();

}

//使用方法
let options = {
...// echarts 各种配置
}

render(options);

上述代码可能没办法正常运行(毕竟只是伪代码),但是基本上把文字描述的步骤完整的表达了出来。对上面api不太了解的同学 点击这里

代码完善

上面的伪代码中,主要有两个变化点,1、第三方库 2、初始化脚本。

如果把上述两个变化点能封装起来,其实我们是理论上可以兼容所有charts的node端渲染的,只要提供了第三方库脚本和自定义的初始化脚本,不仅仅是chart,其它的任何内容都可以做到,只是需要写得初始化脚本是否复杂而已,这个需要根据具体需要均衡,毕竟没有银弹。

在上面思路的基础上,我抽象了一个node模块node-charts,内置了echart和highcharts的初始化脚本并支持外部扩展,使用方式如下:

1
npm install --save node-charts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const fs = require('fs');
const NodeCharts = require('node-charts');
let nc = new NodeCharts();

let option = {
//第三方chart 配置项
}

//监听全局异常事件
nc.on('error',(err)=>{
console.log(err);
});

nc.render(option,(err,data)=>{
fs.writeFileSync('test.png',data);
},{
type:'echarts' //所用的第三方库标识,内置highcharts 和echarts两种默认为echarts,可通过根目录创建node.config.js文件配置 外部chart
})

源码见 https://github.com/JerrZhang/node-charts 欢迎issue & star.

总结

这种思路写起来较为简单,但是也有一定的不足,首先限于puppeteer的限制,截图只支持两种png 、jpeg,其它格式当前版本(1.4.0)暂时不支持

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

发表于 2018-05-19

如何抓取页面所有内容

基本需求

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

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

Puppeteer实现选择性截图

发表于 2018-05-09

如何实现截图

对百度首页进行截图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const puppeteer = require('puppeteer');

const start = async () => {

const browser = await puppeteer.launch();

const page = await browser.newPage();

await page.goto('https://www.baidu.com');

await page.screenshot({
path:'baidu.png'
});
await page.close();
await browser.close();
}

start();

截图的代码很简单,比如如果我想实现对百度搜索框部分截图怎么做呢,查询page.screenshot api 可以看到其api说明中包含clip 选项,用于设置截图的x,y,width,hegiht.通过查看元素可以看到搜索框部分的form id 为 form 。

基于上面的代码做如下修改

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
const puppeteer = require('puppeteer');

const start = async () => {

const browser = await puppeteer.launch();

const page = await browser.newPage();

await page.goto('https://www.baidu.com');

//调用evaluate 方法返回id 为form元素的位置信息
let clip = await page.evaluate(() => {
let {
x,
y,
width,
height
} = document.getElementById('form').getBoundingClientRect();
return {
x,
y,
width,
height
};
});

await page.screenshot({
path:'baidu.png',
clip:clip //设置clip 属性
});
await page.close();
await browser.close();
}

start();

修改后即可完成对百度搜索框的局部截图

但上面的实现有些复杂,我们需要获取位置信息,有没有一种方式针对元素进行截图呢,puppeteer提供了另一个接口ElementHandle.screenshot 方法,该方法参数和page.screenshot 一样。ElementHandle 对象是页面内的Dom对象。

如果使用ElementHandle.screenshot ,我们的代码可以修改为

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
const puppeteer = require('puppeteer');

const start = async () => {

const browser = await puppeteer.launch();

const page = await browser.newPage();

await page.goto('https://www.baidu.com');

//调用evaluate 方法返回id 为form元素的位置信息
// let clip = await page.evaluate(() => {
// let {
// x,
// y,
// width,
// height
// } = document.getElementById('form').getBoundingClientRect();
// return {
// x,
// y,
// width,
// height
// };
// });

// await page.screenshot({
// path:'baidu.png',
// clip:clip //设置clip 属性
// });
//获取页面Dom对象form
let form = await page.$('#form');
//调用页面内Dom对象的screenshot 方法进行截图
form.screenshot({
path:'form.png'
});
await page.close();
await browser.close();
}

start();

总结

两种方法可以作如下类比学习:

page.screenshot 如果类比为document.getElementById

ElementHandle.screenshot 就类似domElement.getElementById

PS

今天puppeteer 发布了1.4.0版本主要更新如下:

大的升级:Chromium 68.0.3419.0 (r555668)

API 增加:1、elementHandle.$eval 类似domElement.querySelector(‘selector’) 2、page.browser() 3、target.browser

其它:bug fix 和优化

https://github.com/GoogleChrome/puppeteer/releases

基于puppeteer模拟登录抓取页面

发表于 2018-05-08

关于热图

在网站分析行业中,网站热图能够很好的反应用户在网站的操作行为,具体分析用户的喜好,对网站进行针对性的优化,一个热图的例子(来源于ptengine)

ptengine点击热图

上图中能很清晰的看到用户关注点在那,我们不关注产品中热图的功能如何,本篇文章就热图的实现做一下简单的分析和总结。

热图主流的实现方式

一般实现热图显示需要经过如下阶段:

  1. 获取网站页面
  2. 获取经过处理后的用户数据
  3. 绘制热图
    本篇主要聚焦于阶段1来详细的介绍一下主流的在热图中获取网站页面的实现方式
  4. 使用iframe直接嵌入用户网站
  5. 抓取用户页面保存到本地,通过iframe嵌入本地资源(所谓本地资源这里认为是分析工具这一端)

两种方式各有各的优缺点,首先第一种直接嵌入用户网站,这个有一定的限制条件,比如如果用户网站为了防止iframe劫持,不允许iframe嵌套(设置meta X-FRAME-OPTIONS 为sameorgin 或者直接设置http header ,甚至直接通过js来控制

1
if(window.top !== window.self){ window.top.location = window.location;}

),这种情况下就需要客户网站做一部分工作才可以被分析工具的iframe加载,使用起来不一定那么方便,因为并不是所有的需要检测分析的网站用户都可以管理网站的。

第二种方式,直接抓取网站页面到本地服务器,然后浏览的是本机服务器上抓取的页面,这种情况下页面已经过来了,我们就可以为所欲为了,首先我们绕过了X-FRAME-OPTIONS 为sameorgin的问题,只需要解决js控制的问题,对于抓取的页面来说,我们可以通过特殊的对应来处理(比如移除对应的js控制,或者添加我们自己的js);但是这种方式也有很多的不足:1、无法抓取spa页面,无法抓取需要用户登录授权的页面,无法抓取用户设置了白明白的页面等等。

两种方式都存在https 和 http资源由于同源策略引起的另一个问题,https站无法加载http资源,所以如果为了最好的兼容性,热图分析工具需要被应用http协议,当然具体可以根据访问的客户网站而具体分站优化。

抓取网站页面如何优化

这里我们针对抓取网站页面遇到的问题基于puppeteer做一些优化,提高抓取成功的概率,主要优化以下两种页面:

  1. spa页面
    spa页面在当前页算是主流了,但是它总所周知的是其对搜索引擎的不友好;通常的页面抓取程序其实就是一个简单的爬虫,其过程通常都是发起一个http get 请求到用户网站(应该是用户网站服务器)。这种抓取方式本身就会有问题问题,首先,直接请求的是用户服务器,用户服务器对非浏览器的agent 应该会有很多限制,需要绕过处理;其次,请求返回的是原始内容,需要在浏览器中通过js渲染的部分无法获取(当然,在iframe嵌入后,js执行还是会再一定程度上弥补这个问题),最后如果页面是spa页面,那么此时获取的只是模板,在热图中显示效果非常不友好。
    针对这种情况,如果基于puppeteer来做,流程就变成了
    puppeteer启动浏览器打开用户网站–>页面渲染–>返回渲染后结果,简单的用伪代码实现如下:
1
2
3
4
5
6
7
8
const puppeteer = require('puppeteer');

async getHtml = (url) =>{
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto(url);
return await page.content();
}

这样我们拿到的内容就是渲染后的内容,无论页面的渲染方式如何(客户端渲染抑或服务端)

需要登录的页面

对于需要登录页面其实分为多种情况:

  • 需要登录才可以查看页面,如果没有登录,则跳转到login页面(各种管理系统)
    对于这种类型的页面我们需要做的就是模拟登录,所谓模拟登录就是让浏览器去登录,这里需要用户提供对应网站的用户名和密码,然后我们走如下的流程:
    访问用户网站–>用户网站检测到未登录跳转到login–>puppeteer控制浏览器自动登录后跳转到真正需要抓取的页面,可用如下伪代码来说明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const puppeteer = require("puppeteer");
async autoLogin =(url)=>{
const browser = await puppeteer.launch();
const page =await browser.newPage();
await page.goto(url);
await page.waitForNavigation();

//登录
await page.type('#username',"用户提供的用户名");
await page.type('#password','用户提供的密码');

await page.click('#btn_login');

//页面登录成功后,需要保证redirect 跳转到请求的页面
await page.waitForNavigation();

return await page.content();
}
  • 登录与否都可以查看页面,只是登录后看到内容会所有不同 (各种电商或者portal页面)

这种情况处理会比较简单一些,可以简单的认为是如下步骤:

通过puppeteer启动浏览器打开请求页面–>点击登录按钮–>输入用户名和密码登录 –>重新加载页面

基本代码如下图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const puppeteer = require("puppeteer");
async autoLoginV2 =(url)=>{
const browser = await puppeteer.launch();
const page =await browser.newPage();
await page.goto(url);

await page.click('#btn_show_login');

//登录
await page.type('#username',"用户提供的用户名");
await page.type('#password','用户提供的密码');

await page.click('#btn_login');

//页面登录成功后,是否需要reload 根据实际情况来确定
await page.reload();

return await page.content();
}

总结

明天总结吧,今天下班了。
补充(还昨天的债):基于puppeteer虽然可以很友好的抓取页面内容,但是也存在这很多的局限

  1. 抓取的内容为渲染后的原始html,即资源路径(css、image、javascript)等都是相对路径,保存到本地后无法正常显示,需要特殊处理(js不需要特殊处理,甚至可以移除,因为渲染的结构已经完成)
  2. 通过puppeteer抓取页面性能会比直接http get 性能会差一些,因为多了渲染的过程
  3. 同样无法保证页面的完整性,只是很大的提高了完整的概率,虽然通过page对象提供的各种wait 方法能够解决这个问题,但是网站不同,处理方式就会不同,无法复用。

基于puppeteer的网络拦截工具flyover

发表于 2018-05-04

为什么需要网络拦截工具

通常我们会遇到这样的场景:

  1. 线上一个图片有异常,线下修正之后需要发布之前进行测试,有些情况下,QA同学有直接在线上环境测试的需求,只是链接本地资源,通常在windows下有一个很好的工具fiddler可以完成这个功能,当然mac下也有其对应的工具Charles,但是工具收费,在不付费的前提下,使用体验很差。
  2. 线上js资源出了bug,线下修复之后通过测试区不好还原场景,因为只有在线上的数据环境下才能重现bug,但是在线上的代码一般都是混淆过的,调试很不友好(这里不考虑生成soucemap的场景)
  3. 其它需要使用本地资源替换线上资源的情况

flyover是什么

  • flyover是一个简易的本地网络代理工具,基于Puppteer做网络拦截,可以实现对线上压缩代码的调试。
  • flyover基于node & puppeteer实现 & 代码开源(当然发布没几天,可能会有很多问题,欢迎大家issue)

如何使用flyover

  1. npm install -g flyover
  2. flyover –help具体细节见
    https://github.com/JerrZhang/flyover ,欢迎issue 和 star

flyover 实现原理

我们知道所有请求,无论是页面请求还是js 发起的各种请求,最终都是通过浏览器软件发起的,服务器响应后,都是响应给浏览器的,那么整个工程可以细分为如下流程(个人理解):

  1. 请求浏览器发起请求
  2. 浏览器接到请求申请,发起请求到服务器
  3. 服务器处理后响应给浏览器
  4. 浏览器把响应给上层引擎(比如UI引擎& js引擎等)

puppeteer提供了在第2阶段和第4个阶段的拦截,可以完成浏览器接到请求之后,终端到远程服务器的请求,转而读取本地资源响应给上层引擎;依次来实现对资源的本地化拦截和替换;技术实现也比较简单,只需要在启用拦截后,监听page 对象的request事件进行处理即可,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//启用请求拦截
this.page.setRequestInterception(true);
//监听请求事件
this.page.on('request', (req) => {
//如果包含在拒绝列表中的文件
let denyFile = this._isInCtrllist(req.url(), ctrlsfiles);
if (denyFile.getIsDenyFile()) {
//直接响应本地文件内容
req.respond({
body: denyFile.getFileContent()
});
} else {
//其它情况请求继续
req.continue();
}
})

简单的几行代码就实现了一个初级的网络拦截工具,是不是很简单实用?

完整代码 https://github.com/JerrZhang/flyover

不足

  1. 只能使用chrome浏览器来测试,这是由于puppeteer本身的限制决定的
  2. 还无法支持sourcemap,2.0中会支持添加sourcemap来进行调试

使用Puppeteer抓取受限网站

发表于 2018-05-02

不要相信前端是安全的,今天简单验证一下,但是希望大家支持正版,支持原作者,毕竟写书不易。

安装Puppteer

1
npm install --save puppeteer

选择目标网站

我们这里选择胡子大哈大神的网站 http://huziketang.mangojuice.top ;
爬取所有文章

基本思想思路

  • 实现方案

爬取书籍目录->根据目录爬取没个章节的内容

  • 注意的地方

本书有付费章节和免费章节,爬取付费章节需要禁用javascript执行,然后移除对应的mask的dom节点

核心代码

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
const path = require('path');
const fs = require('mz/fs');
const puppeteer = require('puppeteer');


const pdfDir = path.resolve(__dirname, './pdf/');
const targetHost = "http://huziketang.mangojuice.top"


//保存pdf 文件
const savePdf = async (page, link) => {
let fileName = link.substring(link.lastIndexOf('/')) + '.pdf';
await page.goto(link);
await page.evaluate(() => {
//隐藏左侧菜单栏 以及下方部分内容
let allNeedHidens = document.querySelectorAll('#table-of-content,.PageNavigation,.share-block,hr,blockquote,.post__back>a,#wrapper>h1');
let elCount = allNeedHidens.length;
for (let i = 0; i < elCount; i++) {
allNeedHidens[i].style.display = 'none';
}
});
await page.pdf({
path: pdfDir + fileName
});
console.log(`${link} saved to pdf successfully!!!`);
}



//启动程序
const start = async () => {
//创建一个browser 实例
let browser = await puppeteer.launch({
headless: true,
devtools: false
});

//创建一个空白page实例
let page = await browser.newPage();
//设置禁用js,当前必须设置,否则会导致页面无法处理
//说明:只是禁用page原有javascript,但是page.evaluate 中可以继续使用
await page.setJavaScriptEnabled(false);

//获取书目录标题
await page.goto(targetHost + '/books/react/');

let result = await page.evaluate((targetHost) => {
//获取目录链接
let ulArray = document.querySelectorAll('ul.table-of-content>li>a');
let array = Array.prototype.slice.call(ulArray, 0);
let links = array.map((v) => {
let href = v.getAttribute('href');
return `${targetHost}${href}`;
})
return {
links: links
}
}, targetHost);

for (let link of result.links) {

await savePdf(page, link);

}


await page.close();
await browser.close();


}

start();

代码比较简单,不做过多解释。

Hello World

发表于 2018-05-02

Welcome to Hexo! This is your very first post. Check documentation for more info. If you get any problems when using Hexo, you can find the answer in troubleshooting or you can ask me on GitHub.

Quick Start

Create a new post

1
$ hexo new "My New Post"

More info: Writing

Run server

1
$ hexo server

More info: Server

Generate static files

1
$ hexo generate

More info: Generating

Deploy to remote sites

1
$ hexo deploy

More info: Deployment

Jerremyzhang

8 日志
© 2018 Jerremyzhang
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4