背景和需要解决的问题:
- 上游服务生产大量的文章爬虫数据,下游Puppeteer服务需要处理这些数据,转换成格式化的标准文章。
- 之所以使用Puppeteer服务来产生标准化文章是因为只有浏览器才能比较精准的解析css,而我们需要提取出关键的样式再赋予标准化的格式,比如加粗,加斜,或者居中,大小号字体等。
- 任务处理比较多,QPS需要达到50-200。
首先,你需要先对Puppeteer有一个简单的认识,它的官网在这里:
GoogleChrome/puppeteergithub.com
然后,我们简单的用一句话概括,Puppeteer就是一个可编程的服务端无头浏览器,提供了丰富的API来让开发者做一些自动化的事。
所以解决这个问题的思路就是:
- Puppeteer打开Browser,准备好对应的Tab。
- 爬虫服务调用Puppeteer服务,Puppeteer处理来源的原始HTML。
- HTML利用Puppeteer打开后,进行节点的过滤和选择。
- 解析真正的正文部分内容,获取节点内的computedStyle。
- 过滤一部分异常的内容,根据筛选后的符合标准化样式的Style做对应的格式化转换,加入我们自己的className。
- 把结果返回,如果中间有错误,把错误返回。
- 关闭这个Tab。
- 关闭这个Browser。
看起来挺美好的,启动一个简单的NodeJs后端服务,我们就可以按照这8个步骤完成开发,但是现实其实并不是这么美好。
- 频繁的开关browser和tab,效率很低。
- Puppeteer异常一次后,browser就不受控制,无法关闭,导致内存泄露,前期上线后QPS高时,内存暴涨,QPS低时,内存不释放。
- 插入的HTML内容里有部分JS做了防抓站,会跳走。
- 在Page中evaluate脚本的时候,极度难以调试,你也不知道爬虫抓来的是啥东西,错误率超高,调试需要case by case,上线后JS报错很难追踪和复现。
- Puppeteer自身很慢,并发非常低,处理任务一秒一个都做不到,我们的要求是QPS最少50。
所以其实这个项目的大头不在于解析样式和格式化样式(虽然这部分代码也挺有意思,后边简单给一些代码),但是挑战其实在于如何让服务稳定且性能高。
我做了几件事:
一,设计了一个简易的Puppeteer连接池
class pool {
constructor(browserMax, pageMax) {
this.browsers = [];
this.browserMax = browserMax;
this.pageMax = pageMax;
}
}
Pool类接受2个参数,browserMax和每个browser的pageMax数,使用browsers来储存所有的browser和page。
async createAll() {
for (var i = 0; i < this.browserMax; i++) {
let browser = await createBrowser();
this.browsers[i] = { browser, pages: []};
let [defaultPage] = await browser.pages();
defaultPage = await setPage(defaultPage);
this.browsers[i].pages[0] = {
page: defaultPage,
used: false,
count: 0,
browser
};
for (var k = 1; k < this.pageMax; k++) {
let page = await createPage(browser);
this.browsers[i].pages[k] = {
page,
used: false,
count: 0,
browser
};
}
}
return this.browsers;
}
提前创建好所有的browser,避免每次请求需要用到的时候临时创建和关闭,增加并发和性能,我们创建的方法比较简单,这里需要注意的是,await在forEach里有一些古怪的问题,这里不展开说,最好都使用for in 或者for of来处理这种async,await的场景。
每个browsers的item存着一个browser引用,一组page引用。
let [defaultPage] = await browser.pages();
这句是为了拿到默认打开第一个tab用的。
每个page的引用里保存了自己的引用,当前这个page是否在使用,使用了几次,以及归属于哪个browser。
async function createBrowser() {
let browser = await puppeteer.launch({
headless: process.env.NODE_ENV === "dev" ? false : true,
ignoreHTTPSErrors: true,
args: [
"--disable-web-security",
"--disable-setuid-sandbox",
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-first-run",
"--no-zygote",
"--disable-popup-blocking"
]
});
return browser;
}
创建browser的代码,需要配置一下chrome的参数,比如不限制安全请求,禁用popup弹窗,禁用sandbox规则等,因为我们不知道抓来的html里都有什么样的请求限制,索性都打开了。
启动的时候headless也根据环境来切换是否无头启动,本地dev开发,调试起来还是有界面的方便一些。
async function createPage(browser) {
// 使用默认的就是PC 的 devices
// const devices = require("puppeteer/DeviceDescriptors");
// const iPhonex = devices["iPhone X"];
let page = await browser.newPage();
page = await setPage(page);
return page;
}
创建Page的代码,可以设置统一的UA和设备类型,Puppeteer默认是PC设备。
async function setPage(page) {
await page.setViewport({
width: 1200,
height: 800
});
// await page.emulate(iPhonex);
// await page.setJavaScriptEnabled(false);
await page.setRequestInterception(true);
page.on("request", request => {
let type = request.resourceType(); // else
if (type === "image" || type === "script") request.abort();
else if (
request.isNavigationRequest() ||
request.redirectChain().length > 0
) {
request.abort();
} else {
request.continue();
}
});
return page;
}
设置每个page的一些属性,比如打开的Viewport,模拟移动设备,是否进制执行js,是否过滤一些资源请求,为了让处理速度更快,我们禁用掉了script和image资源,只保留css的资源加载,并且阻止了frame需要多次重定向的请求。
async use(func, ctx) {
let item = await this._findFreePage();
if (item) {
item.used = true;
item.count++;
let ret;
try {
ret = await func(item.page);
} catch (e) {
logger.error(`${e.message}`, ctx);
}
if (item.count >= 5) {
await item.page.close();
item.page = await createPage(item.browser);
item.count = 0;
}
item.used = false;
return ret;
} else {
responseCode.throwError(
responseCode.NOT_FREE_PAGE_INSTANCE,
"not free page instance"
);
}
}
use方法是从连接池里拿可复用的page的方法,这里的规则是先找到一个空闲的page单位,然后给这个page单位加锁,使用次数累计,然后开始执行使用,使用报错我们会进行try catch捕获,然后如果使用完了,使用次数超过5次,那么我们关闭这个page,然后创建新的page实例,补上这个page单位。最后解锁page,返回page实例的处理结果。
如果没有空闲的page,返回对应的错误,说明所有的page都在使用中,调用方会进行等待重试。
async _findFreePage() {
for (var i = 0; i < this.browserMax; i++) {
for (var k = 0; k < this.pageMax; k++) {
let item = this.browsers[i].pages[k];
if (item.used === false) {
if (item.page.isClosed()) {
item.page = await this.createPage(this.browsers[i]);
item.count = 0;
}
this.browsers[i].pages.push(this.browsers[i].pages.splice(k, 1)[0]);
return item;
}
}
}
return false;
}
我们怎么从池子里找到空闲的page呢,也比较简单,首先从我们存储的browsers对象中进行查找,找到下面没有被used的page,判断是否有异常被关闭了,如果异常被关闭了,我们需要重新建立补上,然后使用次数归0,最后我们这个选中的page的存储排序挪到数组最后,让其他的没有被used的page有更大(快)的机会被拿到。
async checkFree() {
for (var i = 0; i < this.browserMax; i++) {
for (var k = 0; k < this.pageMax; k++) {
if (this.browsers[i].pages[k].used) {
await sleep(1000);
return this.checkFree();
}
}
}
}
async close() {
for (var i = 0; i < this.browserMax; i++) {
await this.browsers[i].browser.close();
}
}
关闭方法不多说了,直接把所有browsers关了即可,checkFree方法进行所有的page检查,如果有一个在使用,那么就等1秒,再重新检查,这个checkFree是用来进行所有browsers重启用的,使用方法如下:
let pagePool = new Pool(poolConfig.browserMax, poolConfig.pageMax);
pagePool.browsers = await pagePool.createAll();
function loopFreeMem(ms) {
setTimeout(async () => {
let oldPool = pagePool;
backup = new Pool(poolConfig.browserMax, poolConfig.pageMax);
backup.browsers = await backup.createAll();
pagePool = backup;
await oldPool.checkFree();
await oldPool.close();
logger.info(`loop free mem ${oldPool.id}, ${pagePool.id}`);
loopFreeMem(ms);
}, ms);
}
loopFreeMem(1000 * 60 * 60 * 4);
我们在服务启动之前,先创建一个pagePool,然后创建所有的page,保存在browsers这个数组中。
然后写一个定时释放所有browsers的递归,比如4小时一次的loop,一天检查6次,避免服务内存一直不释放的问题。
在loopFreeMem函数中,我们先保存老的pagePool引用,然后创建一个新的,要切换的browsers对象,然后进行上面的checkFree操作,一直检查到所有的page都被用完释放,关闭所有browsers,然后开始下一次loop。
await pagePool.use(async page => {
await page.evaluate();
},ctx);
ctx是koa传入的,为了在内部可以打log用,这样我们使用时,就是这样就可以拿到一个空闲的page实例了,再对page直接做我们想做的操作,比如evaluate等,而我们也不需要维护这个page的生命周期,所有的操作都封装在了pagePool连接池中。
二,调试evaluate中的代码。
我们都知道在Puppeteer的evaluate中执行的脚本是浏览器内的JavaScript,它和NodeJs环境是不同的上下文,所以我们在evaluate中的日志是不太好看的,针对这个问题,其实可以在evaluate return
的时候,把我们想log的数据一并导出就行了,实现起来也很简单。
async function parse(page, xpath, selector) {
// await autoScroll(page);
const newDoc = await page.evaluate(
(selector, xpath) => {
return {
str: result,
log: logs
};
},
selector,
xpath
);
return newDoc
}
嗯,直接返回一个对象,带着我们想要的处理结果和日志结果,然后我们打印的时候可以这样:
let response = await createParse(page, selector, xpath);
response.log.map(item => {
logger.info(`${ctx.requestId} page.console: ${item}`);
});
return response.str
直接把接口请求的requestId和日志做关联然后循环输出就行了。
三,XPATH隐藏的坑和格式化主逻辑
可以从上面看出我们是支持xpath和selector来进行二次的HTML筛选的,这里说一下XPATH的选择方法,因为大部分的同学可能对这个比较陌生,尤其是在浏览器端的API。
function $xpath(path) {
try {
return document.evaluate(
path,
document.documentElement,
null,
XPathResult.ORDERED_NODE_ITERATOR_TYPE,
null
);
} catch (e) {}
}
这里注意第四个参数XPathResult.ORDERED_NODE_ITERATOR_TYPE,可以去查一下官方网站这个位置的参数有什么作用,之前遇到一个问题就是文章的排序没有按照原文的顺序来,因为默认的排序关系是选择关系,而不是NODE的ORDERED。
if (xpath) {
result = "";
rootList = $xpath(xpath);
let domList = [];
logs.push("xpath query success!");
next = rootList.iterateNext();
while (next) {
domList.push(next);
next = rootList.iterateNext();
}
for (var i = 0; i < domList.length; i++) {
var item = domList[i];
result += await cleancode(item);
}
return {
str: result,
log: logs
};
}
之后的操作就比较简单了,但是这里需要注意的是,xpath在evaluate之后,如果你在iterateNext之前对结果节点进行了修改,那么你就无法再做iterateNext操作了,会报错。所以我们的cleancode函数需要在while分组完成后进行格式化了。
function dfs(dom, process) {
nodeNum++;
let children = [];
Array.from(dom.childNodes).forEach(child => {
let next = dfs(child, process);
if (next) {
children.push(next);
}
});
return process(dom, children);
}
在cleancode函数中,主要是依赖于dfs这个函数进行筛选后结果的递归操作,在每个process函数中对每个dom节点和这个节点的children集合做判断和修改,用来输出最后的格式化结果,下面我举几个例子:
function replaceTable(node) {
let tableList = Array.from(node.getElementsByTagName("table"));
if (tableList.length === 0) return;
tableList.forEach(table => {
let trs = Array.from(table.getElementsByTagName("tr"));
let ps = [];
trs.forEach(tr => {
let html = tr.innerHTML;
let p = document.createElement("p");
p.innerHTML = html;
ps.push(p);
});
ps.forEach(p => {
table.parentNode.insertBefore(p, table);
});
table.parentNode.removeChild(table);
});
}
比如把table都换成p标签,因为我们的页面不支持直接渲染TABLE,会很丑,也没办法控制大小。
function shouldDisplayBlock(dom) {
let selfblock = getComputedStyle(dom).display === "block";
if (selfblock) return selfblock;
// 判断同级是否有block
let brotherBlock = false;
for (var i = 0; i < dom.parentNode.childNodes.length; i++) {
let node = dom.parentNode.childNodes[i];
if (
node.nodeType === 1 &&
getComputedStyle(node).display === "block"
) {
brotherBlock = true;
break;
}
}
return brotherBlock;
}
比如判断一个dom是否应该是block的,因为有的网站,父元素不是block,但是子元素block了,那么这个元素应该也是block的。
function removeHidden(dom) {
// 过滤隐藏元素
let isDisplayNone = getComputedStyle(dom).display === "none",
isVisibility = getComputedStyle(dom).visibility === "hidden",
/*
isSmallIMG =
parseInt(getComputedStyle(dom).width, 10) < 12 &&
parseInt(getComputedStyle(dom).height, 10) < 12,
*/
isColor =
getComputedStyle(dom).color.match(/rgba\((?:\d+,\s){3}(\d+)\)/) &&
parseInt(
getComputedStyle(dom).color.match(
/rgba\((?:\d+,\s){3}(\d+)\)/
)[1],
10
) === 0,
isImgFontsize =
parseInt(getComputedStyle(dom).fontSize, 10) <= 0 &&
dom.getElementsByTagName("img").length === 0, // 字体的尺寸为0
isTextIndent =
parseInt(getComputedStyle(dom).textIndent, 10) <= -999, // 文本缩进小于999px的
isBGcolor =
(!getComputedStyle(dom).backgroundColor ||
getComputedStyle(dom).backgroundColor === "") &&
getComputedStyle(dom).color === "#ffffff", // 不存在背景色,且字体颜色为白色
isBGcolorSame =
getComputedStyle(dom).backgroundColor &&
getComputedStyle(dom).backgroundColor ===
getComputedStyle(dom).color, // 存在背景色,且背景色和字体颜色一致
isSmallWH =
parseInt(getComputedStyle(dom).width, 10) < 18 &&
parseInt(getComputedStyle(dom).height, 10) < 18 &&
getComputedStyle(dom).overflow === "hidden", // 高度或者宽度小于18px 且 overflow为hidden
isOpacity = parseInt(getComputedStyle(dom).opacity, 10) === 0;
return (
isDisplayNone ||
isVisibility ||
// isSmallIMG ||
isColor ||
isImgFontsize ||
isTextIndent ||
isBGcolor ||
isBGcolorSame ||
isSmallWH ||
isOpacity
);
}
再比如判断一个元素是不是真的不可见,我们的规则也非常多。
function processNode(dom, next) {
if (dom.nodeType === 1) {
let tagName = dom.tagName.toLowerCase();
if (WHITELIST_TAG.includes(tagName) && next.length) {
// 白名单标签,嵌套一层
return (
"<" +
tagName +
' cms-style="' +
tagName +
'">' +
next.join("") +
"</" +
tagName +
">"
);
} else if (tagName === "a") {
return (
"<a href='" +
dom.getAttribute("href") +
"'>" +
next.join("") +
"</a>"
);
} else if (MEDIA_TAG.includes(tagName)) {
if (
dom.getAttribute("data-original") ||
dom.getAttribute("original") ||
dom.getAttribute("real_src") ||
dom.getAttribute("data-src") ||
dom.getAttribute("p_src") ||
dom.getAttribute("src")
) {
return (
"<" +
tagName +
" src='" +
(dom.getAttribute("data-original") ||
dom.getAttribute("original") ||
dom.getAttribute("real_src") ||
dom.getAttribute("data-src") ||
dom.getAttribute("p_src") ||
dom.getAttribute("src")) +
"'>"
);
}
} else if (NEWLINE_TAG.includes(tagName)) {
return "<p cms-style='font-L'> </p>";
} else if (
// 过滤隐藏元素
removeHidden(dom)
) {
return "";
} else if (
shouldDisplayBlock(dom) &&
// dom.offsetParent !== null && //pandu
next.length
) {
if (!next.join("").match(/<p\s?[^>]*>/g)) {
// 获取next所有样式 进行叠加
if (next.includes("<br>")) {
// 将br拆成多个p
let brArr = [];
next
.join("")
.split(/<br>/)
.forEach(section => {
section &&
brArr.push(
"<p cms-style='" +
cmsStyle(dom) +
"'>" +
section +
"</p>"
);
});
return brArr.join("");
} else {
// 在外层嵌套p
return (
"<p cms-style='" +
cmsStyle(dom) +
"'>" +
next.join("") +
"</p>"
);
}
} else {
return next.join("");
}
} else {
return next.join("");
}
}
if (dom.nodeType === 3) {
let text = dom.nodeValue.replace(/^( ?\s*)*$/g, "");
if (
dom.parentNode.tagName === "SCRIPT" ||
dom.parentNode.tagName === "STYLE"
) {
return "";
}
if (/(^\s+$|^\B$)/.test(text)) return "";
return text;
}
}
最后我们把整个HTML通过dfs函数中的方法遍历出一套标准的基于白名单和过滤规则的HTML string来,可以看到过滤函数里拼接的标准样式都是通过css的属性选择器来进行修正的,而cmsStyle函数则是解析dom样式,来进行判断,这一行文本应该是什么标准样式的方法。还有比如遇到br和分段怎么办,遇到script,遇到image(处理懒加载属性),遇到媒体标签怎么处理的逻辑。
最后我们拿到了所有的样式之后,还有一套兜底的补充逻辑,比如我们xpath选中的内容,全是文本,没有匹配到合法的html标签,再或者选中的内容,有文本是直接在一级元素中的,我们无法遍历到dom和children的时候,需要一套能够补标签的逻辑,这个逻辑我利用的Javascript中的正则exec方法。
// 兜底的p标签
let reg = /<(p|blockquote|ul)[\s|\S]*?>[\s|\S]*?<\/\1>/g;
reg.lastIndex = 0;
let execsize = 0;
let exceptTime = 0;
let result = "";
while ((crt = reg.exec(lasthtmlstring)) !== null) {
let itemLength = crt[0].length;
if (reg.lastIndex - itemLength != execsize) {
let needWrapItem = lasthtmlstring.slice(
execsize,
reg.lastIndex - itemLength
);
if (!/^\s+$/g.test(needWrapItem)) {
result += `<p cms-style='font-L'>${needWrapItem}</p>`;
}
}
result += crt[0];
execsize = reg.lastIndex;
exceptTime++;
}
if (execsize !== lasthtmlstring.length) {
result += `<p cms-style='font-L'>${lasthtmlstring.slice(
execsize,
lasthtmlstring.length
)}</p>`;
}
这里的技巧也可以说一下,我们通过记录每次exec匹配到的节点位置,匹配的长度,来进行非捕获的感知,比如我们匹配到的第一个p或者blockquote标签的位置不是第一个开始位置,说明开始位置到匹配的第一个位置这个区间出现了异常,如果不是空字符,那么我们就需要补一个p元素包住,这样全部匹配完毕后,最后的判断是说,如果匹配的最后结果不等于最终结果长度,说明结尾部分也是有异常的,需要补一个兜底的P元素。
四,充钱让服务变强。
前面说了一些优化方法和使用方法,以及格式化的主逻辑,我们其实最终遇到的问题还是说,并发扛不住,qps到了10几个的时候,容器pod的cpu就飙到99%-120%的使用率。
那么我们通过调整了browser的数量,tab的数量后发现,增加多browser和tab并不能让负载有效的增高,但是会让cpu和内存成倍的增加,最后发现每一个browser的创建,都会让nodejs handle住一个chrome的进程,这个消耗太大且不划算,最后我们决定,一个docker容器启动4个进程,每个进程启动1个browser,每个browser启动10个page。
这样一个docker容器就可以同时处理40个请求,但是只启动了4个browser,然后我们决定通过多容器负载的方式来增加qps,也就是说达到200的QPS的话我们就启动5个pod就可以了,因为一次处理请求在1s内很难完成,我们后来发现是因为有一些脏数据阻塞了Puppeteer,我们可以通过设置比较短的超时时间来进行预防,比如:
await page
.setContent(html, {
timeout: 5000,
waitUntil: "domcontentloaded"
})
.catch(e => {
e.source = html;
responseCode.throwError(responseCode.CONTENT_ERROR, e);
});
xpath &&
(await page.waitForXPath(xpath, { timeout: 4000 }).catch(e => {
e.source = html;
responseCode.throwError(responseCode.XPATH_ERROR, e);
}));
selector &&
(await page.waitForSelector(selector, { timeout: 4000 }).catch(e => {
e.source = html;
responseCode.throwError(responseCode.SELECTOR_ERROR, e);
}));
默认page的超时时间都比较长,一个页面里如果5s没有domready,4s没有找到对应的xpath和selector都应该是属于脏数据了,说明网站和我们的过滤规则不一致导致的,就不要卡着服务了。
还有就是我们整个服务启用了4个IDC服务,每个IDC服务启动了20个容器pod,这样就大大的缓解了处理时间慢的问题,因为我们的个数多,一个IDC的请求大概能够同时处理20*40个page的处理,这样就达成了200QPS的任务…,嗯,充钱让你强大。
五,抓站脚本写了防抓逻辑怎么破
我们在处理异常的时候,经常会遇到一些特别有意思的JS代码,比如页面报错了,onerror之后弹个alert,页面进入的时候有人用JS判断了如果URL不是本域的,就做重定向或者弹窗,这里告诉大家几个破解的小技巧。
if (original_url) {
let myURL = new URL(original_url);
let original_host = myURL.host;
let original_origin = original_url.slice(
0,
original_url.lastIndexOf("/") + 1
);
html = `
<base href="${original_origin}">
<script>
window.alert = window.confirm = window.prompt = function(){};
Object.defineProperty(document, 'domain', { value: '${original_host}'})
</script>
${html}
`;
}
html = `
<html>
<script>
window.onerror = function(e){
return false;
}
</script>
${html}
</html>
`;
await page
original_url是抓站新闻的原始链接,我们解析出来host之后,先给页面加一个base标签,防止页面资源写的相对路径,因为setContent打开的page,url是空的。
然后我们插入2段JS脚本,先给alert,confirm这些脚本设置空,避免阻塞Puppeteer注入的js,然后我们给document上的domain设置好正确的domain,**(defineProperty可以给一些只读属性设置新的值,比如navigator.platform也是适用的)**这样一般的判断就都能过了,最后再给onerror给设置return false,防止页面脚本报错阻塞我们插入的格式化文章JS。
最后,这篇文章比较长,项目写了也蛮久了,最近看到很多关于Puppeteer的文章,所以也凑个热闹,节前需求不多,写了这篇文章来分享一些自己的小经验吧。