当前位置: 首页 > 后端技术 > Node.js

一个节点爬虫的升级打怪之路

时间:2023-04-03 13:51:02 Node.js

一直认为爬虫是很多web开发者绕不开的一个点。我们也应该或多或少接触到这方面的东西,因为我们可以从爬虫身上学到一些web开发应该掌握的基础知识。而且,这很有趣。我是知乎的轻度和重度用户。我写了一个爬虫来帮助我爬取和分析它的数据。我觉得这个过程挺有意思的,因为这是一个不断给自己制造问题,然后解决问题的过程。遇到了一些点,今天总结一下,分享给大家。它爬了什么?先简单介绍一下我的爬虫。它可以定时捕捉某个问题的关注度、浏览量和答案,这样我就可以把这些数据绘制成图表来展示它的热点趋势。为了不让我错过一些热门事件,它会定期在我关心的话题下获取热门问答,并推送到我的邮箱。作为一名前端开发者,我必须为这个爬虫系统创建一个接口,它允许我登录我的知乎账号,添加我关心的主题和主题,并看到可视化数据。所以这个爬虫也有登录知乎和搜索话题的功能。然后看界面。下面就认真的说说它的发展历程。技术选择Python以其简单快速的语法和丰富的爬虫库一直是爬虫开发者的首选。不幸的是我不熟悉它。当然,最重要的是,作为前端开发者,如果node能够满足爬虫的需求,自然是首选。而且随着node的发展,也出现了很多好用的爬虫库,甚至还有puppeteer这种可以直接模拟Chrome访问网页的工具。Node在爬虫方面应该可以很好的满足我所有的爬虫需求。所以我选择从零开始搭建一个基于koa2的服务器。为什么不直接选择egg、express、thinkjs等更全面的框架呢?因为我爱折腾。这也是一个学习过程。如果你之前不了解node,并且有兴趣搭建一个node服务器,可以阅读我之前的文章——从零开始搭建Koa2Server。对于爬虫,我选择了request+cheerio。知乎虽然很多地方用到了react,但得益于它的大部分页面还是由服务端渲染,只要我能请求网页和接口(request),解析页面(cherrio)就可以满足我的爬虫需求。其他的我就不一一列举了。我会列出一个技术栈服务器koajs作为节点服务器框架;request+cheerio作为爬虫服务;mongodb作为数据存储;node-schedule作为任务调度;nodemailer作为电子邮件推送。客户端vuejs前端框架;museui材料设计UI库;chart.js图表库。技术选好后,我们还要关心业务。第一个任务是实际抓取页面。如何爬取网站的数据?知乎没有开放接口供用户获取数据,所以想要获取数据就得自己去爬取网页信息。我们知道,即使是网页,本质上也是一个GET请求接口。我们只需要在服务器端请求对应网页的地址即可(客户端请求会跨域),然后解析html结构得到想要的数据。.那我为什么要登录呢?由于您不是登录账号获取信息,知乎只会展示有限的数据,您将无法获知您的知乎账号关心的话题、问题等。而如果你想让自己的系统被其他小伙伴使用,你还必须建立一个账号系统。模拟登录,大家会使用Chrome等现代浏览器查看请求信息。我们在知乎的登录页面登录,然后查看抓取的接口信息可知,登录无非就是将账号、密码等信息发送给一个登录API。如果成功。服务器会设置一个cookie给客户端,也就是登录凭证。所以我们的思路也是一样的,通过爬虫服务器请求接口,带上我们的账号密码信息,成功之后把返回的cookie保存到我们的系统数据库中,后面爬取其他页面的时候带上这个cookie就可以了。当然,真正去尝试的时候,我们会遭遇更多的挫折,因为我们会遇到token、验证码等问题。不过既然我们有了客户端,就可以把验证码的识别交给真人而不是服务器去解析图片字符,这样就降低了我们登录的难度。曲折的是,即使提交了正确的验证码,还是会提示验证码错误。如果我们自己做过验证码提交系统,可以很快定位到原因。如果没有,我们可以再查看一下登录涉及的request和response,我们也可以猜测:当客户端获取到验证码时,知乎服务器也会给客户端设置一个新的cookie。在提交登录请求时,您必须连同此cookie一起提交验证码,以验证本次提交的验证码确实是当时给用户的验证码。语言描述有点绕,我用图的形式表达了一次登录请求的完整过程。注:我写爬虫的时候,知乎也部分采用了图片字符验证码,现在已经全部改为“点击倒排文字”的形式了。这样会增加提交正确验证码的难度,但也不是不可以。获取到图片后,手动识别并点击倒排文字,将点击的坐标提交到登录界面。当然有兴趣有能力的同学也可以自己写算法来识别验证码。在上一步的爬取数据中,我们已经获取到了登录后的凭证cookie。用户登录成功后,我们将登录的账户信息及其凭证cookie保存在mongo中。以后该用户发起的爬取需求,包括跟踪问题的数据爬取,都将基于该cookie进行爬取。当然cookie是有时间限制的,所以我们保存cookie的时候也要记录过期时间。当我们后面拿到cookie的时候,我们需要添加一个过期检查。如果到期,我们将返回到期提醒。爬虫的基础扎实后,才能真正拿到自己想要的数据。我的需求是知道一个知乎问题的热点趋势。先用浏览器看一个问题页下面有什么数据,可以让我爬取分析。比如比如这个问题:有哪些令人惊奇的推理段落。打开链接后浏览页面最直接显示的关注者,1xxxx个回答,默认显示几个高赞回答和点赞评论数。右键查看网站源码,确认数据是服务器渲染的,我们可以通过request请求网页,然后通过cherrio使用css选择器定位数据节点,获取并存储。代码示例如下:asyncgetData(cookie,qid){constoptions={url:`${zhihuRoot}/question/${qid}`,method:'GET',headers:{'Cookie':cookie,'Accept-Encoding':'deflate,sdch,br'//不允许gzip,开启gzip会开启知乎客户端渲染,导致爬取失败}}constrs=awaitthis.request(options)if(rs.error){returnthis.failRequest(rs)}const$=cheerio.load(rs)constNumberBoard=$('.NumberBoard-item.NumberBoard-value')const$title=$('.QuestionHeader-title')$title.find('button').remove()返回{成功:true,标题:$title.text(),数据:{qid:qid,关注者:Number($(NumberBoard[0]).text()),读者:Number($(NumberBoard[1]).text()),answers:Number($('h4.List-headerTextspan').text().replace('answers',''))}这样我们就爬取了一个问题的数据,只要在一定时间间隔继续执行这个方法获取数据,就可以最终绘制出一个问题的数据曲线,分析热点趋势.那么问题来了,这个定时任务要怎么做呢?对于定时任务,我使用node-schedule进行任务调度。如果你以前做过计划任务,你可能会熟悉它的类似cron的语法。不熟悉也没关系。它提供了不像cron的、更直观的设置来配置任务。你可以通过阅读文档得到一个大概的了解。当然,这个定时任务并不是简单的不断执行上面的爬取方法getData。因为这个爬虫系统不只有一个用户,一个用户不只跟踪一个问题。所以我们这里的完整任务应该是遍历系统中每一个cookie还没有过期的用户,然后遍历每一个用户的跟踪问题,然后获取这些问题的数据。系统还有另外两个定时任务,一个是定时抓取用户关心的话题的热门答案,一个是将这个话题的热门答案推送给对应的用户。这两个任务和上面的任务大致相同,就不赘述了。但是我们在做定时任务的时候,会有一个详细的问题,就是爬取的时候如何控制并发问题。具体的例子:如果爬虫请求并发太多,知乎可能会限制访问这个IP,所以我们需要让爬虫一个一个请求,或者几个请求。简单考虑一下,我们将采取循环等待。我想都没想就写了下面的代码://爬虫方法asyncfunctiongetQuestionData(){//做爬虫动作}//questions就是获取的问答questions.forEach(awaitgetQuestionData)但是执行之后,我们会发现,这个其实还是并发执行的,为什么呢?其实仔细想想就明白了。forEach只是for循环的语法糖,如果没有这个方法,让你实现,你会怎么写?你也可以这样写:Array.prototype.forEach=function(callback){for(leti=0;i{ctx.body={success:true,data:data}}classQuestion{asyncget(){success(data)}asynccreate(){error(result)}}module.exports=newQuestion()这样确实可以,但是会有新的问题---这些方法不是对象本身的属性,所以不能子类继承。为什么需要继承?因为有时候我们希望一些不同的控制器有共同的方法或者属性,比如:我希望我所有的成功或者失败都是这样的格式:{success:false,message:'对应的错误信息'}{success:true,data:'对应数据'}按照koa的核心思想,这种通用的格式转换应该专门写一个中间件,在路由中间件之后(也就是controller中的方法执行完之后)做特殊的处理和响应。但是,这将导致需要为每个公共方法添加一个中间件。而控制器本身已经失去了对这些方法的控制。这个中间件是自己执行还是直接next()会很难判断。如果抽取出来放到utils方法中再引用,也不是不可以,但是如果方法很多的话,声明引用就有点麻烦了,也没有抽象类的意思了。更理想的状态应该是刚才说的,每个人都继承一个抽象父类,然后调用父类的public对应方法,如:classAbstractController{success(ctx,data){ctx.body={success:true,数据:数据}}error(ctx,error){ctx.body={success:false,msg:error}}}classQuestionextendsAbstractController{asyncget(ctx){constdata=awaitgetData(ctx.params.id)returnsuper.success(ctx,data)}}这是很多比较方便,但是写过koa的人可能会有这样的烦恼,总是需要传入一个contextctx作为参数。比如上述controller的所有中间件方法都要传ctx参数。在调用父类的方法时,他们必须再次传递,这也会使方法失去一些可读性。所以综上所述,我们存在以下问题:controller中的方法不能调用自身的其他方法和属性;调用父类方法时,需要传递上下文参数ctx。要解决它,解决方法其实很简单。我们只需要想办法让controller方法中的this指向实例化对象本身,然后把ctx挂在这个this上即可。怎么做?我们只需要再封装一次koa-router,如下:./controller/topic')constrouterMap=[['post','/api/question',question,'create'],['get','/api/question',question,'get'],['get','/api/topic',topic,'get'],['post','/api/topic/follow',topic,'follow']]routerMap.map(route=>{const[method,path,controller,action]=routerouter[method](path,async(ctx,next)=>控制器[action].bind(Object.assign(controller,{ctx}))(ctx,next))})module.exports=router意思是route传递controller方法时,controller本身与ctx合并,通过bind指定方法的this。这样我们就可以通过this获取该方法所属的controller对象的其他方法了。另外,子类方法和父类方法也可以通过this.ctx获取context对象ctx。但是在绑定之前,我们其实应该考虑一下,其他中间件和koa本身会不会做类似的事情,修改this的值。怎么判断呢,两种方式:调试。在我们绑定之前,在中间件方法中打印它。如果未定义,则不会绑定。查看koa-router/koa/node的源码。事实是,大自然不会。然后我们就可以放心绑定了。写在最后,以上大概是写这个小工具的时候遇到的一些点,感觉可以总结一下。没有技术上的难点,但是可以通过这个学习一些相关的知识,包括网站安全,爬虫和反爬虫,koa的底层原理等等。这个工具本身是非常个人化的,不一定能满足所有人的需求。而且是半年前写的,最近才总结出来的。而就在我快要写完文章的时候,发现知乎提示我账号不安全。估计是同一个IP同一个账号发起的网络请求太多了。我的服务器IP已经被认为是不安全的IP,在上面登录的账号都会提示不安全。所以不建议大家直接使用。当然,如果你还对它感兴趣,本地测试一下或者学着用也没什么大问题。或者如果你有更深的兴趣,可以尝试自己绕过知乎的安全策略。最后附上项目GitHub地址--阅读原文--转载请先授权自己。