以前研究数据,零零散散的写过一些数据抓取的爬虫,不过写的比较随意。有不少地方如今看起来并非很合理 这段时间比较闲,原本是想给以前的项目作重构的。 后来 利用这个周末,索性从新写了一个项目,就是本项目 guwen-spider。目前这个爬虫仍是比较简单的类型的, 直接抓取页面,而后在页面中提取数据,保存数据到数据库。 经过与以前写的对比,我以为难点在于整个程序的健壮性,以及相应的容错机制。在昨天写代码的过程当中其实也有反映, 真正的主体代码其实很快就写完了 ,花了大部分时间是在 作稳定性的调试, 以及寻求一种更合理的方式来处理数据与流程控制的关系。前端
项目的背景是抓取一个一级页面是目录列表 ,点击一个目录进去 是一个章节 及篇幅列表 ,点击章节或篇幅进入具体的内容页面。node
本项目github地址 : guwen-spider (PS:最后面还有彩蛋 ~~逃react
项目技术细节git
项目大量用到了 ES7 的async 函数, 更直观的反应程序了的流程。为了方便,在对数据遍历的过程当中直接使用了著名的async这个库,因此不可避免的仍是用到了回调promise ,由于数据的处理发生在回调函数中,不可避免的会遇到一些数据传递的问题,其实也能够直接用ES7的async await 写一个方法来实现相同的功能。这里其实最赞的一个地方是使用了 Class 的 static 方法封装对数据库的操做, static 顾名思义 静态方法 就跟 prototype 同样 ,不会占用额外空间。 项目主要用到了github
目录结构web
├── bin // 入口 │ ├── booklist.js // 抓取书籍逻辑 │ ├── chapterlist.js // 抓取章节逻辑 │ ├── content.js // 抓取内容逻辑 │ └── index.js // 程序入口 ├── config // 配置文件 ├── dbhelper // 数据库操做方法目录 ├── logs // 项目日志目录 ├── model // mongoDB 集合操做实例 ├── node_modules ├── utils // 工具函数 ├── package.json
项目实现方案分析数据库
项目是一个典型的多级抓取案例,目前只有三级,即 书籍列表, 书籍项对应的 章节列表,一个章节连接对应的内容。 抓取这样的结构能够采用两种方式, 一是 直接从外层到内层 内层抓取完之后再执行下一个外层的抓取, 还有一种就是先把外层抓取完成保存到数据库,而后根据外层抓取到全部内层章节的连接,再次保存,而后从数据库查询到对应的连接单元 对之进行内容抓取。这两种方案各有利弊,其实两种方式我都试过, 后者有一个好处,由于对三个层级是分开抓取的, 这样就可以更方便,尽量多的保存到对应章节的相关数据。 能够试想一下 ,若是采用前者 按照正常的逻辑 对一级目录进行遍历抓取到对应的二级章节目录, 再对章节列表进行遍历 抓取内容,到第三级 内容单元抓取完成 须要保存时,若是须要不少的一级目录信息,就须要 这些分层的数据之间进行数据传递 ,想一想其实应该是比较复杂的一件事情。因此分开保存数据 必定程度上避开了没必要要的复杂的数据传递。npm
目前咱们考虑到 其实咱们要抓取到的古文书籍数量并很少,古文书籍大概只有180本囊括了各类经史。其和章节内容自己是一个很小的数据 ,即一个集合里面有180个文档记录。 这180本书全部章节抓取下来一共有一万六千个章节,对应须要访问一万六千个页面爬取到对应的内容。因此选择第二种应该是合理的。json
项目实现后端
主程有三个方法 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, 三个方法
booklist.js
/**
* 初始化方法 返回抓取结果 true 抓取成果 false 抓取失败
*/
const bookListInit = async() => {
logger.info('抓取书籍列表开始...');
const pageUrlList = getPageUrlList(totalListPage, baseUrl);
let res = await getBookList(pageUrlList);
return res;
}
复制代码
chapterlist.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;
};
复制代码
content.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 来给数据作分类,每次按照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, errorCollectionModel,在插入出错时 分别保存信息到对应的集合中,两者任选其一便可。增长集合来保存数据的缘由是 便于一次性查看以及后续操做, 不用看日志。
(PS ,其实彻底用 errorCollectionModel 这个集合就能够了 ,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端 还能够给 移动端 ,app 提供支持。目前整个一套还很简陋,可是能够知足基本的查询浏览功能。但愿后期有时间能够把项目变得更加丰富。
本项目地址 地址 : guwen-spider
对应前端 React + Redux + semantic-ui 地址 : guwen-react
对应Node端 Koa2.2 + mongoose 地址 : guwen-node
项目挺简单的 ,可是多了一个学习和研究 从前端到服务端的开发的环境。
以上です