熟悉个人朋友可能会知道,我一贯是不写热点的。为何不写呢?是由于我不关注热点吗?其实也不是。有些事件我仍是很关注的,也确实有很多想法和观点。 但我一直奉行一个原则,就是:要作有生命力的内容。javascript
本文介绍的内容来自于笔者以前负责研发的爬虫管理平台, 专门抽象出了一个相对独立的功能模块为你们讲解如何使用nodejs开发专属于本身的爬虫平台.文章涵盖的知识点比较多,包含nodejs, 爬虫框架, 父子进程及其通讯, react和umi等知识, 笔者会以尽量简单的语言向你们一一介绍.css
在开始文章以前,咱们有必要了解爬虫的一些应用. 咱们通常了解的爬虫, 多用来爬取网页数据, 捕获请求信息, 网页截图等,以下图: 前端
apify是一款用于JavaScript的可伸缩的web爬虫库。能经过无头(headless)Chrome 和 Puppeteer 实现数据提取和** Web** 自动化做业的开发。 它提供了管理和自动扩展无头Chrome / Puppeteer实例池的工具,支持维护目标URL的请求队列,并可将爬取结果存储到本地文件系统或云端。vue
咱们安装和使用它很是简单, 官网上也有很是多的实例案例能够参考, 具体安装使用步骤以下:java
npm install apify --save
复制代码
const Apify = require('apify');
Apify.main(async () => {
const requestQueue = await Apify.openRequestQueue();
await requestQueue.addRequest({ url: 'https://www.iana.org/' });
const pseudoUrls = [new Apify.PseudoUrl('https://www.iana.org/[.*]')];
const crawler = new Apify.PuppeteerCrawler({
requestQueue,
handlePageFunction: async ({ request, page }) => {
const title = await page.title();
console.log(`Title of ${request.url}: ${title}`);
await Apify.utils.enqueueLinks({
page,
selector: 'a',
pseudoUrls,
requestQueue,
});
},
maxRequestsPerCrawl: 100,
maxConcurrency: 10,
});
await crawler.run();
});
复制代码
使用node执行后可能会出现以下界面: node
咱们要想实现一个爬虫平台, 要考虑的一个关键问题就是爬虫任务的执行时机以及以何种方式执行. 由于爬取网页和截图须要等网页所有加载完成以后再处理, 这样才能保证数据的完整性, 因此咱们能够认定它为一个耗时任务.react
当咱们使用nodejs做为后台服务器时, 因为nodejs自己是单线程的,因此当爬取请求传入nodejs时, nodejs不得不等待这个"耗时任务"完成才能进行其余请求的处理, 这样将会致使页面其余请求须要等待该任务执行结束才能继续进行, 因此为了更好的用户体验和流畅的响应,咱们不德不考虑多进程处理. 好在nodejs设计支持子进程, 咱们能够把爬虫这类耗时任务放入子进程中来处理,当子进程处理完成以后再通知主进程. 整个流程以下图所示: webpack
nodejs有3种建立子进程的方式, 这里咱们使用fork来处理, 具体实现方式以下:css3
// child.js
function computedTotal(arr, cb) {
// 耗时计算任务
}
// 与主进程通讯
// 监听主进程信号
process.on('message', (msg) => {
computedTotal(bigDataArr, (flag) => {
// 向主进程发送完成信号
process.send(flag);
})
});
// main.js
const { fork } = require('child_process');
app.use(async (ctx, next) => {
if(ctx.url === '/fetch') {
const data = ctx.request.body;
// 通知子进程开始执行任务,并传入数据
const res = await createPromisefork('./child.js', data)
}
// 建立异步线程
function createPromisefork(childUrl, data) {
// 加载子进程
const res = fork(childUrl)
// 通知子进程开始work
data && res.send(data)
return new Promise(reslove => {
res.on('message', f => {
reslove(f)
})
})
}
await next()
})
复制代码
以上是一个实现父子进程通讯的简单案例, 咱们的爬虫服务也会采用该模式来实现.git
以上介绍的是要实现咱们的爬虫应用须要考虑的技术问题, 接下来咱们开始正式实现业务功能, 由于爬虫任务是在子进程中进行的,因此咱们将在子进程代码中实现咱们的爬虫功能.咱们先来整理一下具体业务需求, 以下图:
j'接下来我会先解决控制爬虫最大并发数这个问题, 之因此要解决这个问题, 是为了考虑爬虫性能问题, 咱们不能一次性让爬虫爬取因此的网页,这样会开启不少并行进程来处理, 因此咱们须要设计一个节流装置,来控制每次并发的数量, 当前一次的完成以后再进行下一批的页面抓取处理. 具体代码实现以下:
// 异步队列
const queue = []
// 最大并发数
const max_parallel = 6
// 开始指针
let start = 0
for(let i = 0; i < urls.length; i++) {
// 添加异步队列
queue.push(fetchPage(browser, i, urls[i]))
if(i &&
(i+1) % max_parallel === 0
|| i === (urls.length - 1)) {
// 每隔6条执行一次, 实现异步分流执行, 控制并发数
await Promise.all(queue.slice(start, i+1))
start = i
}
}
复制代码
以上代码便可实现每次同时抓取6个网页, 当第一次任务都结束以后才会执行下一批任务.代码中的urls指的是用户输入的url集合, fetchPage为抓取页面的爬虫逻辑, 笔者将其封装成了promise.
咱们都知道puppeteer截取网页图片只会截取加载完成的部分,对于通常的静态网站来讲彻底没有问题, 可是对于页面内容比较多的内容型或者电商网站, 基本上都采用了按需加载的模式, 因此通常手段截取下来的只是一部分页面, 或者截取的是图片还没加载出来的占位符,以下图所示:
// 滚动高度
let scrollStep = 1080;
// 最大滚动高度, 防止无限加载的页面致使长效耗时任务
let max_height = 30000;
let m = {prevScroll: -1, curScroll: 0}
while (m.prevScroll !== m.curScroll && m.curScroll < max_height) {
// 若是上一次滚动和本次滚动高度同样, 或者滚动高度大于设置的最高高度, 则中止截取
m = await page.evaluate((scrollStep) => {
if (document.scrollingElement) {
let prevScroll = document.scrollingElement.scrollTop;
document.scrollingElement.scrollTop = prevScroll + scrollStep;
let curScroll = document.scrollingElement.scrollTop
return {prevScroll, curScroll}
}
}, scrollStep);
// 等待3秒后继续滚动页面, 为了让页面加载充分
await sleep(3000);
}
// 其余业务代码...
// 截取网页快照,并设置图片质量和保存路径
const screenshot = await page.screenshot({path: `static/${uid}.jpg`, fullPage: true, quality: 70});
复制代码
爬虫代码的其余部分由于不是核心重点,这里不一一举例, 我已经放到github上,你们能够交流研究.
有关如何提取网页文本, 也有现成的api能够调用, 你们能够选择适合本身业务的api去应用,笔者这里拿puppeteer的page.$eval来举例:
const txt = await page.$eval('body', el => {
// el即为dom节点, 能够对body的子节点进行提取,分析
return {...}
})
复制代码
为了搭建完整的node服务平台,笔者采用了
const Koa = require('koa');
const { resolve } = require('path');
const staticServer = require('koa-static');
const koaBody = require('koa-body');
const cors = require('koa2-cors');
const logger = require('koa-logger');
const glob = require('glob');
const { fork } = require('child_process');
const app = new Koa();
// 建立静态目录
app.use(staticServer(resolve(__dirname, './static')));
app.use(staticServer(resolve(__dirname, './db')));
app.use(koaBody());
app.use(logger());
const config = {
imgPath: resolve('./', 'static'),
txtPath: resolve('./', 'db')
}
// 设置跨域
app.use(cors({
origin: function (ctx) {
if (ctx.url.indexOf('fetch') > -1) {
return '*'; // 容许来自全部域名请求
}
return ''; // 这样就能只容许 http://localhost 这个域名的请求了
},
exposeHeaders: ['WWW-Authenticate', 'Server-Authorization'],
maxAge: 5, // 该字段可选,用来指定本次预检请求的有效期,单位为秒
credentials: true,
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'x-requested-with'],
}))
// 建立异步线程
function createPromisefork(childUrl, data) {
const res = fork(childUrl)
data && res.send(data)
return new Promise(reslove => {
res.on('message', f => {
reslove(f)
})
})
}
app.use(async (ctx, next) => {
if(ctx.url === '/fetch') {
const data = ctx.request.body;
const res = await createPromisefork('./child.js', data)
// 获取文件路径
const txtUrls = [];
let reg = /.*?(\d+)\.\w*$/;
glob.sync(`${config.txtPath}/*.*`).forEach(item => {
if(reg.test(item)) {
txtUrls.push(item.replace(reg, '$1'))
}
})
ctx.body = {
state: res,
data: txtUrls,
msg: res ? '抓取完成' : '抓取失败,缘由多是非法的url或者请求超时或者服务器内部错误'
}
}
await next()
})
app.listen(80)
复制代码
该爬虫平台的前端界面笔者采用umi3+antd4.0开发, 由于antd4.0相比以前版本确实体积和性能都提升了很多, 对于组件来讲也作了更合理的拆分. 由于前端页面实现比较简单,整个前端代码使用hooks写不到200行,这里就不一一介绍了.你们能够在笔者的github上学习研究.
界面以下:
若是想学习更多H5游戏, webpack,node,gulp,css3,javascript,nodeJS,canvas数据可视化等前端知识和实战,欢迎在公号《趣谈前端》加入咱们的技术群一块儿学习讨论,共同探索前端的边界。