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

96
作者 relsoul
2016.12.11 13:33* 字数 1426

注意事项:

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

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

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

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

涉及框架

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

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

cheerio:nodejs版的jQuery

分析目标网站url

目标网站的url都是

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

${i}<=965

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

分享目标网站DOM结构

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

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

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

文章内容DOM结构

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

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

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

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

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

爬取分析

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

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

<!-- more -->

关于promise与co模块

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

a(function(){
    b(function(){
        c(function(){
            d(function(){

            })
        })
    })
})

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

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中是这样处理的

co(function*(){
    let id=yield a(10)
    let id1=yield a(id);
})

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

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

try{
    let id=yield a(10);
}catch(e){

}

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

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

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

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

process.on('unhandledRejection', function (err) {
    console.error(err.stack);
});

process.on(`uncaughtException`, console.error);

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

说多了.咋们继续爬虫

获取html的Promise函数

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为爬取函数.

{
    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函数

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来封装了一下

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函数

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);
});

添加全局错误监听函数

process.on('unhandledRejection', function (err) {
    console.error(err.stack);
});

process.on(`uncaughtException`, console.error);

最终代码

down

本文已经同步到

日记本