项目计划
项目介绍
本项目基于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我们发现这就是我们需要爬取的数据!
施工过程
爬虫开发和优化
首先话不多说,先引用需要的模块,随后直接写出superagent的get
1 | const superagent = require('superagent'); |
优化后的模板
1 | var options = [], //创建的一个数组,存储所爬取网页的地址 |
第1坑:数据无法爬下?
按照常规来说可以在superagent.get('目标网页')后.end()
里的回调函数function,可以直接通过
1 | var $ = cheerio.load(data.text) |
根据爬取网页的查看元素,发现需求的数据以json形式存储在<pre>
标签之中
本应该按如下获取数据
1 | var sj = $('pre').eq(0).text() |
实际效果返回的值却为null,这是因为部分大网站的数据是用js输出至页面标签,既然这样无法获取的话,我采取了另外一种办法
1 | var sj = $('body').text(); |
这样的效果就是直接获取当前页面数据,在将其转为json数组,成功获取数据
第2坑:ip数据异常被豆瓣限制访问?
下述的ip代理方法完全可行,在不考虑2.1.3的情况下(部分ip可能存在质量不高连接不稳定情况),完全足够配合superagent完成爬取工作网站会因为你并发请求数太多当做是在恶意请求,封掉你的ip,为了防止这种情况的发生,这里可以使用一款插件superagent-proxy进行ip代理
1 | npm install superagent-proxy --save |
这里发出superagent-proxy的官方文档,内含详细配置信息设置代理的代理ip,可以选择使用免费的代理服务器帮我们爬数据 (感恩)
1 | require('superagent-proxy')(superagent); |
OK,现在,配置完成后我的index.js内容变为如上。
大修改:因为考虑到代理ip的不稳定性,我们采用另外一种方式:使用async.mapLimit控制请求并发
网站会因为你并发请求数太多当做是在恶意请求,封掉你的ip,为了防止这种情况的发生,我们一般会在代码里控制并发请求数,Node里面一般借助async
模块来实现
机缘巧合之下发现了blogNode爬虫之——使用async.mapLimit控制请求并发,在这里有部分内容采取了借鉴
1 | -options:创建的一个数组,存储所爬取网页的地址 |
第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 | for(let startnumber = 0; startnumber < 100; startnumber += 20) { |
面对新的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,不可取,移除!
当n = 0
后停止,并不会触发外部async.mapLimit
的回调函数存储数据,这里添加if(n == 0){……}
是为了数据不足内部存储,若正常结束便外部存储
1 | 数据获取后…… |
修改过后的代码如下:
1 | 数据获取后…… |
第4坑:豆瓣数据start不能超过9979?存储数据仅有9999条
换个思路:一个国家的电影每年肯定不会超过10k部,于是加入&year_range = 20xx,20xx
的限制,获取每一年的数据,最后可以将各数据(JSON)拼接起来,获得总的数据。
但考虑到项目部分需求的限制,在此我会创建n个数据表存取相应的数据,问题不大。
数据清洗和存储
数据清洗
OK,通过上述办法我们已经获得了一个json数组,现在进行的是清洗数据,将需求之外的无效数据删除避免占用数据库内存
1 | var length = movieItems.data.length; |
这个length
的设置就相当有灵性,因为对json字符串利用正则表达式进行数据清洗时,只会进行1次,这时候外面套一个for循环就可以清除掉所有的无效数据,另外length
还有一个效果就是进行判定(页面均为每次发送20个电影数据),当length < 20
时即可说明本次爬取数据结束,是一个结束的标志。
1 | for (i = 0; i < length; i++) { |
数据存储
SAVE to JSON
先将获取的数据writeFile
简单文本写入至/data目录下的json文件中
1 | fs.writeFile(target_dir, JSON.stringify(dataArr), 'utf-8', function (err) { |
SAVE to MongoDB
这里另开一个js专门用来将json数据存储进MongoDB,首先利用fs的readFile
简单文本读取。
这里的db_name
和target_dir
分开书写,是为了方便更改读数据和存数据的文件目录。
因为有31个数据库,要是嫌麻烦(懒)得话可以简单来个循环,获取文件名字存入数组依次为db_name
赋值,这里不做细讲
1 | var db_name = '2019CN' |
再将其存入数据库sxProduct3
中的数据表db_name
1 | var mongo = require("mongodb"); |
OKOK,我们可以清楚看到数据存储进了数据库,但接下来利用循环存储的时候竟然出现了大问题!
1 | for(var item in dataArr){ |
哈哈,经过最近的沉淀学习可以轻松看出是因为存储的时候是异步执行,数据尚未存储完毕时,nowdata
却已重新赋值。OKOK利用上面的思路,给它来之前的控制请求并发,我们给他设置为1次,这样就能如下完美解决。
网页界面和跳转
很流畅的到达本施工项目最轻松无脑的一步,简单写一个静态界面(这里为ejs),启动服务器查看效果如下
接下来我们要做的给它做几个简单的js:
为左边的slide添加onclick
事件绑定类名checked
,达到已选择效果。1
2
3var 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
9exports.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
。 data
由res.write()
进行返回。1
2
3
4
5
6
7
8
9
10
11
12titleBox.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("");
数据查询和可视化
数据读取
LOAD from MongoDB
无论哪条路由,都是需要链接本地的数据库,而这完全可以统一为同一个模板,连接数据库读取数据。
数据查询就靠find({……})
来实现,而查询后的所需要的数据result
靠res.write()
以json字符串的形式传回主页面。
虽说可以多次res.write()
传出数据,不过为了更好的插入至ECharts中的options.xxx.data
,我习惯在读取的时候建立一个数组dataBox
,将查询结果push
进数组,最后传出。
1 | MongoClient.connect(url, { |
数据查询
根据需求进行相应的数据查询:
查找数量?.find({}).count(回调)
查询评分于0-5之间?.find({'$where': 'this.rate<5&&this.rate>0'}).toArray(回调)
注意:数据中的评分rate
的格式为string
,比较需要进行类型转换parseFloat
‘
查询多个评分时,可以采取比较落后的办法for循环
1 | var arr = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; |
数据可视化
根据我写的函数定义function fuck(picId,picData){……}
,将传入进来的nowIndex
判断出渲染的图像类型,将data
通过一系列的for循环添加至tempOptions
。如下:
1 | var picData = JSON.parse(picData) |
这里的格式全部为:
1 | //生成图表配置 |
第1坑:仅显示第一次图像?
在我的fuck()
函数里第一次点击可以成功展示,但再次点击时抛出Error: There is a chart instance already initialized on the dom!
这里仅需将model
给变为全局变量即可。
1 | var modeldom = document.getElementById('model'); |
还有一种情况也会无法显示:就是渲染的div#model
需要进行清除,在每次调用fuck()
函数时,给它添加一条
1 | model.clear(); |
第2坑:旭日图数据重叠?
通过将树状结构的类型数据添加至配置过后,刷新发现文字重叠?多次检查害我没吃午饭数据插入位置情况以及数据类型是否改变,并未有收获,然后在恍惚之中发现官方文档里的value: x
这条属性,它会确定当前元素所占用的格子长度,父元素会随着子元素自动撑开,然而我这里父元素也存在value: 1
,所以刚好数据全部往后移1位,导致文字重叠。
其他说明
修改记录
- 19.08.29
- 项目结束
- 完成数据可视化
- 19.08.27
- 添加2.3,修改部分2.4
- 修改文档格式规范
- 调试页面路由
- 19.08.26
- 添加2.1.4,修改2.2.2
- 已爬取09-19年各国电影数据,均存入数据库
- 19.08.24
- 修改2.1.2,2.1.3
- 利用async.mapLimit控制请求并发
- request代替superagent
- 19.08.23 创建该文档
参考链接
superagent-proxy官方文档
Node爬虫之——使用async.mapLimit控制请求并发
async.mapLimit 并发请求限制的一点实践
MongoDB常用操作一查询find方法db.collection_name.find()
mongodb 查询大于某个字符串数值
ECharts官方文档