2018-06-05

谈一谈简单的js爬虫

基本概念

网络爬虫的两个主要任务就是:

  1. 下载页面
  2. 找页面中的链接

使用到的第三方包

"cheerio": "^1.0.0-rc.2" nodejs版的jquery
"events": "^3.0.0" 监听
"log4js": "^2.7.0" 日志
"mongodb": "^3.1.0-beta4" 数据库插件
"superagent": "^3.8.3" 网络访问的包

第三方依赖的使用

log4js

var log4js = require('log4js')
//设置日志文档
log4js.configure({
    appenders:{cheese:{type:'file',filename:'../log/'+new Date()+'.log'},out:{type:'stdout'}},
    categories:{default:{appenders:['cheese','out'],level:'error'}}
})
const logger = log4js.getLogger('cheese')
logger.debug('msg')
logger.error('msg')
logger.info('msg')
logger.warn('msg')

cheerio

主要用来解析页面中的链接,非常核心的模块

const $ = cheerio.load(html)
let hrefs = $('[href]')
    for(let i = 0 ; i < hrefs.length;i++){
        this.href.push($(hrefs[i]).attr('href'))
    }

这是官方推荐的写法,先用load方法载入页面,$('[href]')就是jquery选择器的写法,由于得到的是DOM
对象,所以每次都要$(href[i])转换为jquery对象,最后使用attr()方法取出href属性。这便是本例用到的所有方法,如果还想继续深入了解,请前去npm阅读相应文档。

events

监听模块

let emitter = new events.EventEmitter()
const LISTEN_TITLE = 'one_turn_done'
emitter.addListener('one_turn_done',function () {
    logger.debug('新队列开始sitemapLinks:',sitemapLinks.length)
    if(counter>=200) {
        counter = 0
        logger.debug('Rest')
        setTimeout(()=>{
            logger.debug('休息结束')
            excuteList().then((values) => {
                emitter.emit('one_turn_done')
            })},600000)

    }else {
        logger.debug('不休息')
        excuteList().then((values) => {
            emitter.emit('one_turn_done')
        })
    }
})

首先,要建立一个监听的对象

再使用EventEmitter的addListener方法添加监听

最后使用emit方法触发监听

mongodb

非关系数据库,使用他的原因是因为数据量比较大,mongodb读写快。
但由于数据库操作是异步的,所以我使用Promise来控制:下载-》href入库-》下载...这样的同步顺序

function initMongo(resolve,reject) {
    let dburl = 'mongodb://localhost:27017'
    MongoClient.connect(dburl,function (err,db) {
        if(err){
            reject(err.message)
        }else {
            longTimeDBClient = db.db('crawler').collection('segmentfault')
            resolve('welcome mongoDb')
        }
    })
}

数据库链接的初始化操作,这种写法将一个Mongodb连接赋给全局变量,这样不用每次都去处理这个同步操作,缺点就是:非常的耗费内存。

longTimeDBClient.insertOne({domain:'https://www.segmentfault.com',url:'/tags'},()=>{})

插入操作

longTimeDBClient.find({domain:currentDomain,url:currentUrl})
       .toArray(function (err,res) {
            if(res.length===0){
                 sitemapLinks.push({
                     domain: currentDomain,
                     url: currentUrl
                 })
            longTimeDBClient.insertOne({domain:currentDomain,url:currentUrl},()=>{
            //logger.debug(currentDomain+currentUrl+':入队成功')
            resolve(currentDomain+currentUrl+':入队成功')
                })
            }else{
                    resolve(currentDomain+currentUrl+':重复文档')
                  }
             })

查找操作

let updatestr ={ $set: {
    title: wi.title,
    body: wi.body,
    encoding: wi.encoding,
    html: wi.html,
}}
longTimeDBClient.updateOne(
    {
          domain: wi.domain,
          url: wi.url,
    },
    updatestr,
    function (err, _) {
    if (err) logger.record('error', err.message);
    else logger.debug('文档插入成功 domain:', wi.domain, ' url:', wi.url,'现在数组的长度:',sitemapLinks.length)
    })

修改操作

superagent

const request = require('superagent')
request
  .get(wi.getDURL())
  .set('user-agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36')
  .set('accept','text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8')
  .end(function (err,  res){})

这个依赖对nodejs的http包分装的非常精美get用来设置要访问的网址,set可以设置表头信息,end是最后一个方法
发送请求并将结果返回到回调函数的res参数上。

流程图

爬虫自然语言描述.jpg

代码描述

出队并下载页面

function excuteList(){
    if(sitemapLinks.length===0){
        //如果执行器发现队列为0,那么结束
        //这种情况很少:可能是站点已经爬完或者发生了未知
        //console.log()
        logger.debug('3.可能爬完了,sitemapLinks: 0 currentLinks:',currentLinks.length)
        process.exit(0)
    }
    exchangeLinks()
    let promiseQueue = []
    let fivecounter = 0
    //console.log(currentLinks)
    while(currentLinks.length > 0){
        promiseQueue.push(new Promise(buildTheDownLoadEvn(currentLinks.pop(),fivecounter)))
        fivecounter++
    }
    return Promise.all(promiseQueue)
}

