用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提供內容了。當然了,侵犯著作權的事情是不能幹的哦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章