Node.js实现爬虫异步抓取豆瓣图书信息

本篇介绍Node.js实现爬虫抓取豆瓣图书信息,详细讲解Node.js爬虫的异步流程和并发控制。

前言

一个必然的机会,这学期有一个数据结构课设,要用Java实现一个有一定数据处理能力的模拟图书管系统。
既然要求了数据处理能力,那数据量自然不能太少,如果自己随机生成一堆abcd的数据又显得比较low,用Node.js写一个爬虫抓取豆瓣上图书信息这个想法咻的一下就出现啦~
最后实现的过程有一点点曲折,没一开始咻的那一下想的那么简单hh
先把要用到的依赖包简单介绍下

  • async

    Async是一个为处理异步Js提供简单、直接、强大功能的实用模块

    本文中用到async来处理一组多个链接的内容抓取,处理异步集合流程和并发控制

  • superagent
    nodejs里一个非常方便的客户端请求代理模块,非常流行,操作也简单

  • superagent-proxy
    一段时间内高频率请求豆瓣页面会被封IP的。。。gg
    这个时候就需要使用IP代理通过变换IP来怼回去,superagent-proxy就是这么一个加载代理的Node.js模块
  • cheerio
    用来将返回上下文转化为可用jQuery操作的模块,包括 jQuery 核心的子集,从jQuery库中去除了所有 DOM不一致性和浏览器尴尬的部分
  • eventproxy
    利用事件驱动机制解耦复杂异步逻辑,提升异步协作场景的执行效率。简单说作用就是优雅地处理异步事件流

篇幅原因,本篇着重讲解爬虫的具体实现和个别依赖包的个别API使用 すみません

正文开始

分析豆瓣页面结构

因为课设对数据的需求仅限于书名、作者、出版社、类别和简介,所以分析了一下之后很容易就找到了方便爬的页面
豆瓣图书页面
豆瓣图书页面

找到要抓取的目标后,因为本文使用的是cheerio,所以我们通过jQuery的选取元素方式来获取元素。这里可以用Chrome浏览器来获取页面元素的selector。具体操作是,右键检查元素–>Copy–>Copy selector
当然Chrome开发者工具也有Copy XPath的功能,如果喜欢用XPath的话也是可以的,解析Xpath也有相应的模块,这里就不赘述了

引入模块和定义相应变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const request = require("superagent"),
async = require('async'),
eventproxy = require('eventproxy'),
cheerio = require("cheerio");
require("superagent-proxy")(request);
const ep = new eventproxy();
const fs = require('fs');

//抓取分类区块和保存文件名数组的设置
var blockCount = 0;
const blockName = ['literature', 'popular', 'culture', 'life', 'management', 'technology'];

const doubanTagUrl = 'https://book.douban.com/tag/';
var TagPageUrl = [], //存放大分类下每个小分类的连接
filteredTagPageUrl = [], //存放过滤后能成功访问的小分类链接
catchData = []; //存放对每个大分类抓取到的数据
var blockrow, //记录大分类下小分类的行数
$TagPage; // 存放/tag页面的res.text

blockCountblockName分别是计数和存放豆瓣页面下不同大类图书的变量

抓取标签页内容

在上代码之前,把存在的问题说一下,由于豆瓣会对短时间内频繁访问豆瓣的IP封锁一天,小白博主在找不到较好的办法和不想拖慢爬虫速度的情况下决定破财免灾
找到了一个比较好用的IP代理服务:阿布云 按小时买代理服务,好用不贵~

需要引入的superagent-proxy模块和设置代理如下

1
2
3
4
5
6
// 代理隧道验证信息
const proxyHost = "proxy.abuyun.com";
const proxyPort = 9010;
const proxyUser = "abcdefghijklmnop";
const proxyPass = "123456789abcdefg";
const proxyUrl = "http://" + proxyUser + ":" + proxyPass + "@" + proxyHost + ":" + proxyPort;

具体的proxyPort还得看买的服务是什么类型啦,官网文档写的还是挺清楚的

superagent

接下来就是实际抓取的函数了,简单说一下superagent的用法
一个请求的初始化可以用请求对象里合适的方法来执行,然后调用end()来发送请求,下面是一个简单的get请求

1
2
3
4
5
6
7
const request = require("superagent");
const url = 'xxx';
request.get(url).end(function(err,res){
/**
* 处理返回res
**/
});

所以我们只要在.end( )里加上处理的函数即可

完整抓取标签页内容代码

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 获取豆瓣分类浏览页面
*/
var getDoubanPage = function() {
request.get(doubanTagUrl).proxy(proxyUrl).end(function(err, res) {
if (err)
console.error('访问豆瓣失败,检查IP封锁!');
else {
$TagPage = cheerio.load(res.text);
getFilteredTagUrl($TagPage);
}
});
}

获取小分类的标签链接

简单分析html结构后,我想出的比较简单的方法是对每一个大分类遍历内部所有小分类,逐次抓取大分类并写入不同文件。
处理的过程中,发现的一个问题是,有些小分类链接访问失败,有一定随机性,应该是玄学问题,所以要加上一个过滤链接的函数,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

