前言
几个月之前,有同事找我要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='/images/loading.gif' data-original=['"]([^"']*)['"][^>]*>/gmi,451 /<link.*?href=['"]([^"']*)['"][^>]*>/gmi,452 /<img.*?src='/images/loading.gif' data-original=['"]([^"']*)['"][^>]*>/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
原标题:一次使用NodeJS实现网页爬虫记
关键词:JS