2018-06-05
谈一谈简单的js爬虫
基本概念
网络爬虫的两个主要任务就是:
- 下载页面
- 找页面中的链接
使用到的第三方包
"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&¤tUrl!==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
当然,这只是一个类似于练习稿的代码,如有错误,欢迎各位同行批评指正。