/**
* 获取过滤后可访问的小分类链接
*/
var getFilteredTagUrl = function($) {
blockrow = parseInt($('#content > div > div.article > div:nth-child(2) > div:nth-child(' + blockCount + 1 + ') > table td').length / 4);
for (var i = 1; i <= blockrow; i++) {
for (var j = 1; j <= 4; j++) {
TagPageUrl.push('https://book.douban.com' + $('#content > div > div.article > div:nth-child(2) > div:nth-child(' + blockCount + 1 + ') > table > tbody > tr:nth-child(' + i + ') > td:nth-child(' + j + ') > a').attr('href'));
ep.emit('getUrl', TagPageUrl[TagPageUrl.length - 1]);
/**
* 获取小类链接后
* 为'getUrl'事件计数器计数
**/
}
}

/**
* 'getUrl'事件达到指定次数,即一获得当前分类下所有小分类链接
* 对小类链接进行确认并处理
* 注册'checkUrl'事件,当所有小类链接确认后,开始收集图书信息
*/
ep.after('getUrl', blockrow * 4, function(list) {
TagPageUrl.forEach(function(el, index) {
console.log(el);
request.get(encodeURI(el)).proxy(proxyUrl).end(function(err, res) {
ep.emit('checkUrl', el); //为'checkUrl'事件计数器计数
if (err)
console.error(el);
else {
for (var i = 0; i <= 200; i++) { //获取前两百页内容
filteredTagPageUrl.push(el + '?start=' + i * 20 + '&type=T');
}
}
});
});
//所有'checkUrl'事件结束后,开始收集信息
ep.after('checkUrl', blockrow * 4, getbookstart);
});
}

这一段代码要注意的地方比较多,以下逐一说明

  1. 观察链接小标签url后会发现,链接是带中文的,在浏览器里访问链接会被浏览器自动解析为百分号编码的通用地址,但是在Node.js爬虫里,我们需要手动做这件事,所以在传入url前要先进行编码,使用JavaScript提供的encodeURI()方法即可
  2. 使用eventproxy进行事件流程控制
    这里用一个没什么卵用的例子来说明
    • 代码中getFilteredTagUrl函数内部的ep.emit('getUrl', TagPageUrl[TagPageUrl.length - 1]);简单理解就是为getUrl事件的计数器加一,并将TagPageUrl[TagPageUrl.length - 1]传入事件计数器数组,可将数组作为参数传递给事件结束调用的回调函数
    • ep.after('getUrl', blockrow * 4, function(list){...})简单理解就是ep.after(event, num, handle)中,after方法注册事的件event执行次数达到传入参数num后,调用回调函数handle处理结果
    • 虽然在现在的例子中没有什么卵用,同步执行下顺序操作即可,但是我们也能很容易想到eventproxy事件代理的使用场景:发送请求后异步操作回调
    • 所以当确认链接有效的请求发送后,通过一定次数的触发事件代理的回调函数进入下一步页面抓取,在代码的最后几行,ep.after('checkUrl', blockrow * 4, getBookStart)即是当所有小分类链接确认操作完成后,对每一页图书信息进行抓取
  3. 这里好像没什么好说的了,但是得凑出个第三点来,不如你门好好看看eventproxy的文档吧

抓取图书信息

先说明一下async的用法和主要用途

  • 使用async限制并发访问数量,控制异步流程
  • mapLimit定义

    mapLimit(collection, limit, iteratee(item,callback), callback(err, results))

  • collection为要操作的元素集合,limit为并发数量
  • iteratee为操作集合内元素的迭代器,接受参数分别为itemcallback,接下来我们看一下官方文档的描述

    iteratee - function - A function to apply to each item in coll. The iteratee is passed a callback(err, transformed) which must be called once it has completed with an error (which can be null) and a transformed item. Invoked with (item, callback).

    翻译一下,意思就是,迭代器参数列表中必须有callback,这个callback接受的参数为(err,transformed),在每个元素迭代操作完成后都会调用,用于将transformed传递给results,所有迭代器完成后,results会被传递给callback(mapLimit的最后一个参数)

  • 接受的callback为可选参数,会在所有迭代器完成后执行

理解了这些,下面的代码就很容易理解了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
var getBookStart = function() {
console.log('filteredTagPageUrl:' + filteredTagPageUrl.length);
//利用async.mapLimit指定并发数量,调用catchBook获取书本信息
async.mapLimit(filteredTagPageUrl, 5, function(el, callback) { catchBook(el, callback); }, function(err,results) {
console.log("complete!");
//所有页面采集任务结束后,等待500ms后将数据写入文件
setTimeout(WriteFile, 500);
});
}

/**
* [catchBook]
* @param {String} el [页面url]
* @param {Function} callback [mapLimit回调触发]
*/
var catchBook = function(el, callback) {
var delay = parseInt((Math.random() * 30000000) % 1000, 10); //设置延迟时长,模拟人为操作
request.get(encodeURI(el)).proxy(proxyUrl).end(function(err, res) {
if (err)
console.error('无效链接');
else {
var $ = cheerio.load(res.text);
for (var i = 1; i <= 20; i++) { //对每页的二十本书进行内容抓取
var bookOP;
/**
* 利用正则表达式处理抓取信息,这里不赘述
* 欢迎查看github上的源码
*/
catchData.push(bookOP);
}
}
});
setTimeout(function() {
//触发mapLimit回调
callback(null, el);
}, delay);
}

/**
* [WriteFile 写入文件]
*/
var WriteFile = function() {
fs.writeFile('./data/' + blockName[blockCount] + '.txt', catchData, (err) => {
if (err) throw err;
else {
console.log(blockName[blockCount] + ' is saved!');
if (blockCount + 1 < blockName.length) {
//读取下一大类
setTimeout(function() {
blockCount++;
TagPageUrl = [];
filteredTagPageUrl = [];
catchData = [];
getFilteredTagUrl();
}, 3500);
}
}
});
}

最后的读写文件操作比较简单,也不进行赘述啦~

结语

没什么好结语的啦,可把我累坏了
欢迎follow我的githubhhh(虽然没啥东西)

相关资料及工具链接

用钱砸我