手把手教你如何利用nodejs+es6+co写一个爬虫

注意事项:

  1. 这里的爬虫不做太复杂的处理..

  2. 考虑到并发问题.这里的爬虫仅仅是爬完上一个后再爬下一个. 爬完当页后再去爬取下一页,效率虽然低..但是胜在不用同一时间发请大量请求避免被ban

  3. 本文以admin5.com为案例来爬取200页的文章title和content

  4. 本文涉及到的es6语法这里只会简单的说明一下.如果看不懂…来打我啊(笑)

涉及框架

crawler:为一个封装好的nodejs爬虫库,免去你用request框架发请请求然后处理一大堆的返回代码问题.本文只把crawler当做请求工具用.内容的处理将会用cheerio框架来完成

co:能够把异步代码写成跟同步一样,号称es6的async.

cheerio:nodejs版的jQuery

分析目标网站url

目标网站的url都是

1
http://www.admin5.com/browse/19/list_${i}.shtml

${i}<=965

那么这就好办了.生成965个链接然后每次去爬一个链接

分享目标网站DOM结构

目标网站的每篇文字的链接都在一个class为sherry_title的a标签里

1
<a href="http://www.admin5.com/article/20161209/700550.shtml" class="sherry_title" target="_blank">我是如何通过论坛推广产品的?</a>

那么每次爬的时候获取当页的所有文章链接然后再去爬取

文章内容DOM结构

标题放在一个class为sherry_title的div下的h1标签中

1
2
3
<div class="sherry_title">
<h1>我是如何通过论坛推广产品的?</h1>
</div>

内容则放在一个class为content的div标签中

1
2
<div class='content'>
</div>

那么内容中的图片如何爬取呢?
这个也简单…不过这篇文章暂时不说..哈哈哈哈哈

爬取分析

分析完目标网站后.那么就开始分析如何去爬.

  1. 封装一个获取html的Promise函数
  2. 封装一个获取目录的Promise函数
  3. 获取一个获取文章内容的Promise函数
  4. 开始爬取函数

关于promise与co模块

首先我们知道关于最初的解决异步方案是callback(回调),当异步请求完毕后再去通知你的callback然后我们只能在callback里去做数据处理.
这样很容易引起回调地狱.

1
2
3
4
5
6
7
8
9
a(function(){
b(function(){
c(function(){
d(function(){
})
})
})
})

后来出现了promise.实际上也是改善了写法而已,promise会返回两种状态,成功(resolve)和失败(reject).就像你做事情一样,只有成功或者失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function a(id){
return new Promise(function(resolve,reject){
setTimeout(function(){
if(id>10){
reject(id)
}
resolve(id)
},1000)
})
}
a(8).then((id)=>{
id+=10;
return a(id)
})
.then((id)=>{
})
.catch((id)=>{
})

上述封装了一个a函数,这个a函数执行的时候不可能立即返回一个id给你,因为有个定时器,等一秒后才会返回. 这个就是很明显的异步.然后我们把他封装成promise

当你调用a(id)的时候,实际上就已经开始执行这个函数了,不过因为我们a函数返回的是一个promise,这个promise会有个then方法.那么我们可以在then方法里面拿到1秒以后的id

promise有个特性是,你可以返回无限的promise,然后一直then,then,then下去.这算是改善了一种写法.不过重点不在于此.因为后面的co模块和Generator函数都是基于promise来完成的

Generator函数

这个说起来太长…篇幅问题.下次再谈

Co模块

其实简单点,我们并不需要知道内部调用.我们最终想要的效果仅仅是 让异步的写法变得优雅最好能够变成同步函数.ok.co函数和未来的async可以满足你这个需求

拿上述的a函数来说,在co中是这样处理的

1
2
3
4
co(function*(){
let id=yield a(10)
let id1=yield a(id);
})

爽吧.只需要包裹在co里面,就可以达到同步写法的效果,那么这个yield后面的函数满足什么条件呢?

很简单,yield后面的函数只要是promise函数即可. 上述我们说过,promise有两种状态,一种是成功,一种是拒绝
成功当然你就可以直接拿到let id=yield a(10);这个id值咯,假如失败如何监听呢? 也很简单

