nodejs抓取动态网页(索性-spider项目页面(/spider))
优采云 发布时间: 2021-10-14 22:05nodejs抓取动态网页(索性-spider项目页面(/spider))
之前研究过数据,写了一些数据爬虫的爬虫,不过写的比较随意。现在看来不合理的地方很多。这段时间比较闲,本来想重构一下之前的项目。
后来利用这个周末,干脆重新写了一个项目,这个项目guwen-spider。目前这个爬虫还是比较简单的类型。它直接抓取页面,然后从页面中提取数据,并将数据保存到数据库中。
对比我之前写的,我觉得难点在于整个程序的健壮性和相应的容错机制。昨天写代码的过程中,其实反映了真正的主代码其实写的很快,花了大部分时间
做稳定性调试,寻求更合理的方式处理数据与过程控制的关系。
背景
该项目的背景是抓取一个一级页面,它是一个内容列表。单击目录是章节和长度的列表。点击章节或长度是进入具体内容页面。
概述
本项目github地址:[guwen-spider](yangfan0095/guwen-spider)(PS:最后还有彩蛋~~逃跑
项目技术细节
项目使用了大量的ES7 async函数,更直观的反映了程序的流程。为方便起见,在数据遍历的过程中直接使用了众所周知的async库,所以不可避免地会用到回调promise。因为数据的处理发生在回调函数中,难免会遇到一些数据传输的问题。其实你也可以直接用ES7的async await写一个方法来实现同样的功能。其实这里最好的一点就是使用Class的静态方法来封装数据库的操作。顾名思义,静态方法与原型相同,不占用额外空间。
该项目主要用于
* 1 ES7 的 async await 协程做异步逻辑处理。
* 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
项目实现计划分析
该项目是典型的多级爬取案例,目前只有三个层次,分别是书单、书目对应的章节列表、章节链接对应的内容。有两种方法可以捕获这样的结构。一种是直接从外层抓到内层,抓到内层后再执行下一个外层,另一种是先将外层保存到数据库中。,然后根据外层抓取到所有内层章节的链接,再次保存,然后从数据库中查询对应的链接单元来抓取内容。这两种方案各有利弊。其实这两种方法我都试过了。后者有一个优势,因为三个层次是分别捕获的,以便更方便,尽可能保存到相关章节。数据。试想一下,如果按照正常逻辑采用前者
遍历一级目录抓取对应的二级章节列表,再遍历章节列表抓取内容。当三级内容单元被捕获并需要保存时,如果你需要大量一级目录信息,你需要在这些分层数据之间进行数据传输,这实际上应该是一个比较复杂的考虑。因此,单独保存数据在一定程度上避免了不必要和复杂的数据传输。
目前,我们认为我们要捕捉的古籍数量并不多,涵盖各种历史的古籍大约只有180本左右。它和章节内容本身是一小块数据,即一个集合中有180个文档记录。这180本书的所有章节共有16000章,对应爬取相应内容需要访问的16000页。所以选择第二个应该是合理的。
项目实现
主程序有bookListInit、chapterListInit、contentListInit三个方法,分别是抓取图书目录、章节列表、图书内容的初始化方法。通过async,可以控制这三种方法的运行过程。图书目录抓取完成后,将数据保存到数据库中,然后将执行结果返回给主程序。如果主程序运行成功,会根据书单抓取章节列表。,同样抢书的内容。
项目主入口
/**
* 爬虫抓取主入口
*/
const start = async() => {
let booklistRes = await bookListInit();
if (!booklistRes) {
logger.warn('书籍列表抓取出错,程序终止...');
return;
}
logger.info('书籍列表抓取成功,现在进行书籍章节抓取...');
let chapterlistRes = await chapterListInit();
if (!chapterlistRes) {
logger.warn('书籍章节列表抓取出错,程序终止...');
return;
}
logger.info('书籍章节列表抓取成功,现在进行书籍内容抓取...');
let contentListRes = await contentListInit();
if (!contentListRes) {
logger.warn('书籍章节内容抓取出错,程序终止...');
return;
}
logger.info('书籍内容抓取成功');
}
// 开始入口
if (typeof bookListInit === 'function' && typeof chapterListInit === 'function') {
// 开始抓取
start();
}
介绍bookListInit、chapterListInit、contentListInit,三个方法
书单.js
/**
* 初始化方法 返回抓取结果 true 抓取成果 false 抓取失败
*/
const bookListInit = async() => {
logger.info('抓取书籍列表开始...');
const pageUrlList = getPageUrlList(totalListPage, baseUrl);
let res = await getBookList(pageUrlList);
return res;
}
章节列表.js
/**
* 初始化入口
*/
const chapterListInit = async() => {
const list = await bookHelper.getBookList(bookListModel);
if (!list) {
logger.error('初始化查询书籍目录失败');
}
logger.info('开始抓取书籍章节列表,书籍目录共:' + list.length + '条');
let res = await asyncGetChapter(list);
return res;
};
内容.js
/**
* 初始化入口
*/
const contentListInit = async() => {
//获取书籍列表
const list = await bookHelper.getBookLi(bookListModel);
if (!list) {
logger.error('初始化查询书籍目录失败');
return;
}
const res = await mapBookList(list);
if (!res) {
logger.error('抓取章节信息,调用 getCurBookSectionList() 进行串行遍历操作,执行完成回调出错,错误信息已打印,请查看日志!');
return;
}
return res;
}
关于内容抓取的想法
图书目录爬取的逻辑其实很简单。你只需要使用 async.mapLimit 做一次遍历保存数据,但是我们保存内容时的简化逻辑其实就是遍历章节列表去抓取链接中的内容。但实际情况是链接数多达数万。从内存使用的角度来看,我们无法将它们全部保存到一个数组中然后遍历它们,因此我们需要将内容捕获进行单元化。
常见的遍历方法是每次查询一定数量进行爬取。缺点是只用一定数量进行分类,数据之间没有相关性,插入是分批进行的。如果出现错误,容错方面会出现一些小问题,我们认为将一本书作为采集会遇到问题。因此,我们使用第二种方法以书为单位捕获和保存内容。
这里使用了方法`async.mapLimit(list, 1, (series, callback) => {})` 来遍历。回调是不可避免的使用,感觉很恶心。async.mapLimit() 的第二个参数可以设置同时请求的数量。
/*
* 内容抓取步骤:
* 第一步得到书籍列表, 通过书籍列表查到一条书籍记录下 对应的所有章节列表,
* 第二步 对章节列表进行遍历获取内容保存到数据库中
* 第三步 保存完数据后 回到第一步 进行下一步书籍的内容抓取和保存
*/
/**
* 初始化入口
*/
const contentListInit = async() => {
//获取书籍列表
const list = await bookHelper.getBookList(bookListModel);
if (!list) {
logger.error('初始化查询书籍目录失败');
return;
}
const res = await mapBookList(list);
if (!res) {
logger.error('抓取章节信息,调用 getCurBookSectionList() 进行串行遍历操作,执行完成回调出错,错误信息已打印,请查看日志!');
return;
}
return res;
}
/**
* 遍历书籍目录下的章节列表
* @param {*} list
*/
const mapBookList = (list) => {
return new Promise((resolve, reject) => {
async.mapLimit(list, 1, (series, callback) => {
let doc = series._doc;
getCurBookSectionList(doc, callback);
}, (err, result) => {
if (err) {
logger.error('书籍目录抓取异步执行出错!');
logger.error(err);
reject(false); return;
}
resolve(true);
})
})
}
/**
* 获取单本书籍下章节列表 调用章节列表遍历进行抓取内容
* @param {*} series
* @param {*} callback
*/
const getCurBookSectionList = async(series, callback) => {
let num = Math.random() * 1000 + 1000;
await sleep(num);
let key = series.key;
const res = await bookHelper.querySectionList(chapterListModel, {
key: key
});
if (!res) {
logger.error('获取当前书籍: ' + series.bookName + ' 章节内容失败,进入下一部书籍内容抓取!');
callback(null, null);
return;
}
//判断当前数据是否已经存在
const bookItemModel = getModel(key);
const contentLength = await bookHelper.getCollectionLength(bookItemModel, {});
if (contentLength === res.length) {
logger.info('当前书籍:' + series.bookName + '数据库已经抓取完成,进入下一条数据任务');
callback(null, null);
return;
}
await mapSectionList(res);
callback(null, null);
}
抓包后如何保存数据是个问题
这里我们使用key对数据进行分类。每次我们拿到link,根据key遍历,这样做的好处是保存的数据是一个整体。现在我们正在考虑数据存储的问题。
1 可整体插入
优点:数据库操作快,不浪费时间。
缺点:有些书可能有几百章,这意味着几百页的内容在插入之前必须保存。这也会消耗内存并可能导致程序运行不稳定。
2可以以每篇文章文章的形式插入到数据库中。
优点:页面抓取保存的方式,可以及时保存数据,即使出现后续错误,也无需重新保存之前的章节。
缺点:明显慢。想爬几万个页面,做几万次*N的数据库操作,想一想。在这里,您还可以创建一个缓冲区来一次保存一定数量的条目。当条目数达到条目数时,再次保存。好的选择。
/**
* 遍历单条书籍下所有章节 调用内容抓取方法
* @param {*} list
*/
const mapSectionList = (list) => {
return new Promise((resolve, reject) => {
async.mapLimit(list, 1, (series, callback) => {
let doc = series._doc;
getContent(doc, callback)
}, (err, result) => {
if (err) {
logger.error('书籍目录抓取异步执行出错!');
logger.error(err);
reject(false);
return;
}
const bookName = list[0].bookName;
const key = list[0].key;
// 以整体为单元进行保存
saveAllContentToDB(result, bookName, key, resolve);
//以每篇文章作为单元进行保存
// logger.info(bookName + '数据抓取完成,进入下一部书籍抓取函数...');
// resolve(true);
})
})
}
两者都有其优点和缺点,我们都在这里尝试过。准备了两个错误保存集合,errContentModel 和 error采集Model。插入错误时,信息会保存到相应的集合中。您可以选择两者之一。添加集合保存数据的原因是为了方便一次性查看和后续操作,无需查看日志。
(PS,其实可以完全使用error采集Model集合,errContentModel集合完全可以保存章节信息)
//保存出错的数据名称
const errorSpider = mongoose.Schema({
chapter: String,
section: String,
url: String,
key: String,
bookName: String,
author: String,
})
// 保存出错的数据名称 只保留key 和 bookName信息
const errorCollection = mongoose.Schema({
key: String,
bookName: String,
})
我们把每本书信息的内容放到一个新的集合中,集合以key命名。
总结
其实,编写这个项目的主要难点在于程序稳定性的控制,容错机制的设置,以及错误的记录。目前这个项目基本上可以一次直接运行整个流程。但是,程序设计肯定存在很多问题。请指正并交流。
复活节彩蛋
写完这个项目,做了一个基于React的前端网站用于页面浏览,一个基于koa2.x的服务器。整体技术栈相当于React+Redux+Koa2,前后端服务分开部署,各自独立可以更好的去除前后端服务之间的耦合。例如,同一组服务器端代码不仅可以为 Web 提供支持,还可以为移动和应用程序提供支持。目前整套还很简陋,但是可以满足基本的查询和浏览功能。希望以后有时间可以充实一下项目。
本项目地址:[guwen-spider](yangfan0095/guwen-spider)
对应前端React+Redux+semantic-ui地址:[guwen-react](yangfan0095/guwen-react)
对应节点koa2.2+猫鼬地址:[guwen-node](yangfan0095/guwen-node)
项目很简单,但是从前端到服务器端,多了一个学习和研发的环境。
谢谢阅读!
以上です