一个细致入微的nodejs爬虫项目介绍(上)

为了完成作业以及让自己看上去没有真的在划水,决定开始写博客了。

*5.1:给代码们加上了分号,改掉了一些拼错的代码。

虽然说写博客这件事的出发点是为了交作业,但博客这种形式说到底是为了给别人看的,是为了尽可能让别人理解的。如果只是自顾自地讲,而不以“让别人理解”为目标,写博客这件事就沦为一种自我满足,其实也就没有什么意义了。所以,既然是要写出来,放到网上的,那么在介绍的过程中,我会尽可能指出所有可能产生疑惑的点,并尽可能还原我在项目过程中遇到的各种问题以及解决的思路(在保证脉络清晰的前提下)。以这种姿态来描述,这只是为了让更多像我这样的初学者明白我在说的是什么,而这也是让读者理解的前提。

这篇博客分成以下部分:

  • 项目介绍
  • 实现过程
  1. 模块引用
  • 1.1 Node.js模块系统介绍
  • 1.2 回调函数相关
  1. 爬取种子网页
  • 2.1 获取种子网页源码
  • 2.2 网页编码(中文乱码问题)
  • 2.3 手动分析源代码
  • 2.4 获取新闻网页URL
    • 2.4.1 cheerio模块
    • 2.4.2 完善URL
    • 2.4.3 用正则表达式筛选URL
  1. 爬取新闻网页
  • 3.1 数据存储方式
  • 3.2 cheerio选择器
  • 3.3 数据处理
  • 3.4 将数据保存到本地
  1. 代码完善和优化
  • 4.1 避免程序崩溃的方法
  • 4.2 代码模块化
  • 4.3 代码效率计算和优化

项目介绍

在这里插入图片描述
简而言之,把各种新闻网页的内容爬取到本地,再自己建一个网站,要有搜索和热度分析的功能。当然,作为入门项目,还有一个重要目的应该是通过这个项目来熟悉js、html语法,各种模块的用法以及语言特性吧。

篇幅原因,这里先介绍前半部分,即爬虫部分。

实现过程

1. 模块引用

1.1 Node.js模块系统介绍

模块通常定义了一些外部接口,我们可以通过调用模块内的成员函数实现需要的功能,这一点和类是相似的。

为了实现爬虫功能,通常要在代码头部用require函数引入request、cheerio、iconv-lite和fs四个模块,其中fs是node.js内置的,其他三个需要安装,安装命令如下:

npm install request cheerio iconv-lite

在代码头部引入模块:(各个模块的基本功能将随实现过程逐个介绍)

var myRequest = require('request');
var myIconv = require('iconv-lite');
var myCheerio = require('cheerio');
var fs = require('fs');

reference:https://www.runoob.com/nodejs/nodejs-module-system.html

1.2 回调函数相关

在开始爬取之前我想先讨论一下回调函数是怎么一回事。
比如说在调用request时,常见的形式是这样的

myRequest(url,function(err,res,body){
    ...
    ...
})

myRequest需要传入两个参数,第一个url是将要发送请求的网址,问题是第二个参数,它是一个函数,也就是所谓的回调函数(callback),初一看可能不太容易理解。

但是我们知道在编程语言中变量和函数是同级的。我们传入一个变量参数,是为了在需要的时候获取变量的值,那么类似地,传入一个函数参数,则是为了在需要的时候调用这个函数。不妨这样通俗地理解,我们提前规定了myRequest在获取了err,res,body三个变量的信息后应该执行的操作,把这一系列操作用回调函数的形式保存下来,那么myRequest就会在执行过程中按照要求执行回调函数了。(至于具体在什么时候执行,可能需要查看request模块的源码)

从作用上来说,回调函数直接体现了node.js异步编程的特性,能加快代码运行的速度。根据我的理解,因为执行回调函数的过程被视作为request的一部分,并且程序不必等request执行完毕就可以往后执行,那么回调函数的内容就是非阻塞的,对整体时间影响非常小。

reference:https://www.runoob.com/nodejs/nodejs-callback.html

2. 爬取种子网页

2.1 获取种子网页编码

如上所述,爬取网页内容使用的是request模块。request模块的第一个参数可以是一个对象,除了url属性是必须的,我们还可以根据需要添加其他的属性来控制爬取的方法,例如定义一个options对象,将其作为myRequest的参数

