一次使用NodeJS實現網頁爬蟲記

前言javascript

幾個月以前,有同事找我要PHP CI框架寫的OA系統。他跟我說,他須要學習PHP CI框架,我建議他學習大牛寫的國產優秀框架QeePHP。css

我上QeePHP官網,發現官方網站打不開了,GOOGLE了一番,發現QeePHP框架已經沒人維護了。API文檔資料都沒有了,那可怎麼辦?html

畢竟QeePHP學習成本挺高的。GOOGLE時,我發現已經有人把文檔整理好,放在本身的我的網站上了。我在想:萬一放文檔的我的站點也掛了,java

怎麼辦?仍是保存到本身的電腦上比較保險。因而就想着用NodeJS寫個爬蟲抓取須要的文檔到本地。後來抓取完成以後,乾脆寫了一個通用版本的,node

能夠抓取任意網站的內容。c++

 

爬蟲原理
抓取初始URL的頁面內容,提取URL列表,放入URL隊列中,
從URL隊列中取一個URL地址,抓取這個URL地址的內容,提取URL列表,放入URL隊列中git

。。。。。。
。。。。。。github

 

NodeJS實現源碼npm

  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) || ( ([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 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[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     //抓取開始的第一個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[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內容裏的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[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 void
507  */
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 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


參考資料[1]NodeJShttps://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

相關文章
相關標籤/搜索