当前位置: 首页 > 科技观察

NodeJs爬虫爬取古代经典,共16000页经验总结和项目分享

时间:2023-03-13 19:37:24 科技观察

前言之前研究资料,写了一些零散的爬虫用于数据抓取,但是写的比较随意。有很多地方现在看来不是很合理。这次比较空闲,本来想重构一下之前的项目。后来利用这个周末,干脆重新写了一个项目,就是guwen-spider这个项目。目前这种爬虫属于比较简单的类型。它直接抓取页面,然后从页面中提取数据,并将数据保存到数据库中。对比我之前写的,我觉得难点在于整个程序的健壮性和相应的容错机制。其实在昨天写代码的过程中就体现出来了。真正的主代码写得很快,大部分时间都花在了稳定性调试和寻求更合理的数据处理方式和流程控制上。关系。后台工程的背景是抓取一个一级页面作为目录列表,点击目录进入章节长度列表,点击章节或长度进入具体内容页面。本项目github地址一览:guwen-spider(PS:***上有彩蛋~~Escape项目技术细节项目大量使用了ES7的async函数,响应过程更加直观program.为了方便,在遍历数据的时候,流程中直接使用了著名的async库,所以不可避免的要用到回调promise,因为数据的处理发生在回调函数中,难免会遇到一些数据传输的问题,在其实也可以直接用ES7的asyncawait写一个方法来实现同样的功能,其实这里最好的地方之一就是用Class的静态方法来封装对数据库的操作,顾名思义,静态方法与原型相同,不额外占用空间,项目主要使用ES7的asyncawait协程进行异步逻辑处理,npm的async库用于循环遍历和con当前请求操作。log4js用于日志处理,cheerio用于dom操作。Mongoose用于连接mongoDB进行数据存储和操作。目录结构├──bin//entry│├──booklist.js//抓取图书逻辑│├──chapterlist.js//抓取章节逻辑│├──content.js//抓取内容逻辑│└──index.js//程序入口├──config//配置文件├──dbhelper//数据库操作方法目录├──logs//项目日志目录├──model//mongoDB集合操作实例├──node_modules...内容。捕获这样的结构有两种方式,一种是直接从外层捕获内层到内层后执行下一次外层捕获,另一种是先将外层捕获保存到数据库中,然后根据外层抓取所有内层章节的链接,重新保存,然后从数据库中查询对应的链接单元抓取内容。这两种解决方案各有优缺点。其实这两种方法我都试过了。后者有个好处,因为三个关卡是分开抓取的,这样更方便在相应章节的相关章节中尽量保存。数据。试想一下,如果前者按照正常逻辑遍历一级目录抓取对应的二级章节目录,再遍历章节列表抓取内容,而当三级内容单元需要抓取完成后保存,如果需要很多一级目录信息,就需要在这些分层数据之间传递数据。想想,应该是一件比较复杂的事情。因此,将数据单独存储在一定程度上避免了不必要的、复杂的数据传输。目前我们认为,我们要搜集的古籍数量其实并不多,涵盖各种经史的古籍只有180部左右。它和章节内容本身就是一个很小的数据,即一个集合中有180条文档记录。从这180本书的所有章节中一共抓取了16000个章节,对应需要访问16000个页面才能爬到相应的内容。所以选择第二种方案应该是合理的。项目实现的主要流程有bookListInit、chapterListInit、contentListInit三个方法,分别是获取图书目录、章节列表、图书内容的方法,是公开暴露的初始化方法。这三个方法的运行过程都可以通过async来控制。抓取图书目录后,会将数据保存到数据库中,然后将执行结果返回给主程序。如果操作成功,主程序会根据书单执行章节列表的抓取,同样抓书的内容。项目主入口/***爬虫爬虫主入口*/conststart=async()=>{letbooklistRes=awaitbookListInit();if(!booklistRes){logger.warn('图书列表抓取错误,程序终止...');return;}logger.info('图书列表抓取成功,现在抓取图书章节...');letchapterlistRes=awaitchapterListInit();if(!chapterlistRes){logger.warn('图书章节列表capture得到错误,程序终止...');return;}logger.info('已成功抓取图书章节列表,现在抓取图书内容...');letcontentListRes=awaitcontentListInit();if(!contentListRes){logger.warn('图书章节内容抓取错误,程序终止...');return;}logger.info('图书内容抓取成功');}//开始入口if(typeofbookListInit==='function'&&typeofchapterListInit==='function'){//开始抓取start();}引入bookListInit、chapterListInit、contentListInit,三个方法booklist.js/***initializationentry*/constchapterListInit=async()=>{constlist=awaitbookHelper.getBookList(bookListModel);if(!list){logger.error('初始查询图书目录失败');}logger.info('开始抓书章节列表,总书目录:'+list.length+'article');letres=awaitasyncGetChapter(list);returnres;};chapterlist.js/***初始化入口*/constcontentListInit=async()=>{//获取图书列表constlist=awaitbookHelper.getBookLi(bookListModel);if(!list){logger.error('初始查询图书目录失败');return;}constres=awaitmapBookList(list);if(!res){logger.error('抓取章节信息,调用getCurBookSectionList()继续串口遍历操作,执行完成回调报错,错误信息已打印,请查看日志!');return;}returnres;}内容爬取的思考书目录爬取的逻辑其实很简单,使用async.mapLimit即可做一个数据可以通过遍历保存,但是我们在保存内容的时候简化的逻辑其实就是遍历章节列表去抓取链接里面的内容,但是实际情况是链接数量有几十个之多成千上万,而且我们不能从内存使用的角度将它们全部保存起来。数组,然后遍历它,所以我们需要统一内容获取。一般的遍历方式是每次查询一定数量的数据来做爬取。这样做的缺点是它只是按一定数量进行分类,数据之间没有相关性。它是分批插入的。如果有错误,容错性会出现一些小问题,我们会遇到将一本书单独保存为一个集合的问题。因此,我们采用的第二种方式是以书本为单位进行内容的抓取和保存。这里使用async.mapLimit(list,1,(series,callback)=>{})方法进行遍历,免不了用到callback,感觉恶心。async.mapLimit()的第二个参数可以设置同时请求的数量。/**内容抓取步骤:****第一步获取图书列表,通过图书列表找到一条图书记录对应的所有章节列表,*第二步遍历章节列表获取内容保存到数据库中*第三步保存完第一步的数据后,回到第一步抓取保存下一本书的内容*//***初始化入口*/constcontentListInit=async()=>{//获取图书列表constlist=awaitbookHelper.getBookList(bookListModel);if(!list){logger.error('初始查询图书目录失败');return;}constres=awaitmapBookList(list);if(!res){logger.error('抓取章节信息,调用getCurBookSectionList()进行串口遍历操作,执行完成回调报错,错误信息已打印,请查看日志!');return;}returnres;}/***遍历图书目录下的章节列表*@param{*}list*/constmapBookList=(list)=>{returnnewPromise((resolve,reject)=>{async.mapLimit(list,1,(series,callback)=>{letdoc=series._doc;getCurBookSectionList(doc,callback);},(err,result)=>{if(err){logger.error('图书目录爬取异步执行出错!');logger.error(err);reject(false);return;}resolve(true);})})}/***获取单本书章节列表调用章节列表遍历抓取内容*@param{*}series*@param{*}callback*/constgetCurBookSectionList=async(series,callback)=>{letnum=Math.random()*1000+1000;awaitsleep(num);letkey=series.key;constres=awaitbookHelper.querySectionList(chapterListModel,{key:key});if(!res){logger.error('获取当前书籍失败:'+series.bookName+'章节内容,进入下一本书内容抓取!');callback(null,null);return;}//判断当前数据是否已经存在constbookItemModel=getModel(key);constcontentLength=awaitbookHelper.getCollectionLength(bookItemModel,{});if(contentLength===res.length){logger.info('当前图书:'+series.bookName+'数据库已经取完,进入下一个数据任务');callback(null,null);return;}awaitmapSectionList(res);callback(null,null);}取完数据如何保存是个问题这里我们使用key对数据进行分类,每次根据key获取链接,遍历。这样做的好处是保存的数据是一个整体。现在想想数据存储的问题。1、可以整体插入。优点:速度数据库操作快,不浪费时间缺点:有些书可能有几百章,这意味着插入前必须保存几百页的内容。这样也会消耗内存,可能会导致程序运行不稳定。2、可以以每篇文章的形式插入数据库。优点:抓取后保存页面的方式,可以及时保存数据。即使后面出现错误,也不需要重新保存之前的章节。缺点:速度也很明显。仔细想想,如果要爬几万个页面,做几万次*N个数据库的操作,也可以作为一个缓冲区,一次性保存一定数量的item。当项目数量达到时,将它们保存起来也是一个不错的选择。/***遍历单本书下的所有章节,调用内容抓取方法*@param{*}list*/constmapSectionList=(list)=>{returnnewPromise((resolve,reject)=>{async.mapLimit(list,1、(series,callback)=>{letdoc=series._doc;getContent(doc,callback)},(err,result)=>{if(err){logger.error('图书目录捕获异步执行错误!');logger.error(err);reject(false);return;}constbookName=list[0].bookName;constkey=list[0].key;//整体保存saveAllContentToDB(result,bookName,key,resolve);//以每篇文章为单位保存//logger.info(bookName+'数据抓取完成,进入下一个图书抓取函数...');//resolve(true);})})}两者各有优势和缺点,这里我们已经尝试过了。准备了两个用于保存错误的集合,errContentModel和errorCollectionModel。当插入错误时,将信息分别保存到对应的集合中。您可以选择两者之一。之所以添加集合保存数据,是为了方便一次性查看,后续操作不用看日志。(PS,其实完全可以使用errorCollectionModel集合,errContentModel集合完全可以保存章节信息)//保存错误的数据名consterrorSpider=mongoose.Schema({chapter:String,section:String,url:String,key:String,bookName:String,author:String,})//保存错误的数据名,只保留key和bookName信息consterrorCollection=mongoose.Schema({key:String,bookName:String,})我们将保存的将每本书信息的内容放入一个新的集合中,集合以key命名。总结写这个项目,主要难点在于程序稳定性的控制,容错机制的设置,以及错误的记录。目前该项目基本可以实现直接运行,一次性跑通全流程。不过程序设计上肯定还有很多问题,欢迎大家指正交流。写完这个项目后,做了一个基于React的前端网站,用于页面浏览和一个基于koa2.x的服务器。整体技术栈相当于React+Redux+Koa2。前后端服务分开部署,相互独立。可以更好的解除前后端服务的耦合。例如,同一套服务端代码不仅可以为web提供支持,还可以为移动端和app提供支持。目前整套还是很简单的,但是可以满足基本的查询浏览功能。希望以后有时间充实项目。本项目地址地址:guwen-spider对应前端React+Redux+semantic-ui地址:guwen-react对应Node端Koa2.2+mongoose地址:guwen-node项目比较简单,但是有多学习和研究服务器端开发的环境。