首页 > Js/Jquery

一次使用NodeJS实现网页爬虫记

发表于2015-07-11 20:12:31| --次阅读| 来源webkfa| 作者js,javascript

摘要:前言几个月之前,有同事找我要PHP CI框架写的OA系统。他跟我说,他需要学习PHP CI框架,我建议他学习大牛写的国产优秀框架QeePHP。我上QeePHP官网,发现官方网站打不开了,GOOGLE了一番,发现QeePHP框架已经没人维护了。API文档资料都没有了,那可怎么办?毕竟QeePHP学习成...

前言

几个月之前,有同事找我要PHP CI框架写的OA系统。他跟我说,他需要学习PHP CI框架,我建议他学习大牛写的国产优秀框架QeePHP。

我上QeePHP官网,发现官方网站打不开了,GOOGLE了一番,发现QeePHP框架已经没人维护了。API文档资料都没有了,那可怎么办?

毕竟QeePHP学习成本挺高的。GOOGLE时,我发现已经有人把文档整理好,放在自己的个人网站上了。我在想:万一放文档的个人站点也挂了,

怎么办?还是保存到自己的电脑上比较保险。于是就想着用NodeJS写个爬虫抓取需要的文档到本地。后来抓取完成之后,干脆写了一个通用版本的,

可以抓取任意网站的内容。

 

爬虫原理
抓取初始URL的页面内容,提取URL列表,放入URL队列中,
从URL队列中取一个URL地址,抓取这个URL地址的内容,提取URL列表,放入URL队列中

。。。。。。
。。。。。。

 

NodeJS实现源码

1 /**
 * @desc 网页爬虫 抓取某个站点
 *
 * @todolist
 * URL队列很大时处理
 * 302跳转
 * 处理COOKIE
 * iconv-lite解决乱码
 * 大文件偶尔异常退出
 *
 * @author WadeYu
 * @date 2015-05-28
 * @copyright by WadeYu
 * @version 0.0.1
 */
 
/**
 * @desc 依赖的模块
 */
var fs = require("fs");
var http = require("http");
var https = require("https");
var urlUtil = require("url");
var pathUtil = require("path");

/**
 * @desc URL功能类
 */
var Url = function(){};

/**
 * @desc 修正被访问地址分析出来的URL 返回合法完整的URL地址
 *
 * @param string url 访问地址
 * @param string url2 被访问地址分析出来的URL
 *
 * @return string || boolean
 */
Url.prototype.fix = function(url,url2){
    if(!url || !url2){
        return false;
    }
    var oUrl = urlUtil.parse(url);
    if(!oUrl["protocol"] || !oUrl["host"] || !oUrl["pathname"]){//无效的访问地址
        return false;
    }
    if(url2.substring(0,2) === "//"){
        url2 = oUrl["protocol"]+url2;
    }
    var oUrl2 = urlUtil.parse(url2);
    if(oUrl2["host"]){
        if(oUrl2["hash"]){
            delete oUrl2["hash"];
        }
        return urlUtil.format(oUrl2);
    }
    var pathname = oUrl["pathname"];
    if(pathname.indexOf('/') > -1){
        pathname = pathname.substring(0,pathname.lastIndexOf('/'));
    }
    if(url2.charAt(0) === '/'){
        pathname = '';
    }
    url2 = pathUtil.normalize(url2); //修正 ./ 和 ../
    url2 = url2.replace(/\\/g,'/');
    while(url2.indexOf("../") > -1){ //修正以../开头的路径
        pathname = pathUtil.dirname(pathname);
        url2 = url2.substring(3);
    }
    if(url2.indexOf('#') > -1){
        url2 = url2.substring(0,url2.lastIndexOf('#'));
    } else if(url2.indexOf('?') > -1){
        url2 = url2.substring(0,url2.lastIndexOf('?'));
    }
    var oTmp = {
        "protocol": oUrl["protocol"],
        "host": oUrl["host"],
        "pathname": pathname + '/' + url2,
    };
    return urlUtil.format(oTmp);
};

/**
 * @desc 判断是否是合法的URL地址一部分
 *
 * @param string urlPart
 *
 * @return boolean
 */
Url.prototype.isValidPart = function(urlPart){
    if(!urlPart){
        return false;
    }
    if(urlPart.indexOf("javascript") > -1){
        return false;
    }
    if(urlPart.indexOf("mailto") > -1){
        return false;
    }
    if(urlPart.charAt(0) === '#'){
        return false;
    }
    if(urlPart === '/'){
        return false;
    }
    if(urlPart.substring(0,4) === "data"){//base64编码图片
        return false;
    }
    return true;
};

