|
前言
几个月之前,有同事找我要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,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.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 = 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 && (aTmp.indexOf("charset") > -1)){
488 aTmp2 = aTmp.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)){
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[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]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 |
|