web2.0页面抓取
什么是web2.0页面?
我个人理解的就是不是写死的页面
今天要给大伙介绍的页面抓取框架是谷歌针对操作无头浏览器推出的一个基于nodejs的框架——Puppeteer。这个框架的API位于中文API文档。遗憾的是,中文API翻译得并不完整,所以还是得提高自身的英文文档阅读能力。
安装nodejs环境
参考博客
利用koa构建nodejs后端服务
这一步的作用主要是让我们写的页面抓取部分能对外直接提供方便调用的接口。至于到底使用koa还是别的Nodejs框架,甚至于不用web服务,都是可以的
安装Puppeteer 和 Chromium
搭建起nodejs的开发环境之后,就可以安装puppeteer和chromium了。
npm i puppeteer # 会同时安装chromium,需要全局代理
npm i puppeteer-core # 不会安装chromium,不需要全局代理
如果出现安装不上的情况,那么可能是你的npm和node不兼容引起的,最好的办法是重装npm和nodejs。
简单抓取一个Web2.0页面
var glovakBrower;
async function crawl(url){
if(typeof glovakBrower == 'undefined' || glovakBrower == 'undefine'){
globalBrower = await puppeteer.launch({
headless:true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
// '--proxy-server=http://127.0.0.1:18888'
],
executablePath:'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
});
}
var page = await glovakBrower.newPage();
try {
page.setDefaultNavigationTimeout(110 * 1000);
page.setUserAgent('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.130 Safari/537.36');
page.setJavaScriptEnabled(true);
page.setCacheEnabled(false);
page.setRequestInterception(true);
page.on('request', interceptedRequest => {
let url = interceptedRequest.url();
if(url.indexOf('.png') > -1 || url.indexOf('.jpg') > -1
|| url.indexOf('.gif') > -1
|| url.indexOf('.pdf') > -1
|| url.indexOf('.txt') > -1
|| url.indexOf('.mp4') > -1
|| interceptedRequest.resourceType() === 'image')
interceptedRequest.abort();
else
interceptedRequest.continue();
});
await page.goto(url);
await page.waitForSelector('html');
let html = await page.content();
return html;
} catch (error) {
}
finally {
page.close();
}
}
这里仅仅使用了goto方法,然后通过waitForSelector方法等待html的加载,后面我会介绍更为合理的方法。这样一来,一个基本的抓取web2.0页面的爬虫就完成了。这样的爬虫交给单纯做页面文本抓取的服务就已经完全够了。
一个真正的web2.0页面抓取模块
1、DOM树遍历
2、事件递归触发
3、表单自动填写
等等
如何实现上面的功能?
先来看看会遇到的问题
1、事件触发的时候,会产生新的事件,同时会导致页面发生变化
此时我们有两种方案解决这样的问题,其一是重新加载整个页面,其二是监听页面的变化。
先来说说前面那个方法存在的问题:页面重新加载会导致所有事件会被重新触发,进而造成混乱。
所以可以考虑监听页面的变化,在DOM中提供了DOM4类型事件,这种事件会保存一段时间内页面的节点变化情况,以异步的方式去告诉调用者哪些节点发生了变化。我们拿到变化之后,只需要再使用dom树遍历的方式,例如treeWalker等遍历新增的DOM节点,然后进行事件识别和触发即可。
存在的问题:由于是异步的,我们在上层触发的时候无法做到判断下层事件究竟需要执行多久,只能通过等待的方式去异步触发,而不是以同步的方式,等待下层的触发完了再回到上层的事件触发。
优化思路:nodejs本身也是单线程的,所以执行肯定是有先后关系的,需要弄清楚nodejs底层原理才行。
2、触发一部分事件的时候,可能会导致调用window.open 和 window.close 操作
需要对浏览器的window.open和window.close方法的默认行为进行修改,避免出现页面重定向的行为,为啥要避免?前面提到了,不论是open还是location都会导致页面主体发生变化,导致页面重新加载,进而造成所有事件被重新触发,造成混乱。
puppeteer提供了相关的方法在页面加载前去修改BOM中对象的默认行为,但是并不是所有对象的所有方法都能被修改,这取决于浏览器是如何定义这些方法的。幸运的是,window.open 和 window.close方法是完全可以进行修改的。
3、触发一部分事件的时候,可能会导致调用window.location 操作
window.location方法在浏览器中默认是无法修改的,所以我们如果在代码中直接修改其默认的行为是不生效的。
解决方案:修改chromium源码,重新编译chrome。
4、form表单填写时,点击提交会导致页面重新加载
首先修改submit方法的默认行为,然后给form表单添加dom2型submit事件,再利用dispatchEvent触发该事件。
代码部分
创建浏览器对象,由于ndoejs默认是单线程的,所以我们只需要在全局打开一个浏览器对象即可,减少内存的浪费。
if (typeof globalBrower === 'undefined' || globalBrower === 'undefine') {
globalBrower = await puppeteer.launch({
headless: false,
ignoreHTTPSErrors: true, // 忽略证书错误
waitUntil: 'networkidle2',
defaultViewport: {
width: 1920,
height: 1080
},
args: [
'--disable-gpu', // 关闭GPU
'--disable-dev-shm-usage',
'--disable-web-security',
'--disable-xss-auditor', // 关闭 XSS Auditor
'--no-zygote',
'--no-sandbox', // 关闭沙箱模式
'--disable-setuid-sandbox',
'--allow-running-insecure-content', // 允许不安全内容
'--disable-webgl',
'--disable-popup-blocking',
],
executablePath: 'path/to/chrome',
});
}
刚才我们提到了,需要在页面加载之前修改一些浏览器默认的行为。可以使用page.evaluateOnNewDocument的方法。
// 修改location的默认行为
// 注意:在修改之前,确认你已经修改过浏览器中关于Location的默认行为了
await page.evaluateOnNewDocument(() => {
var oldLocation = window.location;
var fakeLocation = Object();
fakeLocation.replace = fakeLocation.assign = function (value) {
};
fakeLocation.reload = function () {
};
fakeLocation.toString = function () {
};
Object.defineProperties(fakeLocation, {
'href': {
'get': function () {
},
'set': function (value) {
}
},
// hash, host, hostname ...
});
var replaceLocation = function (obj) {
Object.defineProperty(obj, 'location', {
'get': function () {
},
'set': function (value) {
}
});
};
replaceLocation(window);
addEventListener('DOMContentLoaded', function () {
})
});
// 修改bom中一些其他函数的默认行为
async function initHook() {
window.onbeforeunload = function (e) {
};
//todo hook History API,许多前端框架都采用此API进行页面路由,记录url并取消操作
window.history.pushState = function (a, b, url) {
};
window.history.replaceState = function (a, b, url) {
};
Object.defineProperty(window.history, "pushState", {"writable": false, "configurable": false});
Object.defineProperty(window.history, "replaceState", {"writable": false, "configurable": false});
// todo 监听hash变化,Vue等框架默认使用hash部分进行前端页面路由
window.addEventListener("hashchange", function () {
});
// todo 监听窗口的打开和关闭,记录新窗口打开的url,并取消实际操作
window.alert = () => {
};
Object.defineProperty(window, "alert", {"writable": false, "configurable": false});
window.prompt = (msg, input) => {
};
Object.defineProperty(window, "prompt", {"writable": false, "configurable": false});
window.confirm = () => {
};
Object.defineProperty(window, "confirm", {"writable": false, "configurable": false});
window.open = function (url) {
};
Object.defineProperty(window, "open", {"writable": false, "configurable": false});
window.close = function () {
};
Object.defineProperty(window, "close", {"writable": false, "configurable": false});
window.__originalSetTimeout = window.setTimeout;
window.setTimeout = function () {
};
window.__originalSetInterval = window.setInterval;
window.setInterval = function () {
};
XMLHttpRequest.prototype.__originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
}
XMLHttpRequest.prototype.__originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (data) {
}
HTMLFormElement.prototype.__originalSubmit = HTMLFormElement.prototype.submit;
HTMLFormElement.prototype.submit = function () {
// hook code
}
}
在hook完对象后,我们需要使用page.on方法在页面事件触发的时候进行处理
await page.on('request', async interceptedRequest => {
});
await page.on('dialog', async dialog => {
});
await page.on('response', interceptedResponse => {
});
await page.on('requestfailed', async failed => {
});
await page.on('console', async msg => {
});
page.on('framenavigated', frameTo => {
});
值得注意的是,触发request事件需要开启页面拦截。page基础设置相关的代码为
await page.setDefaultNavigationTimeout(110 * 1000);
await page.setUserAgent('Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko)Chrome/44.0.2403.130 Safari/537.36');
await page.setJavaScriptEnabled(true);
await page.setCacheEnabled(false);
await page.setRequestInterception(true); // 页面的request事件触发取决于此
await page.setViewport({"width": 1920, "height": 1080});
以上准备工作完成之后,就可以调用goto方法对页面进行加载。注意调用的先后顺序,否则会出现意想不到的问题。
await page.goto(url, {
waitUntil: 'networkidle2'
});
其中,waitUntil的存在是因为goto方法是异步的,在等到还有networkidle2(两个网络通信)的时候结束等待。
然后我们需要监听整个body的节点变化,只需要利用dom4级别的事件监听即可
await page.$eval('body', () => {
// todo 这里监听的是dom4事件,即dom树被修改,触发节点变化的事件
// mutations 是一个node_list
let observer = new MutationObserver((mutations) => {
console.log('eventLoop-nodesMutated:', mutations.length, typeof mutations);
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
for (let i = 0; i < mutation.addedNodes.length; i++) {
// 构造treeWalker进行节点遍历
}
} else if (mutation.type === 'attributes') {
}
});
});
observer.observe(document.getElementsByTagName('body')[0], {
childList: true,
attributes: true,
characterData: false,
subtree: true,
characterDataOldValue: false,
attributeFilter: ['src', 'href'],
});
});
最后,在遍历所有form表单,创建事件并触发即可
let formsParams = await page.evaluate(() => {
// 表单处理
let regexps = {
};
let name_type = {
};
let name_values = {
};
let textLengthReg = '([\\s\\S]*)?((len$)|(length$)|(size$))';
let formsParams = [];
for (let i = 0; i < document.forms.length; i++) {
let form = document.forms[i];
// console.log(form.method, form.action);
let ans = {};
// todo form can't intercept, so I
for (let j = 0; j < form.length; j++) {
let length = -1;
let input = form[j];
let name = input.name.replace('_', '').toLowerCase().trim();
if (input.type === 'submit') {
// 如果当前输入框为 submit,则点击提交
form.addEventListener('submit', (e) => {
e.preventDefault();
new FormData(form);
});
form.addEventListener('formdata', (e) => {
let data = e.formData;
let request = new XMLHttpRequest();
request.open(form.method, form.action);
request.send(data);
});
let evt = document.createEvent('HTMLEvents');
evt.initEvent('submit', true, true);
try {
form.dispatchEvent(evt);
} catch {
}
continue;
}
for (let key in regexps) {
if (name.match(regexps[key])) {
// 如果name匹配到了当前正则表达式,填入值即可
break;
}
}
formsParams.push({
'url': form.action,
'method': form.method,
'params': ans
});
}
}
return formsParams;
});
剩下还有一些对标签的触发比较简单,大家可以自行解决。