/**
 * @desc 获取URL地址 路径部分 不包含域名以及QUERYSTRING
 *
 * @param string url
 *
 * @return string
 */
Url.prototype.getUrlPath = function(url){
    if(!url){
        return '';
    }
    var oUrl = urlUtil.parse(url);
    if(oUrl["pathname"] && (/\/$/).test(oUrl["pathname"])){
        oUrl["pathname"] += "index.html";
    }
    if(oUrl["pathname"]){
        return oUrl["pathname"].replace(/^\/+/,'');
    }
    return '';
};
 

/**
 * @desc 文件内容操作类
 */
var File = function(obj){
    var obj = obj || {};
    this.saveDir = obj["saveDir"] ? obj["saveDir"] : ''; //文件保存目录
};

/**
 * @desc 内容存文件
 *
 * @param string filename 文件名
 * @param mixed content 内容
 * @param string charset 内容编码
 * @param Function cb 异步回调函数
 * @param boolean bAppend 
 *
 * @return boolean
 */
File.prototype.save = function(filename,content,charset,cb,bAppend){
    if(!content || !filename){
        return false;
    }
    var filename = this.fixFileName(filename);
    if(typeof cb !== "function"){
        var cb = function(err){
            if(err){
                console.log("内容保存失败 FILE:"+filename);
            }
        };
    }
    var sSaveDir = pathUtil.dirname(filename);
    var self = this;
    var cbFs = function(){
        var buffer = new Buffer(content,charset ? charset : "utf8");
        fs.open(filename, bAppend ? 'a' : 'w', 0666, function(err,fd){
            if (err){
                cb(err);
                return ;
            }
            var cb2 = function(err){
                cb(err);
                fs.close(fd);
            };
            fs.write(fd,buffer,0,buffer.length,0,cb2);
        });
    };
    fs.exists(sSaveDir,function(exists){
        if(!exists){
            self.mkdir(sSaveDir,"0666",function(){
                cbFs();
            });
        } else {
            cbFs();
        }
    });
};

/**
 * @desc 修正保存文件路径
 *
 * @param string filename 文件名
 *
 * @return string 返回完整的保存路径 包含文件名
 */
File.prototype.fixFileName = function(filename){
    if(pathUtil.isAbsolute(filename)){
        return filename;
    }
    if(this.saveDir){
        this.saveDir = this.saveDir.replace(/[\\/]$/,pathUtil.sep);
    }
    return this.saveDir + pathUtil.sep + filename;
};

/**
 * @递归创建目录
 *
 * @param string 目录路径
 * @param mode 权限设置
 * @param function 回调函数
 * @param string 父目录路径
 *
 * @return void
 */
File.prototype.mkdir = function(sPath,mode,fn,prefix){
    sPath = sPath.replace(/\\+/g,'/');
    var aPath = sPath.split('/');
    var prefix = prefix || '';
    var sPath = prefix + aPath.shift();
    var self = this;
    var cb = function(){
        fs.mkdir(sPath,mode,function(err){
            if ( (!err) || ( ([47,-4075]).indexOf(err["errno"]) > -1 ) ){ //创建成功或者目录已存在
                if (aPath.length > 0){
                    self.mkdir( aPath.join('/'),mode,fn, sPath.replace(/\/$/,'')+'/' );
                } else {
                    fn();
                }
            } else {
                console.log(err);
                console.log('创建目录:'+sPath+'失败');
            }
        });
    };
    fs.exists(sPath,function(exists){
        if(!exists){
            cb();
        } else if(aPath.length > 0){
            self.mkdir(aPath.join('/'),mode,fn, sPath.replace(/\/$/,'')+'/' );
        } else{
            fn();
        }
    });
};

/**
 * @递归删除目录 待完善 异步不好整
 *
 * @param string 目录路径
 * @param function 回调函数
 *
 * @return void
 */
File.prototype.rmdir = function(path,fn){
    var self = this;
    fs.readdir(path,function(err,files){
        if(err){
            if(err.errno == -4052){ //不是目录
                fs.unlink(path,function(err){
                    if(!err){
                        fn(path);
                    }
                });
            }
        } else if(files.length === 0){
            fs.rmdir(path,function(err){
                if(!err){
                    fn(path);
                }
            });
        }else {
            for(var i = 0; i < files.length; i++){
                self.rmdir(path+'/'+files[i],fn);
            }
        }
    });
};

/**
 * @desc 简单日期对象
 */
