前言
几个月之前,有同事找我要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) || ( ([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;
调用
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
时间: 2024-10-14 15:44:21