exchangeLinks()将预备栈中取出特定数量的链接,插入到爬取队列,使用buildTheDownLoadEvn()方法来消费爬取队列,fivecounter用来记录这是第几个链接,用来设置每五秒发送一个请求。这里使用Promise的all方法,使得在这些链接爬取结束后,我再进入下一轮‘出队下载页面’。

exchangeLinks

function exchangeLinks() {
    currentLinks = []
    //每次最多取300个
    for(let i = 0 ; i < 300; i++){
        if(sitemapLinks.length>0) {
            let shift = sitemapLinks.shift()
            currentLinks.push(new webInformation(shift.domain, shift.url))
        }
    }
}

下载页面并且判断链接是否合法

let buildTheDownLoadEvn = (wi,fivecounter)=>{
    return function download(resolve,reject) {
        counter++
        setTimeout(()=>{
            request
                .get(wi.getDURL())
                .set('user-agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36')
                .set('accept','text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8')
                .end(function (err,res) {
                    if(err) {
                        logger.error(wi.getDURL(),err.message)
                        resolve(err)
                    }
                    else {
                        if (res.statusCode === 200&&res.text) {
                            wi.findTheInfo(res.text)
                            let tempLine = []
                            //这里限制了队列的长度,最长20000
                            if(sitemapLinks.length <= 60000&&wi.url.length<=60){
                                wi.href.forEach(function (t) {
                                    tempLine.push(new Promise(pushAcceptableLink(t, wi.domain, wi.url)))
                                })
                                Promise.all(tempLine)
                                    .then(function (data) {
                                        resolve(wi.getDURL())
                                        logger.debug('检查promise:现在数组的长度:',sitemapLinks.length)
                                        //logger.debug('')
                                    })
                            }

                            else{
                                //如果队列到达上限那么,也要返回
                                resolve(wi.getDURL())
                            }

                            let updatestr ={ $set: {
                                title: wi.title,
                                body: wi.body,
                                encoding: wi.encoding,
                                html: wi.html,
                            }}
                            longTimeDBClient.updateOne(
                                {
                                    domain: wi.domain,
                                    url: wi.url,
                                },
                                updatestr,
                                function (err, _) {
                                if (err) logger.record('error', err.message);
                                else logger.debug('文档插入成功 domain:', wi.domain, ' url:', wi.url,'现在数组的长度:',sitemapLinks.length)
                            })

                            //成功带回成功的链接为了在日志文件中记录

                            //console.log(sitemapLinks)
                        } else {
                            resolve(0)
                            logger.error(wi.getDURL(),'internet error stateCode:' + res.statusCode)
                            //日志里要记录一些信息 DURL和错误代码,错误发生的时间
                        }
                    }
                })
        },5000*fivecounter)

    }
}

通过外部函数构建一个新的环境,返回的download是符合Promise回调函数的接口。在request的回调函数中,用pushAcceptableLink来判断链接是否爬过和是否是我要爬的页面,这个规则可以自己定义的。最后longTimeDBClient.updateOne来将页面信息入库,这里没有使用Promise,因为页面入库和爬取的过程是两个不相干的过程。

pushAcceptableLink

function pushAcceptableLink(element,domain,url) {
    return (resolve,reject)=>{
        let regIsFullName = /^http(s)?:\/\/(.*?)\//
        let regIsLink = /^#/
        //logger.debug('oldLinks:',oldLinks.length)
        //oldLinks.forEach(function (element,i) {
            let currentUrl
            let currentDomain
            if(regIsLink.test(element)){
                //do nothing
                //resolve('illegal')
            }else {
                //
                if (element.match(regIsFullName) !== null) {
                    let m = element.match(regIsFullName)[0]
                    currentDomain = element.substr(0,m.length-1)
                    currentUrl = element.substr(m.length-1, element.length)
                } else {
                    currentDomain = domain
                    currentUrl = element
                }
                //let whichOne = {url: currentUrl, domain: currentDomain};
                //list.push(whichOne)
            }
            //去数据库里寻找是否有相同的队列
            if(currentDomain===domain&&currentUrl!==url&&/^\//.test(currentUrl)){
                longTimeDBClient.find({domain:currentDomain,url:currentUrl})
                    .toArray(function (err,res) {
                        if(res.length===0){
                            sitemapLinks.push({
                                domain: currentDomain,
                                url: currentUrl
                            })
                            longTimeDBClient.insertOne({domain:currentDomain,url:currentUrl},()=>{
                                //logger.debug(currentDomain+currentUrl+':入队成功')
                                resolve(currentDomain+currentUrl+':入队成功')
                            })
                        }else{
                            resolve(currentDomain+currentUrl+':重复文档')
                        }
                    })

            }else{
                resolve('illegal')
            }
       // })
    }

}

这个规则可以自己定义,这里就不赘述了。

代码:github

https://github.com/liuk5546/LinkCrawler

当然,这只是一个类似于练习稿的代码,如有错误,欢迎各位同行批评指正。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,651评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,468评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,931评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,218评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,234评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,198评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,084评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,926评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,341评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,563评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,731评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,430评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,036评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,676评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,829评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,743评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,629评论 2 354

推荐阅读更多精彩内容