记录爬虫豆瓣电影数据可视化

项目计划

ac8ac4d122f58ad3f596153185e8de1f.gif

项目介绍

本项目基于node.js,采用 superagent request爬取豆瓣电影数据,存储进MongoDB,最后能通过数据查询,利用ECharts进行数据可视化处理,显示影评分和电影数量分析,影评分和电影数量对比分析,近十年每年发行的电影分析,2019各个国家发行的电影数量分析,近十年中国发行的电影评分,近十年中国发行的电影每一年的数量分布图,2019年评分在5分以下的电影名称……

额外说明

含有删除线的内容是项目最初采用的技术,因为部分特殊原因(如:无法满足相应的需求,可以进一步优化功能……)而采用其他技术替代

  • 弃用superagnet的原因:①代理ip的不稳定性②部分逻辑功能无法实现(能力有限哈哈)
    涉及到的内容:无法处理2.1.3,需要优化2.1.2

需求分析

  • 需要什么数据?
    数据中的directors导演、rate评分、title电影名、国家地区(通过url链接访问再次爬取)?可以在传送数据前先将countries分类确定好
    获取数据存放MongoDB数据库
    界面你懂得……按取不同btn会用ECharts显示不同数据
  • 如何获取(从哪里去爬)?
    豆瓣电影里的 分类 界面中,选择形式:电影 、年代:2019 、国家地区……
    我们发现只有往下拉点击加载更多的时候页面才会传输数据,加载新的电影信息,所以直接爬取页面数据看来是肯定不行的。那换个方式看:打开f12看Network,加载更多的时候会发现服务器传来一个数据,点开url我们发现这就是我们需要爬取的数据!
    753a067aa010751e2601bad0990feca4.png

施工过程

爬虫开发和优化

首先话不多说,先引用需要的模块,随后直接写出superagent的get

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const superagent = require('superagent');


superagent.get("https://movie.douban.com/j/new_search_subjects?tags=movie&start=0&year_range=2009,2019")
.end(onresponse);

function onresponse(err, data) {
if (err) {
console.log(err);
} else {
……
爬虫
}
}

优化后的模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var options = [],   //创建的一个数组,存储所爬取网页的地址
n = 0, //n代表并发请求数,最大值由上面的bfnumber决定
bfnumber = 3 //控制并发生成数的最大值

