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

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

时间:2023-04-03 19:44:58 Node.js

前言之前研究资料,写了一些零散的爬虫用于数据抓取,但是写的比较随意。有很多地方现在看来不是很合理。这次比较空闲,本来想重构一下之前的项目。后来利用这个周末,干脆重新写了一个项目,就是guwen-spider这个项目。目前这种爬虫属于比较简单的类型。它直接抓取页面,然后从页面中提取数据,并将数据保存到数据库中。对比我之前写的,我觉得难点在于整个程序的健壮性和相应的容错机制。其实在昨天写代码的过程中就体现出来了。真正的主代码写得很快,大部分时间都花在了稳定性调试和寻求更合理的数据处理方式和流程控制上。关系。后台工程的背景是抓取一个一级页面作为目录列表,点击目录进入章节长度列表,点击章节或长度进入具体内容页面。概览本项目github地址:guwen-spider(PS:文末有彩蛋~~Escape项目技术细节项目大量使用了ES7的async函数,更直观的体现了程序的流程。对于方便,在数据遍历的过程中,库中直接使用了著名的asynclibrary,所以难免要用到callbackpromise,因为数据处理发生在回调函数中,难免会遇到一些数据传输问题,在其实也可以直接用ES7的asyncawait写一个方法来实现同样的功能,其实这里最好的地方就是用Class的静态方法来封装对数据库的操作,顾名思义,静态方法与原型相同,不额外占用空间,项目主要使用1ES7的asyncawait协程做异步逻辑处理。2使用npm的async库做循环遍历和并发请求操作。3使用log4js做日志处理4使用cheerio处理dom操作。5使用mongoose连接mongoDB做数据存储和操作。目录结构

├──bin//入口│├──booklist.js//抓取图书逻辑│├──chapterlist.js//抓取章节逻辑│├──content.js//抓取内容逻辑│└──index.js//程序入口├──config//配置文件├──dbhelper//数据库操作方法目录├──logs//项目日志目录├──model//mongoDB集合操作实例├──node_modules├──utils//实用函数├──package.json
项目实施方案分析项目是多级爬取的典型案例,目前只有三个级别,即书籍列表,对应的章节bookitemsList,内容对应章节链接。捕获这样的结构有两种方式,一种是直接从外层捕获内层到内层后执行下一次外层捕获,另一种是先将外层捕获保存到数据库中,然后根据外层抓取所有内层章节的链接,重新保存,然后从数据库中查询对应的链接单元抓取内容。这两种解决方案各有优缺点。其实这两种方法我都试过了。后者有个好处,因为三个关卡是分开抓取的,这样更方便在相应章节的相关章节中尽量保存。数据。试想一下,如果前者按照正常逻辑遍历一级目录抓取对应的二级章节目录,再遍历章节列表抓取内容,而当三级内容单元需要抓取完成后保存,如果需要很多一级目录信息,就需要在这些分层数据之间传递数据。想想,应该是一件比较复杂的事情。因此,将数据单独存储在一定程度上避免了不必要的、复杂的数据传输。目前我们认为,我们要搜集的古籍数量其实并不多,涵盖各种经史的古籍只有180部左右。它和章节内容本身就是一个很小的数据,即一个集合中有180条文档记录。从这180本书的所有章节中一共抓取了16000个章节,对应需要访问16000个页面才能爬到相应的内容。所以选择第二种方案应该是合理的。项目实现的主要流程有bookListInit、chapterListInit、contentListInit三个方法,分别是获取图书目录、章节列表、图书内容的方法,是公开暴露的初始化方法。这三个方法的运行过程都可以通过async来控制。抓取图书目录后,会将数据保存到数据库中,然后将执行结果返回给主程序。如果操作成功,主程序会根据书单执行章节列表的抓取,同样抓书的内容。项目主入口/***爬虫爬取主入口*/conststart=async()=>{letbooklistRes=awaitbookListInit();if(!booklistRes){logger.warn('图书列表捕获错误,程序终止...');返回;}logger.info('书单获取成功,正在获取图书章节...');让chapterlistRes=awaitchapterListInit();if(!chapterlistRes){logger.warn('图书章节列表捕获错误,程序终止...');返回;}logger.info('成功抓取图书章节列表,现在抓取图书内容...');让contentListRes=awaitcontentListInit();if(!contentListRes){logger.warn('书章内容抓取错误,程序终止...');返回;}logger.info('成功抓取图书内容');}//开始抓取if(typeofbookListInit==='function'&&typeofchapterListInit==='function'){//开始抓取start();}引入bookListInit,chapterListInit,contentListInit,三个方法booklist.js/***初始化方法returnCrawlresulttrueCrawlresultfalseCrawlfailed*/constbookListInit=async()=>{logger.info('Crawlbookliststart...');constpageUrlList=getPageUrlList(totalListPage,baseUrl);让res=awaitgetBookList(pageUrlList);returnres;}chapterlist.js/***初始化入口*/constchapterListInit=async()=>{constlist=awaitbookHelper.getBookList(bookListModel);if(!list){logger.error('初始化查询图书目录失败');}logger.info('开始抓取图书章节列表,图书总目录:'+list.length+'article');让res=awaitasyncGetChapter(list);returnres;};content.js/***初始化入口*/constcontentListInit=async()=>{//获取图书列表constlist=awaitbookHelper.getBookLi(bookListModel);if(!list){logger.error('初始化查询图书目录失败');返回;}constres=awaitmapBookList(list);if(!res){logger.error('抓取章节信息,调用getCurBookSectionList()进行串口遍历操作,执行完成回调错误,错误信息已打印,请查看日志!');返回;}returnres;}思考内容抓取图书目录抓取其实逻辑很简单,就是用async.mapLimit做一个遍历保存数据,但是我们在保存内容的时候简化逻辑其实就是遍历章节列表抓取链接中的内容,但实际情况是链接数量多达数万个。从内存占用的角度来说,我们不能全部保存在一个数组中,然后遍历,所以我们需要对内容爬取进行单元化。一般的遍历方式是每次查询一定数量的数据来做爬取。这样做的缺点是它只是按一定数量进行分类,数据之间没有相关性。它是分批插入的。如果有错误,容错性会出现一些小问题,我们会遇到将一本书单独保存为一个集合的问题。因此,我们采用的第二种方式是以书本为单位进行内容的抓取和保存。这里使用async.mapLimit(list,1,(series,callback)=>{})方法进行遍历,免不了用到callback,感觉恶心。async.mapLimit()的第二个参数可以设置同时请求的数量。/**内容抓取步骤:*第一步获取图书列表,通过图书列表找到一条图书记录对应的所有章节列表,*第二步遍历章节列表获取内容和保存到数据库中*第三步保存数据后,返回第一步抓取并保存下一本书的内容*//***初始化入口*/constcontentListInit=async()=>{//获取图书列表constlist=awaitbookHelper.getBookList(bookListModel);if(!list){logger.error('初始查询图书目录失败');返回;}constres=awaitmapBookList(list);if(!res){logger.error('抓取章节信息,调用getCurBookSectionList()进行串口遍历操作,执行完成回调错误,已打印错误信息,请查看日志!');返回;}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;}解决(真);})})}/***获取单本书的章节列表调用章节列表遍历抓取内容*@param{*}series*@param{*}callback*/constgetCurBookSectionList=async(series,回调)=>{让num=Math.random()*1000+1000;等待睡眠(数量);让键=系列.键;constres=awaitbookHelper.querySectionList(chapterListModel,{key:key});if(!res){logger.error('获取当前图书失败:'+series.bookName+'章节内容,进入下一本书内容抓取!');回调(空,空);返回;}//判断当前数据是否已经存在constbookItemModel=getModel(key);constcontentLength=awaitbookHelper.getCollectionLength(bookItemModel,{});if(contentLength===res.length){logger.info('当前图书:'+series.bookName+'数据库已抓取,进入下一个数据任务');回调(空,空);返回;}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('异步执行book目录捕获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项目比较简单,但是有一个更多学习和研究服务器端开发的环境。高于desu