jericho0702 发表于 2017-2-22 11:15:18

一次使用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地址 路径部分 不包含域名以及QUERYSTRING
114*
115* @param string url
116*
117* @return string
118*/
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 boolean
152*/
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 void
218*/
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) || ( ().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 void
257*/
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,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);
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   //抓取开始的第一个URL
346   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 int
366*/
367 Robot.id = 1;
368 Robot.incr = function(){
369   return this.id++;
370 };
371
372 /**
373* @desc 爬虫开始抓取
374*
375* @return boolean
376*/
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 boolean
398*/
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 !== "binary"){
411             //res.setEncoding(aType ? aType : "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内容里的URL
438*
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.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.trim().replace(/^\/+/,'')); //删除/是否会产生问题
462             }
463         }while(aRet);
464   }
465   return a;
466 };
467
468 /**
469* @desc 判断请求资源类型
470*
471* @param stringContent-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 = s.toLowerCase();
482         });
483   if(aType && (aType.indexOf(';') > -1)){
484         var aTmp = aType.split(';');
485         aType = aTmp;
486         for(var i = 1; i < aTmp.length; i++){
487             if(aTmp && (aTmp.indexOf("charset") > -1)){
488               aTmp2 = aTmp.split('=');
489               aType = aTmp2 ? aTmp2.replace(/^\s+|\s+$/,'').replace('-','').toLowerCase() : '';
490             }
491         }
492   }
493   if((["image"]).indexOf(aType) > -1){
494         aType = "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 void
507*/
508 Robot.prototype.handlerSuccess = function(str,aType,url){
509   if((aType === "text") && ((["css","html"]).indexOf(aType) > -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)){
513               this.debug && this.oFile.save("_log/aInvalidRawUrl.log",url+"----"+aUrls+"\n","utf8",function(){},true);
514               continue;
515             }
516             var sUrl = this.oUrl.fix(url,aUrls);
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 != "binary"){//只支持UTF8编码
546             aType = "utf8";
547         }
548         this.oFile.save(sPath,str,aType ? aType : "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 void
564*/
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.异常退出之后处理
  实现过程中碰到了一些问题,最后还是解决了,
爬虫原理很简单,只有真正实现过,才会对它更加理解,
原来实现不是那么简单,也是需要花时间的。
  7.下载地址: https://codeload.github.com/wadeyu/nodejsrobot/zip/master
  参考资料
NodeJS
https://nodejs.org/
Nodejs抓取非utf8字符编码的页面
http://www.cnblogs.com/fengmk2/archive/2011/05/15/2047109.html
iconv-lite编码解码
https://www.npmjs.com/package/iconv-lite
页: [1]
查看完整版本: 一次使用NodeJS实现网页爬虫记