function spider(spider(option, callback) {
n++;
request(option, function (err, res, data) {
if (!err) {
……
爬虫
}else{
console.log(err);
n--;
callback(err, null);
}
}
}

第1坑:数据无法爬下?

按照常规来说可以在superagent.get('目标网页')后.end()里的回调函数function,可以直接通过

1
var $ = cheerio.load(data.text)

9e4e3fa14e5afdd27dbe67841bc2ad4a.png
根据爬取网页的查看元素,发现需求的数据以json形式存储在<pre>标签之中
本应该按如下获取数据

1
var sj = $('pre').eq(0).text()

实际效果返回的值却为null,这是因为部分大网站的数据是用js输出至页面标签,既然这样无法获取的话,我采取了另外一种办法

1
2
var sj = $('body').text();
var movieItems = JSON.parse(sj);

这样的效果就是直接获取当前页面数据,在将其转为json数组,成功获取数据

第2坑:ip数据异常被豆瓣限制访问?

下述的ip代理方法完全可行,在不考虑2.1.3的情况下(部分ip可能存在质量不高连接不稳定情况),完全足够配合superagent完成爬取工作
网站会因为你并发请求数太多当做是在恶意请求,封掉你的ip,为了防止这种情况的发生,这里可以使用一款插件superagent-proxy进行ip代理

1
npm install superagent-proxy --save

这里发出superagent-proxy的官方文档,内含详细配置信息
设置代理的代理ip,可以选择使用免费的代理服务器帮我们爬数据 (感恩)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
require('superagent-proxy')(superagent);

var proxy = 'http://60.205.229.126:80'; //设置代理

var header = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.6',
'User-Agent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Mobile Safari/537.36',
'Cache-Control': 'max-age=0',
'Connection': 'keep-alive'
};


superagent.get("https://movie.douban.com/j/new_search_subjects?tags=movie&start=0&year_range=2009,2019")
.set('header', header)
.proxy(proxy)
.end(onresponse);

OK,现在,配置完成后我的index.js内容变为如上。

大修改:因为考虑到代理ip的不稳定性,我们采用另外一种方式:使用async.mapLimit控制请求并发
网站会因为你并发请求数太多当做是在恶意请求,封掉你的ip,为了防止这种情况的发生,我们一般会在代码里控制并发请求数,Node里面一般借助async模块来实现
机缘巧合之下发现了blogNode爬虫之——使用async.mapLimit控制请求并发,在这里有部分内容采取了借鉴

1
2
3
4
5
6
7
8
9
10
11
12
13
-options:创建的一个数组,存储所爬取网页的地址
-bfnumber:控制并发生成数的最大值,这里我取得3
-spider:爬虫函数
-callback:回调函数,执行完spider后可以在此保存数据

async.mapLimit(options, bfnumber, spider.bind(this), function (err, result) {
if (err) {
console.log(err);
} else {
……
数据存储
}
});

第3坑:如何书写逻辑检测数据爬取完毕?

https:// movie.douban.com/j/new_search_subjects?tags=movie& start=0 &year_range=2009,2019

注意这是第一次的数据,其中路由中有个start为0,当start = ?改变后它就会从第?个开始发送20个数据,所以我们首先获取数据就是不断的改变start = startnumber这个数值。
这里有个前提就是,豆瓣是利用json每次传输20条电影数据,所以当本次数据长度length < 20时即可说明本次爬取数据结束。那如何做到效果,这成了一个难题?
首先想到的是,外面套一个无限循环for循环(无法通过回调函数结束外部循环)

1
2
3
4
for(let startnumber = 0; startnumber < 100; startnumber += 20) {
superagent.get("https://movie.douban.com/j/new_search_subjects?tags=movie&start=" + startnumber + "&year_range=2009,2019")
……
}

面对新的function,尝试进行逐步调试。(推荐的调试方法:先从特殊再到普通) 找特殊情节,例如tags=movie时仅有131条数据,这里既可以判断是否爬取,也可以判断是否length < 20能够结束
length ≠ 20的时候拒绝callback,然后发现它会从最后1个length < 20的页面,继续爬取bfnumber - 1次数据,获得[]的空白数据。这里当start = 120开始,会继续爬取start = 140,160的数据。所以当length = 0的时候,我们拒绝将该数据存入dataArr

坑中遇到的疑问


多此一举的保险?(感觉没用):我甚至为每次进入request请求,var了一个index(正则获取当前url的start后页码/20获得下标),一旦不满足长度=20,便把options数组从当前index+bfnumber后的内容全部清除,避免查询无效页面。此举目的:因为options是最初通过for循环自己添加网址地址,在不清楚后续多少的情况下,直接放入大量(10000/20)条地址。实际效果:能删除,但不n–会抛出err,无法停止,目前仅能通过不再callback来达到停止。
↑上诉方法确实没用,今日爬取数据中存在一个问题:部分页面并没有存储20个数据,而下一页将会继续存储,这就导致了提前删除后面存放数据的页面的地址会抛出err,不可取,移除!

af6c663b18a70a4c1a5b322ada27f947.png
n = 0后停止,并不会触发外部async.mapLimit的回调函数存储数据,这里添加if(n == 0){……}是为了数据不足内部存储,若正常结束便外部存储

1
2
3
数据获取后……
n--;
callback(null, 'done!');

修改过后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
数据获取后……
if (length != 0) {
dataArr.push(ajson);
}
n--;
if (length == 20) {
callback(null, 'done!');
} else {
options.splice(nowIndex + bfnumber, options.length + 1 - nowIndex - bfnumber) //删除后面的地址数组
if (n == 0) {
……
数据存储
}
}

第4坑:豆瓣数据start不能超过9979?存储数据仅有9999条

换个思路:一个国家的电影每年肯定不会超过10k部,于是加入&year_range = 20xx,20xx的限制,获取每一年的数据,最后可以将各数据(JSON)拼接起来,获得总的数据。
但考虑到项目部分需求的限制,在此我会创建n个数据表存取相应的数据,问题不大。

数据清洗和存储

数据清洗

OK,通过上述办法我们已经获得了一个json数组,现在进行的是清洗数据,将需求之外的无效数据删除避免占用数据库内存

1
2
var length = movieItems.data.length;
movieItems = JSON.stringify(movieItems.data)

这个length的设置就相当有灵性,因为对json字符串利用正则表达式进行数据清洗时,只会进行1次,这时候外面套一个for循环就可以清除掉所有的无效数据,另外length还有一个效果就是进行判定(页面均为每次发送20个电影数据),当length < 20时即可说明本次爬取数据结束,是一个结束的标志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for (i = 0; i < length; i++) {
var Regstar = /,"(star)":"(\d)*"/
movieItems = movieItems.replace(Regstar, "");
var Regcoverx = /,"(cover_x)":"?(\d)*"?/
movieItems = movieItems.replace(Regcoverx, "");
var Regcovery = /,"(cover_y)":"?(\d)*"?/
movieItems = movieItems.replace(Regcovery, "");
var Regcover = /,"(cover)":"[\w:\/.]*"/
movieItems = movieItems.replace(Regcover, "");
var Regid = /,"(id)":"(\d)*"/
movieItems = movieItems.replace(Regid, "");
var Regurl = /,"(url)":"[\w:\/.]*"/
movieItems = movieItems.replace(Regurl, "");
}

数据存储

SAVE to JSON

先将获取的数据writeFile简单文本写入至/data目录下的json文件中

1
2
3
4
5
6
7
fs.writeFile(target_dir, JSON.stringify(dataArr), 'utf-8', function (err) {
if (err) {
console.log('数据写入失败……');
} else {
console.log('数据写入成功……');
}
})

fb2e0791c032c10edc5fb1af5dd8015d.png

SAVE to MongoDB

这里另开一个js专门用来将json数据存储进MongoDB,首先利用fs的readFile简单文本读取。
这里的db_nametarget_dir分开书写,是为了方便更改读数据和存数据的文件目录。
因为有31个数据库,要是嫌麻烦(懒)得话可以简单来个循环,获取文件名字存入数组依次为db_name赋值,这里不做细讲

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var db_name = '2019CN'
var target_dir = 'data/' + db_name + '.json';


fs.readFile(target_dir, function (err, data) {
if (err) throw err;
console.log(data) //此时是一个Buffer对象
var savedata = data.toString()
console.log(typeof (savedata))//object
var dataArr = JSON.parse(savedata)

读数据完毕
……(存数据)
})

再将其存入数据库sxProduct3中的数据表db_name

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var mongo = require("mongodb");
var MongoClient = mongo.MongoClient;
var url = "mongodb://localhost:27017/";

//测试:先1个再多个
nowdata = dataArr[0];

MongoClient.connect(url, {
useNewUrlParser: true,
useUnifiedTopology: true
}, function (err, db) {
if (err) {
console.log("数据库连接失败...");
}
var dbo = db.db("sxProduct3");
dbo.collection(db_name).insertMany(nowdata, function (err, res) {
if (err) {
console.log("数据导入失败...");
throw err;
}
console.log("数据插入成功……");
db.close();
});
});

2cd32dc94e206e69e9790eb0701d488c.png
OKOK,我们可以清楚看到数据存储进了数据库,但接下来利用循环存储的时候竟然出现了大问题!

1
2
3
4
for(var item in dataArr){
nowdata = dataArr[item];
……
}

78a58c7bce5fd95c2715633aa5a1b5ad.png
哈哈,经过最近的沉淀学习可以轻松看出是因为存储的时候是异步执行,数据尚未存储完毕时,nowdata却已重新赋值。OKOK利用上面的思路,给它来之前的控制请求并发,我们给他设置为1次,这样就能如下完美解决。
06a1c659dc1ea2ce0f3c0413ad89e670.png

网页界面和跳转

很流畅的到达本施工项目最轻松无脑的一步,简单写一个静态界面(这里为ejs),启动服务器查看效果如下
7ed8b3fe8684302f1a5ccc6f84e3f06e.png
接下来我们要做的给它做几个简单的js:

  • 为左边的slide添加onclick事件绑定类名checked,达到已选择效果。

    1
    2
    3
    var urlindex = window.location.href;
    urlindex = parseInt(urlindex.substr(-1, 1));
    titleBox.eq(urlindex).addClass("checked");
  • 同时href跳转至相应的界面,例如(http://127.0.0.1:8000/dataPic0),随后我们在index.js中获取相应路由。

    1
    2
    3
    4
    5
    6
    7
    8
    //index.ejs
    titleBox.click(function () {
    location.href = '/dataPic' + nowIndex;
    }

    - - - - - - - - - -
    //index.js
    app.get("/dataPic:picId", model.dataPic);
  • model.js暴露函数,带着新的nowID重新渲染index.ejs,这个时候像预计存放图像的<div>中传回数值<%= nowID %>

    1
    2
    3
    4
    5
    6
    7
    8
    9
    exports.dataPic = function (req, res) {
    var nowID = req.params.picId;
    switch (nowID) {
    ……
    }
    res.render("index", {
    nowID: nowID
    });
    }
  • 于另外一个main.js中进行数据可视化,首先做测试看是否能通过nowID的不同,动态渲染当前网页。

    1
    2
    3
    4
    5
    6
    7
    8
    //基于准备好的dom,初始化echarts实例
    var modeldom = document.getElementById('model')
    var picId = parseInt(modeldom.innerText)
    const model = echarts.init(modeldom);//注意:这一部会清除#model内的内容会导致picId获取为NaN,所以要放在下面
    switch (picId) {
    ……
    判定=>渲染
    }

完成上述步骤,检验,大功告成!
大修改:上述方法 会使后面不能同时调取 main.js和读取 data,读取数据库需要 require加载,但 require通过node打开,所以这里我们暂时只好 合并index.js和model.js。这次不能通过switch的方式判断路由偷懒,因为每个路由的数据查询不一样,所以分别设置8个app.get("/dataPic0", function (req, res) {……}更好。
所以index.ejs中的onclick函数需要相应修改:

  • 修改checked的方式。
  • location.href链接跳转变更为$.get()请求(听说这就是AJAX?不太了解),由回调函数返回data
  • datares.write()进行返回。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    titleBox.click(function () {
    $(this).siblings().removeClass("checked");
    $(this).addClass("checked");
    var nowIndex = $(this).index();
    $.get("http://127.0.0.1:8000/dataPic" + nowIndex, function (data) {
    fuck(nowIndex,data); //启动ECharts函数渲染
    })
    })

    - - - - - - - - - -
    res.write(result);
    res.end("");

03ec0b536d21c17aa7abfc8a775f7b10.png

数据查询和可视化

数据读取

LOAD from MongoDB

无论哪条路由,都是需要链接本地的数据库,而这完全可以统一为同一个模板,连接数据库读取数据。
数据查询就靠find({……})来实现,而查询后的所需要的数据resultres.write()以json字符串的形式传回主页面。
虽说可以多次res.write()传出数据,不过为了更好的插入至ECharts中的options.xxx.data,我习惯在读取的时候建立一个数组dataBox,将查询结果push进数组,最后传出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MongoClient.connect(url, {
useNewUrlParser: true,
useUnifiedTopology: true
}, function (err, db) {
if (err) throw err;
var dbo = db.db("sxProduct3");
dbo.collection("2019CN").find({}).toArray(function (err, result) {
if (err) throw err;
db.close(); //关闭数据库
res.write(JSON.stringify(result));
res.end("");
});
});
});

数据查询

根据需求进行相应的数据查询:
查找数量?.find({}).count(回调)
查询评分于0-5之间?.find({'$where': 'this.rate<5&&this.rate>0'}).toArray(回调)
注意:数据中的评分rate的格式为string,比较需要进行类型转换parseFloat
查询多个评分时,可以采取比较落后的办法for循环

1
2
3
4
5
6
7
var arr = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
for (var item in result) {
var x = parseFloat(result[item].rate);
if (9.5 < x && x <= 10) {
}else if……
return arr;
}

数据可视化

根据我写的函数定义function fuck(picId,picData){……},将传入进来的nowIndex判断出渲染的图像类型,将data通过一系列的for循环添加至tempOptions。如下:

1
2
3
4
5
6
7
var picData = JSON.parse(picData)
for (var i in picData) {
var nowData = picData[i]
for (var item in nowData) {
tempOptions.series[i].data.push(nowData[item]);
}
}

这里的格式全部为:

1
2
3
4
5
6
//生成图表配置
var tempOptions = {……}
//插入数据
……
//渲染数据
model.setOption(tempOptions);

第1坑:仅显示第一次图像?

在我的fuck()函数里第一次点击可以成功展示,但再次点击时抛出Error: There is a chart instance already initialized on the dom!
这里仅需将model给变为全局变量即可。

1
2
var modeldom = document.getElementById('model');
const model = echarts.init(modeldom);

还有一种情况也会无法显示:就是渲染的div#model需要进行清除,在每次调用fuck()函数时,给它添加一条

1
model.clear();

第2坑:旭日图数据重叠?

通过将树状结构的类型数据添加至配置过后,刷新发现文字重叠?多次检查害我没吃午饭数据插入位置情况以及数据类型是否改变,并未有收获,然后在恍惚之中发现官方文档里的value: x这条属性,它会确定当前元素所占用的格子长度,父元素会随着子元素自动撑开,然而我这里父元素也存在value: 1,所以刚好数据全部往后移1位,导致文字重叠。
30337f6a8b074543652d3ff0a5de4ea8.png

其他说明

修改记录

  • 19.08.29
    1. 项目结束
    2. 完成数据可视化
  • 19.08.27
    1. 添加2.3,修改部分2.4
    2. 修改文档格式规范
    3. 调试页面路由
  • 19.08.26
    1. 添加2.1.4,修改2.2.2
    2. 已爬取09-19年各国电影数据,均存入数据库
  • 19.08.24
    1. 修改2.1.2,2.1.3
    2. 利用async.mapLimit控制请求并发
    3. request代替superagent
  • 19.08.23 创建该文档

参考链接

superagent-proxy官方文档
Node爬虫之——使用async.mapLimit控制请求并发
async.mapLimit 并发请求限制的一点实践
MongoDB常用操作一查询find方法db.collection_name.find()
mongodb 查询大于某个字符串数值
ECharts官方文档

坚持原创技术分享,感谢您的投喂~