你的位置:首页 > Java教程

[Java教程]一次使用NodeJS实现网页爬虫记

前言

几个月之前,有同事找我要PHP CI框架写的OA系统。他跟我说,他需要学习PHP CI框架,我建议他学习大牛写的国产优秀框架QeePHP。

我上QeePHP官网,发现官方网站打不开了,GOOGLE了一番,发现QeePHP框架已经没人维护了。API文档资料都没有了,那可怎么办?

毕竟QeePHP学习成本挺高的。GOOGLE时,我发现已经有人把文档整理好,放在自己的个人网站上了。我在想:万一放文档的个人站点也挂了,

怎么办?还是保存到自己的电脑上比较保险。于是就想着用NodeJS写个爬虫抓取需要的文档到本地。后来抓取完成之后,干脆写了一个通用版本的,

可以抓取任意网站的内容。

 

爬虫原理
抓取初始URL的页面内容,提取URL列表,放入URL队列中,
从URL队列中取一个URL地址,抓取这个URL地址的内容,提取URL列表,放入URL队列中

。。。。。。
。。。。。。

 

NodeJS实现源码

 1 /** 2  * @desc 网页爬虫 抓取某个站点 3  * 4  * @todolist 5  * URL队列很大时处理 6  * 302跳转 7  * 处理COOKIE 8  * iconv-lite解决乱码 9  * 大文件偶尔异常退出 10  * 11  * @author WadeYu 12  * @date 2015-05-28 13  * @copyright by WadeYu 14  * @version 0.0.1 15 */ 16  17 /** 18  * @desc 依赖的模块 19 */ 20 var fs = require("fs"); 21 var http = require("http"); 22 var https = require("https"); 23 var urlUtil = require("url"); 24 var pathUtil = require("path"); 25  26 /** 27  * @desc URL功能类 28 */ 29 var Url = function(){}; 30  31 /** 32  * @desc 修正被访问地址分析出来的URL 返回合法完整的URL地址 33  * 34  * @param string url 访问地址 35  * @param string url2 被访问地址分析出来的URL 36  * 37  * @return string || boolean 38 */ 39 Url.prototype.fix = function(url,url2){ 40   if(!url || !url2){ 41     return false; 42   } 43   var oUrl = urlUtil.parse(url); 44   if(!oUrl["protocol"] || !oUrl["host"] || !oUrl["pathname"]){//无效的访问地址 45     return false; 46   } 47   if(url2.substring(0,2) === "//"){ 48     url2 = oUrl["protocol"]+url2; 49   } 50   var oUrl2 = urlUtil.parse(url2); 51   if(oUrl2["host"]){ 52     if(oUrl2["hash"]){ 53       delete oUrl2["hash"]; 54     } 55     return urlUtil.format(oUrl2); 56   } 57   var pathname = oUrl["pathname"]; 58   if(pathname.indexOf('/') > -1){ 59     pathname = pathname.substring(0,pathname.lastIndexOf('/')); 60   } 61   if(url2.charAt(0) === '/'){ 62     pathname = ''; 63   } 64   url2 = pathUtil.normalize(url2); //修正 ./ 和 ../ 65   url2 = url2.replace(/\\/g,'/'); 66   while(url2.indexOf("../") > -1){ //修正以../开头的路径 67     pathname = pathUtil.dirname(pathname); 68     url2 = url2.substring(3); 69   } 70   if(url2.indexOf('#') > -1){ 71     url2 = url2.substring(0,url2.lastIndexOf('#')); 72   } else if(url2.indexOf('?') > -1){ 73     url2 = url2.substring(0,url2.lastIndexOf('?')); 74   } 75   var oTmp = { 76     "protocol": oUrl["protocol"], 77     "host": oUrl["host"], 78     "pathname": pathname + '/' + url2, 79   }; 80   return urlUtil.format(oTmp); 81 }; 82  83 /** 84  * @desc 判断是否是合法的URL地址一部分 85  * 86  * @param string urlPart 87  * 88  * @return boolean 89 */ 90 Url.prototype.isValidPart = function(urlPart){ 91   if(!urlPart){ 92     return false; 93   } 94   if(urlPart.indexOf("javascript") > -1){ 95     return false; 96   } 97   if(urlPart.indexOf("mailto") > -1){ 98     return false; 99   }100   if(urlPart.charAt(0) === '#'){101     return false;102   }103   if(urlPart === '/'){104     return false;105   }106   if(urlPart.substring(0,4) === "data"){//base64编码图片107     return false;108   }109   return true;110 };111 112 /**113  * @desc 获取URL地址 路径部分 不包含域名以及QUERYSTRING114  *115  * @param string url116  *117  * @return string118 */119 Url.prototype.getUrlPath = function(url){120   if(!url){121     return '';122   }123   var oUrl = urlUtil.parse(url);124   if(oUrl["pathname"] && (/\/$/).test(oUrl["pathname"])){125     oUrl["pathname"] += "index.html";126   }127   if(oUrl["pathname"]){128     return oUrl["pathname"].replace(/^\/+/,'');129   }130   return '';131 };132 133 134 /**135  * @desc 文件内容操作类136 */137 var File = function(obj){138   var obj = obj || {};139   this.saveDir = obj["saveDir"] ? obj["saveDir"] : ''; //文件保存目录140 };141 142 /**143  * @desc 内容存文件144  *145  * @param string filename 文件名146  * @param mixed content 内容147  * @param string charset 内容编码148  * @param Function cb 异步回调函数149  * @param boolean bAppend 150  *151  * @return boolean152 */153 File.prototype.save = function(filename,content,charset,cb,bAppend){154   if(!content || !filename){155     return false;156   }157   var filename = this.fixFileName(filename);158   if(typeof cb !== "function"){159     var cb = function(err){160       if(err){161         console.log("内容保存失败 FILE:"+filename);162       }163     };164   }165   var sSaveDir = pathUtil.dirname(filename);166   var self = this;167   var cbFs = function(){168     var buffer = new Buffer(content,charset ? charset : "utf8");169     fs.open(filename, bAppend ? 'a' : 'w', 0666, function(err,fd){170       if (err){171         cb(err);172         return ;173       }174       var cb2 = function(err){175         cb(err);176         fs.close(fd);177       };178       fs.write(fd,buffer,0,buffer.length,0,cb2);179     });180   };181   fs.exists(sSaveDir,function(exists){182     if(!exists){183       self.mkdir(sSaveDir,"0666",function(){184         cbFs();185       });186     } else {187       cbFs();188     }189   });190 };191 192 /**193  * @desc 修正保存文件路径194  *195  * @param string filename 文件名196  *197  * @return string 返回完整的保存路径 包含文件名198 */199 File.prototype.fixFileName = function(filename){200   if(pathUtil.isAbsolute(filename)){201     return filename;202   }203   if(this.saveDir){204     this.saveDir = this.saveDir.replace(/[\\/]$/,pathUtil.sep);205   }206   return this.saveDir + pathUtil.sep + filename;207 };208 209 /**210  * @递归创建目录211  *212  * @param string 目录路径213  * @param mode 权限设置214  * @param function 回调函数215  * @param string 父目录路径216  *217  * @return void218 */219 File.prototype.mkdir = function(sPath,mode,fn,prefix){220   sPath = sPath.replace(/\\+/g,'/');221   var aPath = sPath.split('/');222   var prefix = prefix || '';223   var sPath = prefix + aPath.shift();224   var self = this;225   var cb = function(){226     fs.mkdir(sPath,mode,function(err){227       if ( (!err) || ( ([47,-4075]).indexOf(err["errno"]) > -1 ) ){ //创建成功或者目录已存在228         if (aPath.length > 0){229           self.mkdir( aPath.join('/'),mode,fn, sPath.replace(/\/$/,'')+'/' );230         } else {231           fn();232         }233       } else {234         console.log(err);235         console.log('创建目录:'+sPath+'失败');236       }237     });238   };239   fs.exists(sPath,function(exists){240     if(!exists){241       cb();242     } else if(aPath.length > 0){243       self.mkdir(aPath.join('/'),mode,fn, sPath.replace(/\/$/,'')+'/' );244     } else{245       fn();246     }247   });248 };249 250 /**251  * @递归删除目录 待完善 异步不好整252  *253  * @param string 目录路径254  * @param function 回调函数255  *256  * @return void257 */258 File.prototype.rmdir = function(path,fn){259   var self = this;260   fs.readdir(path,function(err,files){261     if(err){262       if(err.errno == -4052){ //不是目录263         fs.unlink(path,function(err){264           if(!err){265             fn(path);266           }267         });268       }269     } else if(files.length === 0){270       fs.rmdir(path,function(err){271         if(!err){272           fn(path);273         }274       });275     }else {276       for(var i = 0; i < files.length; i++){277         self.rmdir(path+'/'+files[i],fn);278       }279     }280   });281 };282 283 /**284  * @desc 简单日期对象285 */286 var oDate = {287   time:function(){//返回时间戳 毫秒288     return (new Date()).getTime();289   },290   date:function(fmt){//返回对应格式日期291     var oDate = new Date();292     var year = oDate.getFullYear();293     var fixZero = function(num){294       return num < 10 ? ('0'+num) : num;295     };296     var oTmp = {297       Y: year,298       y: (year+'').substring(2,4),299       m: fixZero(oDate.getMonth()+1),300       d: fixZero(oDate.getDate()),301       H: fixZero(oDate.getHours()),302       i: fixZero(oDate.getMinutes()),303       s: fixZero(oDate.getSeconds()),304     };305     for(var p in oTmp){306       if(oTmp.hasOwnProperty(p)){307         fmt = fmt.replace(p,oTmp[p]);308       }309     }310     return fmt;311   },312 };313 314 /**315  * @desc 未抓取过的URL队列316 */317 var aNewUrlQueue = [];318 319 /**320  * @desc 已抓取过的URL队列321 */322 var aGotUrlQueue = [];323 324 /**325  * @desc 统计326 */327 var oCnt = {328   total:0,//抓取总数329   succ:0,//抓取成功数330   fSucc:0,//文件保存成功数331 };332 333 /**334  * 可能有问题的路径的长度 超过打监控日志335 */336 var sPathMaxSize = 120;337 338 /**339  * @desc 爬虫类340 */341 var Robot = function(obj){342   var obj = obj || {};343   //所在域名344   this.domain = obj.domain || '';345   //抓取开始的第一个URL346   this.firstUrl = obj.firstUrl || '';347   //唯一标识348   this.id = this.constructor.incr();349   //内容落地保存路径350   this.saveDir = obj.saveDir || '';351   //是否开启调试功能352   this.debug = obj.debug || false;353   //第一个URL地址入未抓取队列354   if(this.firstUrl){355     aNewUrlQueue.push(this.firstUrl);356   }357   //辅助对象358   this.oUrl = new Url();359   this.oFile = new File({saveDir:this.saveDir});360 };361 362 /**363  * @desc 爬虫类私有方法---返回唯一爬虫编号364  *365  * @return int366 */367 Robot.id = 1;368 Robot.incr = function(){369   return this.id++;370 };371 372 /**373  * @desc 爬虫开始抓取374  *375  * @return boolean376 */377 Robot.prototype.crawl = function(){378   if(aNewUrlQueue.length > 0){379     var url = aNewUrlQueue.pop();380     this.sendReq(url);381     oCnt.total++;382     aGotUrlQueue.push(url);383   } else {384     if(this.debug){385       console.log("抓取结束");386       console.log(oCnt);387     }388   }389   return true;390 };391 392 /**393  * @desc 发起HTTP请求394  *395  * @param string url URL地址396  *397  * @return boolean398 */399 Robot.prototype.sendReq = function(url){400   var req = '';401   if(url.indexOf("https") > -1){402     req = https.request(url);403   } else {404     req = http.request(url);405   }406   var self = this;407   req.on('response',function(res){408     var aType = self.getResourceType(res.headers["content-type"]);409     var data = '';410     if(aType[2] !== "binary"){411       //res.setEncoding(aType[2] ? aType[2] : "utf8");//非支持的内置编码会报错412     } else {413       res.setEncoding("binary");414     }415     res.on('data',function(chunk){416       data += chunk;417     });418     res.on('end',function(){ //获取数据结束419       self.debug && console.log("抓取URL:"+url+"成功\n");420       self.handlerSuccess(data,aType,url);421       data = null;422     });423     res.on('error',function(){424       self.handlerFailure();425       self.debug && console.log("服务器端响应失败URL:"+url+"\n");426     });427   }).on('error',function(err){428     self.handlerFailure();429     self.debug && console.log("抓取URL:"+url+"失败\n");430   }).on('finish',function(){//调用END方法之后触发431     self.debug && console.log("开始抓取URL:"+url+"\n");432   });433   req.end();//发起请求434 };435 436 /**437  * @desc 提取HTML内容里的URL438  *439  * @param string html HTML文本440  *441  * @return []442 */443 Robot.prototype.parseUrl = function(html){444   if(!html){445     return [];446   }447   var a = [];448   var aRegex = [449     /<a.*?href=['"]([^"']*)['"][^>]*>/gmi,450     /<script.*?src=['"]([^"']*)['"][^>]*>/gmi,451     /<link.*?href=['"]([^"']*)['"][^>]*>/gmi,452     /<img.*?src=['"]([^"']*)['"][^>]*>/gmi,453     /url\s*\([\\'"]*([^\(\)]+)[\\'"]*\)/gmi, //CSS背景454   ];455   html = html.replace(/[\n\r\t]/gm,'');456   for(var i = 0; i < aRegex.length; i++){457     do{458       var aRet = aRegex[i].exec(html);459       if(aRet){460         this.debug && this.oFile.save("_log/aParseUrl.log",aRet.join("\n")+"\n\n","utf8",function(){},true);461         a.push(aRet[1].trim().replace(/^\/+/,'')); //删除/是否会产生问题462       }463     }while(aRet);464   }465   return a;466 };467 468 /**469  * @desc 判断请求资源类型470  * 471  * @param string Content-Type头内容472  *473  * @return [大分类,小分类,编码类型] ["image","png","utf8"]474 */475 Robot.prototype.getResourceType = function(type){476   if(!type){477     return '';478   }479   var aType = type.split('/');480     aType.forEach(function(s,i,a){481       a[i] = s.toLowerCase();482     });483   if(aType[1] && (aType[1].indexOf(';') > -1)){484     var aTmp = aType[1].split(';');485     aType[1] = aTmp[0];486     for(var i = 1; i < aTmp.length; i++){487       if(aTmp[i] && (aTmp[i].indexOf("charset") > -1)){488         aTmp2 = aTmp[i].split('=');489         aType[2] = aTmp2[1] ? aTmp2[1].replace(/^\s+|\s+$/,'').replace('-','').toLowerCase() : '';490       }491     }492   }493   if((["image"]).indexOf(aType[0]) > -1){494     aType[2] = "binary";495   }496   return aType;497 };498 499 /**500  * @desc 抓取页面内容成功调用的回调函数501  *502  * @param string str 抓取的内容503  * @param [] aType 抓取内容类型504  * @param string url 请求的URL地址505  *506  * @return void507 */508 Robot.prototype.handlerSuccess = function(str,aType,url){509   if((aType[0] === "text") && ((["css","html"]).indexOf(aType[1]) > -1)){ //提取URL地址510     aUrls = (url.indexOf(this.domain) > -1) ? this.parseUrl(str) : []; //非站内只抓取一次511     for(var i = 0; i < aUrls.length; i++){512       if(!this.oUrl.isValidPart(aUrls[i])){513         this.debug && this.oFile.save("_log/aInvalidRawUrl.log",url+"----"+aUrls[i]+"\n","utf8",function(){},true);514         continue;515       }516       var sUrl = this.oUrl.fix(url,aUrls[i]);517       /*if(sUrl.indexOf(this.domain) === -1){ //只抓取站点内的 这里判断会过滤掉静态资源518         continue;519       }*/520       if(aNewUrlQueue.indexOf(sUrl) > -1){521         continue;522       }523       if(aGotUrlQueue.indexOf(sUrl) > -1){524         continue;525       }526       aNewUrlQueue.push(sUrl);527     }528   }529   //内容存文件530   var sPath = this.oUrl.getUrlPath(url);531   var self = this;532   var oTmp = urlUtil.parse(url);533   if(oTmp["hostname"]){//路径包含域名 防止文件保存时因文件名相同被覆盖534     sPath = sPath.replace(/^\/+/,'');535     sPath = oTmp["hostname"]+pathUtil.sep+sPath;536   }537   if(sPath){ 538     if(this.debug){539       this.oFile.save("_log/urlFileSave.log",url+"--------"+sPath+"\n","utf8",function(){},true);540     }541     if(sPath.length > sPathMaxSize){ //可能有问题的路径 打监控日志542       this.oFile.save("_log/sPathMaxSizeOverLoad.log",url+"--------"+sPath+"\n","utf8",function(){},true);543       return ;544     }545     if(aType[2] != "binary"){//只支持UTF8编码546       aType[2] = "utf8";547     }548     this.oFile.save(sPath,str,aType[2] ? aType[2] : "utf8",function(err){549       if(err){550         self.debug && console.log("Path:"+sPath+"存文件失败");551       } else {552         oCnt.fSucc++;553       }554     });555   }556   oCnt.succ++;557   this.crawl();//继续抓取558 };559 560 /**561  * @desc 抓取页面失败调用的回调函数562  *563  * @return void564 */565 Robot.prototype.handlerFailure = function(){566   this.crawl();567 };568 569 /**570  * @desc 外部引用571 */572 module.exports = Robot;

View Code

 

调用

var Robot = require("./robot.js");var oOptions = {	domain:'baidu.com', //抓取网站的域名	firstUrl:'http://www.baidu.com/', //抓取的初始URL地址	saveDir:"E:\\wwwroot/baidu/", //抓取内容保存目录	debug:true, //是否开启调试模式};var o = new Robot(oOptions);o.crawl(); //开始抓取

 

后记
还有些地方需要完善
1.处理302跳转
2.处理COOKIE登陆
3.大文件偶尔会非正常退出
4.使用多进程
5.完善URL队列管理

6.异常退出之后处理

实现过程中碰到了一些问题,最后还是解决了,
爬虫原理很简单,只有真正实现过,才会对它更加理解,
原来实现不是那么简单,也是需要花时间的。


参考资料
[1]NodeJS
https://nodejs.org/
[2]Nodejs抓取非utf8字符编码的页面
http://www.cnblogs.com/fengmk2/archive/2011/05/15/2047109.html
[3]iconv-lite编码解码
https://www.npmjs.com/package/iconv-lite