1
2
3
4
5
try{
let id=yield a(10);
}catch(e){
}

用try,catch即可. 那么我没用try,catch 但是又返回了一个失败的状态.那错误在哪里?

说实在话..你如果不去捕捉的话..你这个错误会消失..对..就会消失掉. 如果你某一天发现你的程序无论如何也run不起来.但是莫名其妙又没报错.
相信我兄弟..这锅promise绝对要背..

那我这种懒癌晚期的患者怎么办?不可能每次都要写try,catch吧?

在nodejs中有两个事件,可以监听到未捕捉的报错信息 那就是

1
2
3
4
5
process.on('unhandledRejection', function (err) {
console.error(err.stack);
});
process.on(`uncaughtException`, console.error);

其实不用管这个事件是啥意思.你每次加上就行了..程序运行起来的时候有很多问题都是我们考虑不到的..但是错误又被吞了.我们又不能进一步处理.
这时候我们可以监听这两个事件.就算没写try catch 你都可以找到错误的源头.

说多了.咋们继续爬虫

获取html的Promise函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let c=new Crawler({
retries:1, //超时重试次数
retryTimeout:3000 //超时时间
});
let contentJson=[];
const getHtml=co.wrap(function*(html){
return new Promise((resolve,reject)=>{
setTimeout(()=>{
c.queue({
url:html,
forceUTF8:true,
callback:function (error,result,$) {
if(error||!result.body){
errorCount++;
return resolve({result:false});
}
result=result.body;
resolve({error,result,$})
}
})
},2000)
})
});

这里的let c=new Crawler 为初始化爬虫引擎,返回的是这个爬虫引擎的实例.

c.queue为爬取函数.

1
2
3
4
5
6
7
8
9
10
11
{
url:html, //爬取目标网站的url
forceUTF8:true, // 强制转码为UTF-8
callback:function (error,result,$) { //error为如果爬取超时或者返回错误HTTP代码时会出现
if(error||!result.body){
return resolve({result:false});
}
result=result.body;
resolve({error,result,$})
}
}

这里的$是框架已经调用了cheerio.不过我们这里不用框架封装好的cheerio.

获取目录的Promise函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const getSubHtml=co.wrap(function*(body){
let $=cheerio.load(body); //字符串转为DOM
let UrlElems=$("a.sherry_title"); //获取到目录中所有文章的url
let subUrlList=[]; //链接存储数组
UrlElems.each((i,e)=>{ //循环获取链接并且存储起来
let url=$(e).attr('href');
let href=`${url}`;
subUrlList.push(href);
});
for(let item of subUrlList){
let {result}=yield getHtml(item); //获取每篇文章的body内容
if(!result){
continue;
}
let {title,content}=yield getContent(result); //获取标题和内容
console.log(`${title}获取完毕`);
contentJson.push({ //最终存储到JSON数组中
title,
content
})
}
});

获取每篇文章内容的Promise函数

嗯..实际上这里并不是异步的.只是从DOM中去获取内容.但是为了保持好看一致..这里也就用co来封装了一下

1
2
3
4
5
6
7
const getContent=co.wrap(function*(body){
let $=cheerio.load(body); //字符串转DOM
let title=$(".sherry_title>h1").text(); //获取标题
let content=$(".content").text(); //获取内容
return Promise.resolve({title,content})
});

start函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let urlList=[];
for(let i=1;i<=250;i++){
urlList.push(`http://www.admin5.com/browse/19/list_${i}.shtml`)
}
co(function*(){
for (let url of urlList){
let {result}=yield getHtml(url); //获取目录body
if(!result){
continue;
}
//console.log("result",result);
//获取当页所有SUB
yield getSubHtml(result);
}
console.info(`全部爬取完毕`,contentJson);
});

添加全局错误监听函数

1
2
3
4
5
process.on('unhandledRejection', function (err) {
console.error(err.stack);
});
process.on(`uncaughtException`, console.error);

最终代码

down

本文已经同步到