var oDate = {
    time:function(){//返回时间戳 毫秒
        return (new Date()).getTime();
    },
    date:function(fmt){//返回对应格式日期
        var oDate = new Date();
        var year = oDate.getFullYear();
        var fixZero = function(num){
            return num < 10 ? ('0'+num) : num;
        };
        var oTmp = {
            Y: year,
            y: (year+'').substring(2,4),
            m: fixZero(oDate.getMonth()+1),
            d: fixZero(oDate.getDate()),
            H: fixZero(oDate.getHours()),
            i: fixZero(oDate.getMinutes()),
            s: fixZero(oDate.getSeconds()),
        };
        for(var p in oTmp){
            if(oTmp.hasOwnProperty(p)){
                fmt = fmt.replace(p,oTmp[p]);
            }
        }
        return fmt;
    },
};

/**
 * @desc 未抓取过的URL队列
 */
var aNewUrlQueue = [];

/**
 * @desc 已抓取过的URL队列
 */
var aGotUrlQueue = [];

/**
 * @desc 统计
 */
var oCnt = {
    total:0,//抓取总数
    succ:0,//抓取成功数
    fSucc:0,//文件保存成功数
};

/**
 * 可能有问题的路径的长度 超过打监控日志
 */
var sPathMaxSize = 120;

/**
 * @desc 爬虫类
 */
var Robot = function(obj){
    var obj = obj || {};
    //所在域名
    this.domain = obj.domain || '';
    //抓取开始的第一个URL
    this.firstUrl = obj.firstUrl || '';
    //唯一标识
    this.id = this.constructor.incr();
    //内容落地保存路径
    this.saveDir = obj.saveDir || '';
    //是否开启调试功能
    this.debug = obj.debug || false;
    //第一个URL地址入未抓取队列
    if(this.firstUrl){
        aNewUrlQueue.push(this.firstUrl);
    }
    //辅助对象
    this.oUrl = new Url();
    this.oFile = new File({saveDir:this.saveDir});
};

/**
 * @desc 爬虫类私有方法---返回唯一爬虫编号
 *
 * @return int
 */
Robot.id = 1;
Robot.incr = function(){
    return this.id++;
};

/**
 * @desc 爬虫开始抓取
 *
 * @return boolean
 */
Robot.prototype.crawl = function(){
    if(aNewUrlQueue.length > 0){
        var url = aNewUrlQueue.pop();
        this.sendReq(url);
        oCnt.total++;
        aGotUrlQueue.push(url);
    } else {
        if(this.debug){
            console.log("抓取结束");
            console.log(oCnt);
        }
    }
    return true;
};

/**
 * @desc 发起HTTP请求
 *
 * @param string url URL地址
 *
 * @return boolean
 */
Robot.prototype.sendReq = function(url){
    var req = '';
    if(url.indexOf("https") > -1){
        req = https.request(url);
    } else {
        req = http.request(url);
    }
    var self = this;
    req.on('response',function(res){
        var aType = self.getResourceType(res.headers["content-type"]);
        var data = '';
        if(aType[2] !== "binary"){
            //res.setEncoding(aType[2] ? aType[2] : "utf8");//非支持的内置编码会报错
        } else {
            res.setEncoding("binary");
        }
        res.on('data',function(chunk){
            data += chunk;
        });
        res.on('end',function(){ //获取数据结束
            self.debug && console.log("抓取URL:"+url+"成功\n");
            self.handlerSuccess(data,aType,url);
            data = null;
        });
        res.on('error',function(){
            self.handlerFailure();
            self.debug && console.log("服务器端响应失败URL:"+url+"\n");
        });
    }).on('error',function(err){
        self.handlerFailure();
        self.debug && console.log("抓取URL:"+url+"失败\n");
    }).on('finish',function(){//调用END方法之后触发
        self.debug && console.log("开始抓取URL:"+url+"\n");
    });
    req.end();//发起请求
};

/**
 * @desc 提取HTML内容里的URL
 *
 * @param string html HTML文本
 *
 * @return []
 */