var options={
    url:myURL,      //设置目标网页的url
    encoding:null,  //设置编码方式,null即不进行编码,将编码工作交给iconv模块,详见2.2
    headers:headers,//设置header,用于防止爬虫被屏蔽,对于大多数网页可以缺省
    timeout:10000   //设置等待时间,单位为ms,超过等待时间err返回值为错误
}
myRequest(options,function(err,res,body){
    console.log(body);
}

这样一来,myRequest就能通过一些神奇的操作将网页的源码存储到body变量中了。如果在options中设置过编码方式为null,那么此时执行console.log(body)会看到一串神秘代码:

<Buffer 3c 21 44 4f 43 54 59 50 45 20 68 74 6d 6c 3e 3c 21 2d 2d 53 54 41 54 55 53 20 4f 4b 2d 2d 3e 0d 0a 3c 68 74 6d 6c 3e 0d 0a 3c 68 65 61 64 3e 0d 0a 09 ... 14565 more bytes>

这是进行编码前网页源码的形式,要进一步加工,首先要用iconv进行编码

2.2 网页编码(中文乱码问题)

要对网页进行编码,首先要确定目标网页的编码方式。最直接的方法是打开浏览器->进入目标网页->F12->Console控制台->输入document.charset即可查看。应该也可以从网页head标签中的charset中查看,但有些网站好像和实际不符合,所以还是用控制台比较好。
在这里插入图片描述
常见的编码方式有"UTF-8",“GBK”,"Unicode"等,在不设置编码方式的情况下,request默认以utf-8的方式编码,但request不支持GBK格式的编码,因此如果网页是GBK格式的,我们就需要用iconv来完成转码工作。

var myEncoding='UTF-8'  //设置为目标网页编码方式
...
myRequest(options,function(err,res,body){
    var html=myIconv.decode(body,myEncoding);
}

不同编码方式最主要的区别在于对于汉字的表示方式不同,utf-8编码属于国际标准,用三个字节表示汉字,而GBK编码只用两个字节,是专门用来解决中文编码的。如果用错误的方式进行编码,不仅中文汉字无法正常显示,整个源码的结构也会发生变化,导致源码不可读。

下图是用utf-8对GBK格式的网页编码的结果,汉字显示为乱码。
在这里插入图片描述
改成用GBK格式编码后汉字可以正常显示。
在这里插入图片描述

2.3 手动分析源码

获取到的源码包含了页面中的全部信息,往往很长,而我们需要的仅仅包含新闻链接的那几行代码,因此在开始爬取之前,我们首先要手动分析源码,观察新闻url的存储位置。好在网页的源码都遵循HTML语言的格式,并且大多数结构清晰。我们也可以在浏览器审查元素(F12)中,查看网页的每一个元素所对应的代码。当然,审查元素中的代码并不是源码,而是源码经过js渲染之后生成的,因此有些网页中会出现不一致的情况,此时应该以源码(右键->查看网页源代码)为准。

分析源码也就是分析它的层级结构。比如下图中的新闻url我们可以通过<a>标签href属性的值来定位url的位置,如果需要更准确的定位,可以把<div class=“xwzxdd-xbt”>也作为查找的条件。
在这里插入图片描述

2.4 获取新闻url

2.4.1 使用cheerio模块

cheerio模块囊括了对html页面的解析,分块,提取等多个功能,是实现爬虫功能最主要的工具。

cheerio模块用法很多也很灵活,由于我目前也只使用了其中的一些功能,不太理解它底层的实现原理,暂时只是把它作为一种工具去使用。避免误导,在此就不过多议论了。

由于cheerio和jQuery很多的用法是一样的,遇到问题时可以参考jQuery参考手册:https://www.w3school.com.cn/jquery/jquery_reference.asp 以及JS权威指南第19章的内容。

以下给出了一段获取新闻url的代码。

var $=myCheerio.load(html);    //解析html文件,将解析后的DOM结构存储在$中
var newsDiv=$('a');            //根据a标签进行分块
newsDiv.each(function(i,e){     //cheerio元素特有的遍历方式,回调函数中:i为计数器,e为当前元素
    news_url=$(e).attr('href');  //获取每个分块中的url链接,以字符串形式存入变量
});

2.4.2 完善URL

爬取到的新闻url的格式可能有以下几种,有些url需要完善之后才能使用:

以"//“开头的url,需要在前面加上"http:”

//channel.chinanews.com/cns/cl/yl-mxnd.shtml

以’/'开头的相对路径url,需要在前面加上种子页面的url

/gj/2020/04-17/9159939.shtml

一些可能没有实际意义的js代码,忽略即可

javascript:void(0)

完整的绝对路径url,可以直接使用

http://magazine.caijing.com.cn/20190605/4593837.shtml

完善url涉及到了JS中的字符串操作。我们可以使用.startsWith判断字符串是否以某子串为开头。以下给出一个示例:

if (news_url==="javascript:void(0)"||news_url===undefined) return;//如果url为"javascript:void(0)"或者不包含url,直接查看下一个
    if (news_url.startsWith("http://"))//分类三类情况完善url,将其转化为一个绝对路径
        news_url=news_url;
    else
        if (news_url.startsWith("//"))
            news_url="http:"+news_url;
        else
            if (news_url.startsWith("/"))
                news_url=myURL+news_url;

除了以上列举的几种,爬取到的url也可能出现别的情况,需要根据情况进行完善,尽可能将所有新闻url都转换成可用的格式。更多关于字符串操作的方法可以在参考手册中查询:https://www.w3school.com.cn/jsref/jsref_obj_string.asp

2.4.3 正则表达式筛选URL

种子页面除了新闻页面的url,也包含了很多其他网站的链接、广告链接等不需要的url,要筛选出需要的url,最方便的方法是利用正则表达式(一种用来匹配字符串的工具)。

首先还是手动分析url,找出其中最适合作为筛选条件的,具有特征的片段。

http://www.ecns.cn/news/2020-04-18/detail-ifzvpqct5600213.shtml
http://www.ecns.cn/video/2020-04-16/detail-ifzvpqct5598595.shtml
http://www.ecns.cn/hd/2020-04-17/detail-ifzvpqct5598937.shtml
http://www.ecns.cn/news/2020-04-18/detail-ifzvpqct5600277.shtml

新闻url种通常都会包括日期信息、和一串作为新闻id的字符串。
比如说选取"2020-04-18"的日期片段,可以看成

“数字*4” + “-” + “数字*2” + “-” + “数字*2”

用正则表达式的语法来表示则是

var news_reg=/\d{4}-\d{2}-\d{2}/;

又或者用“detail-ifzvpqct5600213”来筛选

“detail-” + “字母*8” + “数字*7”,用正则表达式表示一下:

var news_reg=/detail-\w{8}\d{7}/;

可供选择的正则表达式还有很多,检查标准是比较宽松的,只要确保不遗漏,不误筛就可以了。

使用正则表达式的test方法,如果为真,就可以把它作为新闻页面的url,开始爬取了。

if (news_reg.test(new_url))
    ··· //开始爬取新闻页面
    ···

reference: https://www.w3school.com.cn/jsref/jsref_obj_regexp.asp

3. 爬取新闻页面

3.1 数据存储方式

在开始爬取之前,先定义一个对象来保存信息,并将其中每一个属性都初始化为空串。

var Info={
    id:'',title:'',resource:'',author:'',editor:'',
    content:'',keywords:'',pubtime:'',fetchtime:'',url:''
    }
};

用一个对象来存储,在最后我们只需要导出这个对象,就可以导出所有的信息了。

3.2 cheerio选择器

对新闻页面的爬取过程其实与爬取种子页面完全是一个原理——用request模块获取页面信息,用iconv-lite模块完成转码,最后用cheerio解析、分块、提取。不同之处只在于,从提取一个信息(url)变成了要提取多个信息。

虽说如此,实际操作的时候,有些信息(比如title)我们可以简单地通过一个标签来获取,但有些信息的提取(比如content)可能就会变得格外复杂。(嗯。。尤其在爬取一些结构本来就不太清晰的新闻页面时)这时候就更需要好好分析源码结构,并且选择合适的cheerio选择器了。以下是一个给出一个根据源码对应爬取方式的示例(注释内是html源码,紧跟着对应的爬取方式):

//<h1 id="j_data" data-title="要爬取的内容"></h1>
Info.title=$('h1#j_data').attr('data-title');

//<div class="quote-content">
//  <a>要爬取的内容</a></div>
Info.resource=$('div.quote-content').children('a').text();

//<div class="subhead">要爬取的内容</div>
Info.author=$('div.subhead').text();

//<div class="quote-content">
//  <div>要爬取</div>
//  <p>的内容</p> </div>
Info.content=$('div.quote-content').children('div,p').text();

//<div class="basketballTobbs_tag">
//  <a>标签1</a>
//  <a>标签2</a>
//  <a>标签3</a> </div>
var keywordsDiv=$('div.basketballTobbs_tag').children('a');
keywordsDiv.each(function(i,e){
    Info.keywords=Info.keywords+','+$(e).text();
})//这样写是为了在每个标签后面加一个逗号

//<span class="stime">要爬取的内容</span>
//<span class="stime">不想要的内容</span>
//<span class="stime">不想要的内容</span>
Info.pubtime=$('span.stime').eq(0).text();//eq(0)来选择第一个元素

Info.fetchtime=new Date()
Info.fetchtime=Info.fetchtime.toFormat("YYYY-MM-DD-HH-MM-SS")//获取当前时间并转化格式(需要'date-utils')

当然,选择器的使用方式远不止这些,应该在实际使用中边做边掌握,遇到无法处理的情况可以查询cheerio(或者jQuery)的参考手册。

3.3 数据处理

到了这一步,所有需要从网页上爬取的内容都已保存在本地了,但通常还需要对信息做最后一步加工,删除不要的内容,删除其中的换行符、制表符等等,本质上是对字符串的处理,所以在这个过程中其实也能熟悉JS的各种字符串操作。这里介绍几种我遇到过的处理方法。

  • 1.直接替换:利用replace方法,直接用字符、字符串或正则表达式进行查找,用给定的字符替换或删除
Info.content=Info.content.replace(/[\n\r\t]/g,'');//删除每一个换行符和制表符
  • 2.先提取出要删除的子串,再用replace查找并替换掉
var temp=$('otitle').text();    //otitle标签中存储了文章的原标题
Info.content=Info.content.replace(temp,'');//删除原标题
  • 3.先用indexOf找到某标志字符串的起始位置,再用substring或slice方法把需要的内容截取出来
//Info.author="作者:xxx"
Info.author=Info.author.substring(Info.author.indexOf("作者:")+3);//如果"作者:"起始位置是x,那么作者姓名的起始位置就是x+3

最后的数据处理一般是花费时间最多的地方,毕竟每个网站新闻的格式都不同,甚至同一个种子网站下的两个新闻页面也可能是截然不同的,但这一步往往也决定了爬取数据质量的高低。数据的格式越规范、统一,那么在我们后续使用这些数据来搭建网站时就会相对越轻松。这就需要我们一点一点耐心地把关键信息过滤出来,去掉无意义的空格、符号,使用同样的分隔符(逗号or空格)。

3.4 将数据保存到本地

最后,只需要先设置文件名(json后缀),再用fs模块将Info对象保存到本地即可(默认和代码在同一个目录下)

var filename="Id"+Info.id+"_"+Info.fetchtime+'_'+resouce_website+".json";
fs.writeFileSync(filename,JSON.stringify(Info));

爬虫部分到此可以算完成了,但这个代码其实还有很多需要(或者说必须)完善和优化的地方。

4.代码完善和优化

4.1 避免程序崩溃的方法

嗯。。更优雅的说法是“提升代码稳定性”,然而我只是单纯地不想让它崩溃掉而已。。

首先,如果我们在程序中对一个没有定义过的(undefined)变量进行操作,或者将它作为一个参数,那么程序崩溃的可能性会非常大。怎么避免这种情况发生呢?

1.变量初始化:

对变量初始化是很重要的。比如执行以下代码:

var a;
var b='';
console.log(typeof a);
->undefined
console.log(typeof b);
->string

尽管变量b只是被赋值成了一个空串,但这一步初始化声明了它的类型是string,可以正常地作为一个字符串来使用。但如果把a作为字符串使用就会导致程序立刻崩溃。

2.随时检查error参数和response参数:

在很多回调函数中都有一个error参数,比如request模块:

request(url,function(err,response,body){
    ...
}

假如request对页面的申请失败了,error参数就会为真,同时,如果访问被跳转到其他页面,response.statusCode就不等于200了。而实际上,由于这是一个对网络发送的请求,单次访问失败的概率其实是非常高的。一旦访问失败,body参数就无法被正常赋值了,结果是undefined,如果我们继续操作下去,比如用iconv对body编码,程序也会立刻崩溃。所以爬虫代码中,对回调函数err参数的检查是必须的

request(url,function(err,response,body){
    if (err || response.statusCode!==200){
        console.log("页面访问出错~~")
        return;
    }
    ...
})

3.对存在报错可能性的代码,使用try…catch

这个写法的好处在于不仅可以避免程序崩溃,也可以显示出程序崩溃的原因,因此我觉得也可以作为一种不错的调试方法。

用法如下:

var flag=1;
try{
    flag=2;
    abcdefg;
    flag=3;
}
catch(err){
    console.log(err.message);
}
console.log(flag);
->abcdefg is not defined
->2

从flag的值也可以看出,一旦运行出错就不再往下执行,而是直接跳出try所在的代码块。

4.2 代码模块化

这里我想表达的是,尽管爬取不同网站、不同页面具体的方式不一样,但总归在大体框架上还是有很多相同之处的。那么,在爬取一个新网站时能否把相同的框架部分保留下来,只去修改特定的几处呢?

其中一个方法就是以字符串形式将爬取某个元素时的代码提前预设好,并用eval函数来执行代码,比如示例中的:

var keywords_format = "$('meta[name=\"keywords\"]').eq(0).attr(\"content\")"
var title_format = "$('title').text()"
var date_format = "$('#pubtime_baidu').text()"
var author_format = "$('#editor_baidu').text()"
var content_format = "$('.left_zw').text()"
var desc_format = " $('meta[name=\"description\"]').eq(0).attr(\"content\")"
var source_format = "$('#source_baidu').text()"
...
...
fetch.keywords = eval(keywords_format);
fetch.title = eval(title_format);
fetch.author = eval(author_format);
fetch.content = eval(content_format);
fetch.source = eval(source_format);
fetch.desc = eval(desc_format);

但是关于eval的使用似乎普遍有一定的争议。。而且很多时候一行代码也不足以提取出想要的元素,因此我更倾向于用一个函数来代替eval实现模块化,举个例子:

Info.title=get_title($)
Info.content=get_content($)
...
function get_title($){
    var title=''
    title=$('title').text()
    return title;
}
function get_content($){
    var content=''
    content=$('div#article_body').text();
    content=content.replace(/\n|\t|\r/g,'');
    content=content.replace(/点击播放 GIF  \d.\dM/g,'');
    content=content.replace(/视频:/g,'');
    tmp=$('div.jsx-4284531154.isom').text()
    if (tmp!=undefined)
        content=content.replace(tmp,'')
    return content;
}

也就是将$作为参数,将爬取和处理后的结果作为返回值。不同网站爬取方式的差异都只在在函数中体现,而原来的程序则不需要作任何的改动即可运行,整体框架会比较清晰。

4.3 代码效率计算和优化

这一部分是我比较疑惑的。。写代码的时候发现,有些功能可以用好几种不同的方法来实现,其中肯定存在效率上的差异。但是具体哪一种效率更高我也不知道。。虽然有些钻牛角尖,但还是希望能得到解答吧。

在解析种子页面的时候,进行分块操作之后进行遍历,准备提取url,有三种方法:

  1. 以cheerio元素e作为选择器
var newsDiv=$('a')  //分块
//用cheerio元素e作为选择器
newsDiv.each(function(i,e){
    news_url=$(e).attr('href')
}
  1. 以计数器i作为选择器,选择第i个元素
var newsDiv=$('a');  //分块
//以计数器i作为选择器,选择第i个元素
newsDiv.each(function(i,e){
    news_url=$('*').eq(i).attr('href');
}
  1. 重新解析元素e得到$_,再提取$_的属性
var newsDiv=$('a')  //分块
//用cheerio元素e作为选择器
newsDiv.each(function(i,e){
    var $_=myCheerio.load(e);
    news_url=$_('*').attr('href');
}

看别人的代码似乎第一种是最为普遍的写法。。但在我看来,第一种写法需要从整张页面匹配出整个DOM块,效率最低,第二种写法只需要匹配出第i个元素,效率比较快,第三种写法不需要每次都从整张页面去匹配,效率高,但时间可能会花费在对分块的再解析上。emmm说到底还是因为对cheerio模块的底层原理不了,所以完全不知道怎么计算三种写法的效率。

结语

最后还是再把几个链接发一下吧。。边做边查应该学起来是最快的

W3school的JS参考手册:https://www.w3school.com.cn/jsref/index.asp

RUNOOB的Node.js教程:https://www.runoob.com/nodejs/nodejs-tutorial.html

cheerio中文文档:https://www.jianshu.com/p/629a81b4e013

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值