用Node.js写一个爬虫来爬小说
小说就准备点天下霸唱和南派三叔的系列,本人喜欢看,而且数据也好爬。貌似因为树大招风的原因,这两作者的的书被盗版的很多,乱改的也多。然后作者就直接在网上开放免费阅读了,还提供了官网,猜想作者应该是允许爬虫来爬内容的。《盗墓笔记》和《鬼吹灯》系列这两官网从第一眼的界面风格来看还差不多,最后发现还真是一个队伍开发的,服务器都是一个。因为最开始爬数据的时候两次请求之间没有间隔时间,请求太频繁了,然后突然就没法访问了。立马反映过来是不是因为服务器端的保护措施,导致被封IP了。然后在别的电脑上和手机上都还能继续访问,发现还真是被封IP了,大约要等30分钟才能解封。而且要是在爬《盗墓》数据的时候被封IP,访问《鬼吹灯》的站点也是被封了的,哈哈。后来每次爬章节内容的时候都间隔500毫秒,就没有被封过了,这个间隔感觉还可以更短些,只要不影响其他读者的正常访问都是允许的吧。爬数据的同时可以ping站点,若一直有返回,IP就没被封。
页面的结构很简单很语义化。每本小说的章节目录部分html结构都是这样的:
章节内容的结构:
获取数据非常方便,对爬虫很友好,中国好网站!
下面说下这个爬虫:
最开始准备直接用node.js + cheerio + request就搞定,爬数据不需要提供接口访问,甚至连express都不需要,直接做成一个命令行工具。最后想了想,还是不太行,因为后面数据爬下来以后,还要给APP端提供小说接口,所以还是需要一个完整的server端,而且还要和数据库交互,能有ORM最好,免得直接写SQL。于是想起了两年前曾经使用过得ThinkJS,现已经更新到2.2.x版本了。这是国内的一个基于Node.js的MVC框架。相比于Express或Koa来说提供了更强大和完善的功能,应该把它和Sails一起比较。多的就不介绍了,官网文档很全面。
先整理一个小说的条目,两个作者的小说加起来大致有28本:
// book.js
1 export default { 2 /** 3 * 天下霸唱 19本 4 */ 6 guichuideng_1: { 7 id: 1, 8 name: '鬼吹灯1之精绝古城', 9 uri: 'http://www.guichuideng.org/jing-jue-gu-cheng', 10 author: '天下霸唱', 11 publish_date: '2006-09', 12 publisher: '安徽文艺出版社', 13 cover: 'guichuideng_1', 14 }, 16 guichuideng_2: { 17 id: 2, 18 name: '鬼吹灯2之龙岭迷窟', 19 uri: 'http://www.guichuideng.org/long-ling-mi-ku', 20 author: '天下霸唱', 21 publish_date: '2006-11', 22 publisher: '安徽文艺出版社', 23 cover: 'guichuideng_2', 24 }, 26 guichuideng_3: { 27 id: 3, 28 name: '鬼吹灯3之云南虫谷', 29 uri: 'http://www.guichuideng.org/yun-nan-chong-gu', 30 author: '天下霸唱', 31 publish_date: '2006-11', 32 publisher: '安徽文艺出版社', 33 cover: 'guichuideng_3', 34 }, 36 guichuideng_4: { 37 id: 4, 38 name: '鬼吹灯4之昆仑神宫', 39 uri: 'http://www.guichuideng.org/kun-lun-shen-gong', 40 author: '天下霸唱', 41 publish_date: '2006-12', 42 publisher: '安徽文艺出版社', 43 cover: 'guichuideng_4', 44 }, 45 // ...... 省略 46 }
ThinkJS支持从命令行访问接口,这里直接把爬虫的实现做到了controller里,可以从命令行来直接调用这个接口,在package.json的scripts加一个命令可就能通过npm来调用。Node.js虽然在7.x以后都支持原生es6/7书写,但是还是需要harmony和谐模式来运行才可以,要不然一样报语法错误。而TinkJS运行前是先将src的代码用babel编译到app文件夹内再跑服务,实际运行的是降级编译后的js代码,所以es6/7语法可以随心所欲的写,而不用担心兼容问题。用ThinkJS命令行工具初始化了一个项目,并加入一个Npm命令:
1 "spider": "npm run compile && node www/production.js spider/index"
controller:
1 'use strict'; 2 3 /** 4 * spider controller 5 */ 6 7 import Base from './base.js'; 8 9 import rp from 'request-promise'; 10 import cheerio from 'cheerio'; 11 import books from './spider/book'; 12 import {sleep, log} from './spider/tool'; 13 14 export default class extends Base { 15 indexAction (){ 16 if (this.isCli()){ 17 this.checked = false; 18 this.spiderModel = this.model('book'); 19 this.chapterModel = this.model('chapter'); 20 this.crawlBook(); 21 } else { 22 this.fail('该接口只支持在命令行调用~~~'); 23 } 24 } 25 26 async crawlBook (isCheck){ 27 log('小说目录插入开始...'); 28 // 小说先存入书籍表 29 var boookArr = []; 30 for (var x in books){ 31 boookArr.push(books[x]); 32 } 33 await this.spiderModel.addBookMany(boookArr); 34 log('小说目录插入完成...'); 35 log('小说内容抓取开始...'); 36 // 循环抓取小说目录 37 for (var key in books){ 38 var {id, name, uri} = books[key]; 39 var bookId = id; 40 log(name + ' [章节条目抓取开始...]'); 41 try { 42 var $ = await rp({ 43 uri, 44 transform: body => cheerio.load(body) 45 }); 46 var $chapters = $('.container .excerpts .excerpt a'); // 所有章节的dom节点 47 var chapterArr = []; // 存储章节信息 48 log(name + ' [章节条目如下...]'); 49 $chapters.each((i, el) => { 50 var index = i + 1; 51 var $chapter = $(el); // 每个章节的dom 52 let name = $chapter.text().trim(); 53 var uri = $chapter.attr('href'); 54 log(name + ' ' + uri); 55 chapterArr.push({bookId, index, name, uri}); 56 }); 57 } catch (e){ 58 return log(e.message, 1); 59 } 60 61 log(name + ' [章节条目抓取完毕,开始章节内容抓取...]'); 62 63 // 循环抓取章节内容 64 for (var i = 0,len = chapterArr.length;i < len;i ++){ 65 var chapter = chapterArr[i]; 66 // 先查询该章节是否已存在 67 // 爬取的途中断掉或者卡住了,再次启动蜘蛛的时候已存在的章节就不必再爬了 68 var res = await this.chapterModel.findChapter(chapter.name); 69 if (!think.isEmpty(res)){ 70 log(name + ' [章节已存在,忽略...]'); 71 continue; 72 } 73 await sleep(500); 74 await this.crawlChapter(chapter); 75 } 76 } 77 this.checked = isCheck; 78 79 // 再检测一遍是否有遗漏 80 !this.checked && this.crawlBook(1); 81 } 82 83 async crawlChapter ({bookId, index, name, uri}){ 84 try { 85 log(name + ' [章节内容抓取开始...]'); 86 var $ = await rp({ 87 uri, 88 transform: body => cheerio.load(body) 89 }); 90 var $content = $('.article-content'); // 只取正文内容 91 $('.article-content span').remove(); // 干掉翻页提示 92 var content = ' ' + $content.text().trim(); // 提取纯文本(不需要html标签,但保留换行和空格) 93 await this.chapterModel.addChapter({bookId, index, name, content, uri}); 94 log(name + ' [章节内容抓取完毕,已写入数据库...]'); 95 } catch (e){ 96 log(e.message, 1); 97 } 98 } 99 }
爬虫很简单,就是根据book.js的小说条目,先在数据库的小说条目表写入所有小说数据。然后遍历条目,先爬到某小说的章节条目数据,再爬每个章节的内容数据写入到数据的章节表中,完成后继续爬下一本小说的数据。由于这些小说都是出版定稿了的,也不需要定时器来定时爬,和爬新闻等数据还是有区别。这个爬虫基本是一次性的,数据完整地爬完一次就没意义了。
章节表中的bookId和小说表中的id关联,表示该章节属于哪本小说。章节表中的index表示该章节是它所在小说的第几章节(序列)。
给APP端提供的小说章节目录接口,只需要小说id就行。章节内容接口,需要小说id和章节的序列index参数就能取到数据。
model:
1 'use strict'; 2 3 /** 4 * book model 5 */ 6 7 export default class extends think.model.base { 8 /** 9 * 删除某个书籍 10 * @param id 要删除的书籍id 11 * @return {promise} 12 */ 13 removeBookById (id){ 14 return this.where({id}).delete(); 15 } 16 17 /** 18 * 删除所有书籍 19 * @param null 20 * @return {promise} 21 */ 22 removeAllBooks (){ 23 return this.where({id: ['>', 0]}).delete(); 24 } 25 26 /** 27 * 单个增加书籍 28 * @param book 书籍对象 29 * @return {promise} 30 */ 31 addBook (book){ 32 return this.add(book); 33 } 34 35 /** 36 * 批量增加书籍 37 * @param books 书籍对象数组 38 * @return {promise} 39 */ 40 async addBookMany (books){ 41 await this.removeAllBooks(); 42 return this.addMany(books); 43 } 44 }
1 'use strict'; 2 3 /** 4 * chapter model 5 */ 6 7 export default class extends think.model.base { 8 /** 9 * 查询某个章节 10 * @param name 章节名称 11 * @return {promise} 12 */ 13 findChapter (name){ 14 return this.where({name}).find() 15 } 16 17 /** 18 * 单个增加章节 19 * @param chapter 章节对象 20 * @return {promise} 21 */ 22 addChapter (chapter){ 23 return this.add(chapter); 24 } 25 26 /** 27 * 批量增加章节 28 * @param chapters 章节对象数组 29 * @return {promise} 30 */ 31 addChapter (chapters){ 32 return this.add(chapters); 33 } 34 }
model里面提供了操作数据库的方法,传入的数据对象的key需要和表中的cloumn一致,其他的就不用管了,很方便。
命令行cd进工程目录,执行 npm run spider ,就开始写入小说条目数据,然后爬章节数据了:
因为怕封IP,每个章节之间设置了500毫秒间隔时间,再加上网络延迟等原因,28本小说全部爬完再查漏一遍还是需要一些时间的。爬完后,总章节数有2157章,总字数就没统计了。
有了小说数据以后,就可以为自用小说阅读APP提供内容了。当然了,侵犯著作权的事情是不能干的哦。