Robot.prototype.parseUrl = function(html){
    if(!html){
        return [];
    }
    var a = [];
    var aRegex = [
        /<a.*?href=['"]([^"']*)['"][^>]*>/gmi,
        /<script.*?src=['"]([^"']*)['"][^>]*>/gmi,
        /<link.*?href=['"]([^"']*)['"][^>]*>/gmi,
        /<img.*?src=['"]([^"']*)['"][^>]*>/gmi,
        /url\s*\([\\'"]*([^\(\)]+)[\\'"]*\)/gmi, //CSS背景
    ];
    html = html.replace(/[\n\r\t]/gm,'');
    for(var i = 0; i < aRegex.length; i++){
        do{
            var aRet = aRegex[i].exec(html);
            if(aRet){
                this.debug && this.oFile.save("_log/aParseUrl.log",aRet.join("\n")+"\n\n","utf8",function(){},true);
                a.push(aRet[1].trim().replace(/^\/+/,'')); //删除/是否会产生问题
            }
        }while(aRet);
    }
    return a;
};

/**
 * @desc 判断请求资源类型
 * 
 * @param string  Content-Type头内容
 *
 * @return [大分类,小分类,编码类型] ["image","png","utf8"]
 */
Robot.prototype.getResourceType = function(type){
    if(!type){
        return '';
    }
    var aType = type.split('/');
        aType.forEach(function(s,i,a){
            a[i] = s.toLowerCase();
        });
    if(aType[1] && (aType[1].indexOf(';') > -1)){
        var aTmp = aType[1].split(';');
        aType[1] = aTmp[0];
        for(var i = 1; i < aTmp.length; i++){
            if(aTmp[i] && (aTmp[i].indexOf("charset") > -1)){
                aTmp2 = aTmp[i].split('=');
                aType[2] = aTmp2[1] ? aTmp2[1].replace(/^\s+|\s+$/,'').replace('-','').toLowerCase() : '';
            }
        }
    }
    if((["image"]).indexOf(aType[0]) > -1){
        aType[2] = "binary";
    }
    return aType;
};

/**
 * @desc 抓取页面内容成功调用的回调函数
 *
 * @param string str 抓取的内容
 * @param [] aType 抓取内容类型
 * @param string url 请求的URL地址
 *
 * @return void
 */
Robot.prototype.handlerSuccess = function(str,aType,url){
    if((aType[0] === "text") && ((["css","html"]).indexOf(aType[1]) > -1)){ //提取URL地址
        aUrls = (url.indexOf(this.domain) > -1) ? this.parseUrl(str) : []; //非站内只抓取一次
        for(var i = 0; i < aUrls.length; i++){
            if(!this.oUrl.isValidPart(aUrls[i])){
                this.debug && this.oFile.save("_log/aInvalidRawUrl.log",url+"----"+aUrls[i]+"\n","utf8",function(){},true);
                continue;
            }
            var sUrl = this.oUrl.fix(url,aUrls[i]);
            /*if(sUrl.indexOf(this.domain) === -1){ //只抓取站点内的 这里判断会过滤掉静态资源
                continue;
            }*/
            if(aNewUrlQueue.indexOf(sUrl) > -1){
                continue;
            }
            if(aGotUrlQueue.indexOf(sUrl) > -1){
                continue;
            }
            aNewUrlQueue.push(sUrl);
        }
    }
    //内容存文件
    var sPath = this.oUrl.getUrlPath(url);
    var self = this;
    var oTmp = urlUtil.parse(url);
    if(oTmp["hostname"]){//路径包含域名 防止文件保存时因文件名相同被覆盖
        sPath = sPath.replace(/^\/+/,'');
        sPath = oTmp["hostname"]+pathUtil.sep+sPath;
    }
    if(sPath){ 
        if(this.debug){
            this.oFile.save("_log/urlFileSave.log",url+"--------"+sPath+"\n","utf8",function(){},true);
        }
        if(sPath.length > sPathMaxSize){ //可能有问题的路径 打监控日志
            this.oFile.save("_log/sPathMaxSizeOverLoad.log",url+"--------"+sPath+"\n","utf8",function(){},true);
            return ;
        }
        if(aType[2] != "binary"){//只支持UTF8编码
            aType[2] = "utf8";
        }
        this.oFile.save(sPath,str,aType[2] ? aType[2] : "utf8",function(err){
            if(err){
                self.debug && console.log("Path:"+sPath+"存文件失败");
            } else {
                oCnt.fSucc++;
            }
        });
    }
    oCnt.succ++;
    this.crawl();//继续抓取
};

/**
 * @desc 抓取页面失败调用的回调函数
 *
 * @return void
 */
Robot.prototype.handlerFailure = function(){
    this.crawl();
};

/**
 * @desc 外部引用
 */
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

 

相关文章

猜你喜欢

学到老在线代码浏览器 关闭浏览
友情链接: hao123 360导航 搜狗网址导航 114啦网址导航 博客大全
Copyright © 1999-2014, WEBKFA.COM, All Rights Reserved  京ICP备14034497号-1