爬虫综合大作业——网易云音乐《Five Hours》爬虫&可视化分析

作业要求来自于https://edu.cnblogs.com/campus/gzcc/GZCC-16SE2/homework/3075

爬虫综合大作业

  1. 选择一个热点或者你感兴趣的主题。
  2. 选择爬取的对象与范围。
  3. 了解爬取对象的限制与约束。
  4. 爬取相应内容。
  5. 做数据分析与文本分析。
  6. 形成一篇文章,有说明、技术要点、有数据、有数据分析图形化展示与说明、文本分析图形化展示与说明。
  7. 文章公开发布。

1. 数据爬取

       我们本次爬取的对象是一首名为《five hours》的经典电音流行歌曲,Five Hours是Erick Orrosquieta于2014年4月发行的单曲,当年这首单曲就出现在奥地利,比利时,法国,荷兰,挪威,瑞典和瑞士的榜单中。

       作者Erick Orrosquieta,一般为人熟知的是他的艺名Deorro,2014年世界百大DJ排名十九位。1991年8月29日生于美国洛杉矶,墨西哥血统的音乐制作人,签约Ultra Records,2014年他创立了唱片公司Panda Funk。Deorro以前用过TON!C这个名字。Deorro的音乐倾向Melbourne Bounce风格,融合Dutch、 moombah, progressive、 house、贝斯等多种元素和风格,几分钟就可以点炸现场气氛。他的这首单曲“Five Hours”登顶Most Played on Dance Radio和Most Played on Top 40 radio Mixshows。

而在爬虫部分主要是调用官方API,本次用到的API主要有两个:

①获取评论:
http://music.163.com/api/v1/resource/comments/R_SO_4_{歌曲ID}?limit={每页限制数量}&offset={评论数总偏移}

②获取评论对应用户的信息:
https://music.163.com/api/v1/user/detail/{用户ID}

完成后的项目文件图如下:

1.1 评论爬取

具体代码如下:

 1 from urllib import request
 2 import json
 3 import pymysql
 4 from datetime import datetime
 5 import re
 6 
 7 ROOT_URL = 'http://music.163.com/api/v1/resource/comments/R_SO_4_%s?limit=%s&offset=%s'
 8 LIMIT_NUMS = 50    # 每页限制爬取数
 9 DATABASE = 'emp'    # 数据库名
10 TABLE = 'temp1'    # 数据库表名
11 # 数据表设计如下:
12 '''
13 commentId(varchar) 
14 content(text)         likedCount(int) 
15 userId(varchar)    time(datetime)
16 '''
17 PATTERN = re.compile(r'[\n\t\r\/]') # 替换掉评论中的特殊字符以防插入数据库时报错
18 
19 def getData(url):
20     if not url:
21         return None, None
22     headers = {
23             "User-Agent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36',
24             "Host": "music.163.com",
25 }
26     print('Crawling>>> ' + url)
27     try:
28         req = request.Request(url, headers=headers)
29         content = request.urlopen(req).read().decode("utf-8")
30         js = json.loads(content)
31         total = int(js['total'])
32         datas = []
33         for c in js['comments']:
34             data = dict()
35             data['commentId'] = c['commentId']
36             data['content'] = PATTERN.sub('', c['content'])
37             data['time'] = datetime.fromtimestamp(c['time']//1000)
38             data['likedCount'] = c['likedCount']
39             data['userId'] = c['user']['userId']
40             datas.append(data)
41         return total, datas
42     except Exception as e:
43         print('Down err>>> ', e)
44         pass
45     
46 def saveData(data):
47     if not data:
48         return None
49     conn = pymysql.connect(host='localhost', user='root', passwd='123456', db='emp', charset='utf8mb4') # 注意字符集要设为utf8mb4,以支持存储评论中的emoji表情
50     cursor = conn.cursor()
51     sql = 'insert into ' + TABLE + ' (commentId,content,likedCount,time,userId) VALUES (%s,%s,%s,%s,%s)'
52     
53     for d in data:
54         
55         try:
56             #cursor.execute('SELECT max(c) FROM '+TABLE)
57             #id_ = cursor.fetchone()[0]
58             
59             cursor.execute(sql, (d['commentId'], d['content'], d['likedCount'], d['time'], d['userId']))
60             conn.commit()
61         except Exception as e:
62             print('mysql err>>> ',d['commentId'],e)
63             pass
64 
65     cursor.close()
66     conn.close()    
67     
68 if __name__ == '__main__':
69     songId = input('歌曲ID:').strip()
70     total,data = getData(ROOT_URL%(songId, LIMIT_NUMS, 0))
71     saveData(data)
72     if total:
73         for i in range(1, total//50+1):
74                 _, data = getData(ROOT_URL%(songId, LIMIT_NUMS, i*(LIMIT_NUMS)))
75                 saveData(data)

实际操作过程中,网易云官方对于API的请求是有限制的,有条件的可以采用更换代理IP来防反爬,而这一次作业在爬取数据的时候由于前期操作过度,导致被BAN IP,数据无法获取,之后是通过挂载虚拟IP才实现数据爬取的。

本次采用的是单线程爬取,所以IP封的并不太频繁,后面会对代码进行重构,实现多线程+更换IP来加快爬取速度。

根据获取评论的API,请求URL有3个可变部分:每页限制数limit和评论总偏移量offset,通过API分析得知:当offeset=0时,返回json数据中包含有评论总数量total。

本次共爬取5394条数据(避免盲目多爬被封ID)

 

1.2 用户信息爬取

具体代码如下:

 1 from urllib import request
 2 import json
 3 import pymysql
 4 import re
 5 
 6 ROOT_URL = 'https://music.163.com/api/v1/user/detail/'
 7 DATABASE = 'emp'
 8 TABLE_USERS = 'temp2'
 9 TABLE_COMMENTS = 'temp1'
10 # 数据表设计如下:
11 '''
12 id(int)                userId(varchar) 
13 gender(char)         userName(varchar) 
14 age(int)            level(int)            
15 city(varchar)        sign(text)            
16 eventCount(int)    followedCount(int)    
17 followsCount(int)    recordCount(int)    
18 avatar(varchar)
19 '''
20 PATTERN = re.compile(r'[\n\t\r\/]') # 替换掉签名中的特殊字符以防插入数据库时报错
21 headers = {
22             "User-Agent": 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.80 Safari/537.36',
23             "Host": "music.163.com",
24 }
25 def getData(url):
26     if not url:
27         return None
28     print('Crawling>>> ' + url)
29     try:
30         req = request.Request(url, headers=headers)
31         content = request.urlopen(req).read().decode("utf-8")
32         js = json.loads(content)
33         data = {}
34         if js['code'] == 200:
35             data['userId'] = js['profile']['userId']
36             data['userName'] = js['profile']['nickname']
37             data['avatar'] = js['profile']['avatarUrl']
38             data['gender'] = js['profile']['gender']
39             if int(js['profile']['birthday'])<0:
40                 data['age'] = 0
41             else:
42                 data['age'] =(2018-1970)-(int(js['profile']['birthday'])//(1000*365*24*3600))
43             if int(data['age'])<0:
44                 data['age'] = 0
45             data['level'] = js['level']
46             data['sign'] = PATTERN.sub(' ', js['profile']['signature'])
47             data['eventCount'] = js['profile']['eventCount']
48             data['followsCount'] = js['profile']['follows']
49             data['followedCount'] = js['profile']['followeds']
50             data['city'] = js['profile']['city']
51             data['recordCount'] = js['listenSongs']
52             
53             saveData(data)
54     except Exception as e:
55         print('Down err>>> ', e)
56         pass
57     return None
58 
59 def saveData(data):
60     if not data:
61         return None
62     conn = pymysql.connect(host='localhost', user='root', passwd='123456', db='emp', charset='utf8mb4') # 注意字符集要设为utf8mb4,以支持存储签名中的emoji表情
63     cursor = conn.cursor()
64     sql = 'insert into ' + TABLE_USERS + ' (userName,gender,age,level,city,sign,eventCount,followsCount,followedCount,recordCount,avatar,userId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)'
65     try:
66         
67         
68         cursor.execute(sql, (data['userName'],data['gender'],data['age'],data['level'],data['city'],data['sign'],data['eventCount'],data['followsCount'],data['followedCount'],data['recordCount'],data['avatar'],data['userId']))
69         conn.commit()
70     except Exception as e:
71         print('mysql err>>> ',data['userId'],e)
72         pass
73     finally:
74         cursor.close()
75         conn.close()    
76 
77 def getID():
78     conn = pymysql.connect(host='localhost', user='root', passwd='123456', db='emp', charset='utf8mb4')
79     cursor = conn.cursor()
80     sql = 'SELECT userId FROM '+TABLE_COMMENTS
81     try:
82         cursor.execute(sql)
83         res = cursor.fetchall()
84         return res
85     except Exception as e:
86         print('get err>>> ', e)
87         pass
88     finally:
89         cursor.close()
90         conn.close()
91     return None
92 
93 if __name__ == '__main__':
94     usersID = getID()
95     for i in usersID:
96         getData(ROOT_URL+i[0].strip())
97         

根据获取用户信息的API,请求URL有1个可变部分:用户ID,前一部分已经将每条评论对应的用户ID也存储下来,这里只需要从数据库取用户ID并抓取信息即可(对应以上的5394条数据)。

 

至此,已经完成了歌曲评论和对应用户信息的抓取。接下来,对抓取到的数据进行清洗及可视化分析。

 

2 数据清洗 & 可视化

处理代码如下:

  1 import pandas as pd
  2 import pymysql
  3 from pyecharts import Bar,Pie,Line,Scatter,Map
  4 
  5 TABLE_COMMENTS = 'temp1'
  6 TABLE_USERS = 'temp2'
  7 DATABASE = 'emp'
  8 
  9 conn = pymysql.connect(host='localhost', user='root', passwd='123456', db='emp', charset='utf8mb4')
 10 sql_users = 'SELECT id,gender,age,city FROM '+TABLE_USERS
 11 sql_comments = 'SELECT id,time FROM '+TABLE_COMMENTS
 12 comments = pd.read_sql(sql_comments, con=conn)
 13 users = pd.read_sql(sql_users, con=conn)
 14 
 15 # 评论时间(按天)分布分析
 16 comments_day = comments['time'].dt.date
 17 data = comments_day.id.groupby(comments_day['time']).count()
 18 line = Line('评论时间(按天)分布')
 19 line.use_theme('dark')
 20 line.add(
 21     '',
 22     data.index.values,
 23     data.values,
 24     is_fill=True,
 25 )
 26 line.render(r'./评论时间(按天)分布.html')
 27 # 评论时间(按小时)分布分析
 28 comments_hour = comments['time'].dt.hour
 29 data = comments_hour.id.groupby(comments_hour['time']).count()
 30 line = Line('评论时间(按小时)分布')
 31 line.use_theme('dark')
 32 line.add(
 33     '',
 34     data.index.values,
 35     data.values,
 36     is_fill=True,
 37 )
 38 line.render(r'./评论时间(按小时)分布.html')
 39 # 评论时间(按周)分布分析
 40 comments_week = comments['time'].dt.dayofweek
 41 data = comments_week.id.groupby(comments_week['time']).count()
 42 line = Line('评论时间(按周)分布')
 43 line.use_theme('dark')
 44 line.add(
 45     '',
 46     data.index.values,
 47     data.values,
 48     is_fill=True,
 49 )
 50 line.render(r'./评论时间(按周)分布.html')
 51 
 52 # 用户年龄分布分析
 53 age = users[users['age']>0]    # 清洗掉年龄小于1的数据
 54 age = age.id.groupby(age['age']).count()    # 以年龄值对数据分组
 55 Bar = Bar('用户年龄分布')
 56 Bar.use_theme('dark')
 57 Bar.add(
 58     '',
 59     age.index.values,
 60     age.values,
 61     is_fill=True,
 62 )
 63 Bar.render(r'./用户年龄分布图.html')    # 生成渲染的html文件
 64 
 65 # 用户地区分布分析
 66 # 城市code编码转换
 67 def city_group(cityCode):
 68     city_map = {
 69         '11': '北京',
 70         '12': '天津',
 71         '31': '上海',
 72         '50': '重庆',
 73         '5e': '重庆',
 74         '81': '香港',
 75         '82': '澳门',
 76         '13': '河北',
 77         '14': '山西',
 78         '15': '内蒙古',
 79         '21': '辽宁',
 80         '22': '吉林',
 81         '23': '黑龙江',
 82         '32': '江苏',
 83         '33': '浙江',
 84         '34': '安徽',
 85         '35': '福建',
 86         '36': '江西',
 87         '37': '山东',
 88         '41': '河南',
 89         '42': '湖北',
 90         '43': '湖南',
 91         '44': '广东',
 92         '45': '广西',
 93         '46': '海南',
 94         '51': '四川',
 95         '52': '贵州',
 96         '53': '云南',
 97         '54': '西藏',
 98         '61': '陕西',
 99         '62': '甘肃',
100         '63': '青海',
101         '64': '宁夏',
102         '65': '新疆',
103         '71': '台湾',
104         '10': '其他',
105     }
106     return city_map[cityCode[:2]]
107     
108 city = users['city'].apply(city_group)
109 city = city.id.groupby(city['city']).count()
110 map_ = Map('用户地区分布图')
111 map_.add(
112     '',
113     city.index.values,
114     city.values,
115     maptype='china',
116     is_visualmap=True,
117     visual_text_color='#000',
118     is_label_show=True,
119 )
120 map_.render(r'./用户地区分布图.html')

关于数据的清洗,实际上在上一部分抓取数据的过程中已经做了一部分,包括:后台返回的空用户信息、重复数据的去重等。除此之外,还要进行一些清洗:用户年龄错误、用户城市编码转换等。

关于数据的去重,评论部分可以以sommentId为数据库索引,利用数据库来自动去重;用户信息部分以用户ID为数据库索引实现自动去重。

①API返回的用户年龄一般是时间戳的形式(以毫秒计)、有时候也会返回一个负值或者一个大于当前时间的值,暂时没有找到这两种值代表的含义,故而一律按0来处理。

②API返回的用户信息中,城市分为province和city两个字段,本此分析中只保存了city字段。实际上字段值是一个城市code码

③在这部分,利用Python的数据处理库pandas进行数据处理,利用可视化库pyecharts进行数据可视化。

以上,是对抓取到的数据采用可视化库pyecharts进行可视化分析,得到的结果如下:

结论一:评论时间按周分布图可以看出,评论数在一周当中前面较少,后面逐渐增多,这可以解释为往后接近周末,大家有更多时间来听听歌、刷刷歌评,而一旦周末过完,评论量马上下降(周日到周一的下降过渡),大家又回归到工作当中。

 

结论二:评论时间按小时分布图可以看出,评论数在一天当中有两个小高峰:11点-13点和22点-0点。这可以解释为用户在中午午饭时间和晚上下班(课)在家时间有更多的时间来听歌刷评论,符合用户的日常。至于为什么早上没有出现一个小高峰,大概是早上大家都在抢时间上班(学),没有多少时间去刷评论。

结论三:用户年龄分布图可以看出,用户大多集中在14-30岁之间,以20岁左右居多,除去虚假年龄之外,这个年龄分布也符合网易云用户的年龄段。图中可以看出28岁有个高峰,猜测可能是包含了一些异常数据,有兴趣的化可以做进一步分析。

 

结论四:用户地区分布图可以看出,用户涵盖了全国各大省份,因为中间数据(坑)的缺失,并没有展现出哪个省份特别突出的情况。对别的歌评(完全数据)的可视化分析,可以看出明显的地区分布差异。用户地区分布图可以看出,用户涵盖了全国各大省份,因为中间数据的缺失,并没有展现出哪个省份特别突出的情况。对别的歌评(完全数据)的可视化分析,可以看出明显的地区分布差异。

细心观察评论数(按天)分布那张图,发现2017年到2018年间有很大一部分数据缺失,这实际上是因为在数据抓取过程中出现的问题。研究了一下发现,根据获取歌曲评论的API,实际上每首歌最多只能获得2w条左右(去重后)的评论,对于评论数超过2w的歌曲,只能获得前后(日期)各1w条评论,而且这个限制对于网易云官网也是存在的,具体表现为:对一首评论数超过2w的歌,如果一直往后浏览评论,会发现从第500页(网页端网易云每页20条评论)往后,后台返回的内容和第500页完全一样,从后往前同理。这应该是官方后台做了限制,连自家也不放过。。。

此次分析只是对某一首歌曲评论时间、用户年龄/地区分布进行的,实际上抓取到的信息不仅仅在于此,可以做进一步分析(比如利用评论内容进行文本内容分析等),这部分,未来会进一步分析。当然也可以根据自己情况对不同歌曲进行分析。

3.歌评文本分析

评论的文本分析做了两部分:情感分析和词云生成。
情感分析采用Python的文本分析库snownlp。具体代码如下:

 1 import numpy as np
 2 import pymysql
 3 from snownlp import SnowNLP
 4 from pyecharts import Bar
 5 
 6 TABLE_COMMENTS = 'temp1'
 7 DATABASE = 'emp'
 8 SONGNAME = 'five hours'
 9 
10 def getText():
11     conn = pymysql.connect(host='localhost', user='root', passwd='123456', db=DATABASE, charset='utf8')
12     sql = 'SELECT id,content FROM '+TABLE_COMMENTS
13     text = pd.read_sql(sql%(SONGNAME), con=conn)
14     return text
15 
16 def getSemi(text):
17     text['content'] = text['content'].apply(lambda x:round(SnowNLP(x).sentiments, 2))
18     semiscore = text.id.groupby(text['content']).count()
19     bar = Bar('评论情感得分')
20     bar.use_theme('dark')
21     bar.add(
22         '',
23         y_axis = semiscore.values,
24         x_axis = semiscore.index.values,
25         is_fill=True,
26     )
27     bar.render(r'情感得分分析.html')
28 
29     text['content'] = text['content'].apply(lambda x:1 if x>0.5 else -1)
30     semilabel = text.id.groupby(text['content']).count()
31     bar = Bar('评论情感标签')
32     bar.use_theme('dark')
33     bar.add(
34         '',
35         y_axis = semilabel.values,
36         x_axis = semilabel.index.values,
37         is_fill=True,
38     )
39     bar.render(r'情感标签分析.html')

结果:

词云生成采用jieba分词库分词,wordcloud生成词云,具体代码如下:

 1 from wordcloud import WordCloud
 2 import matplotlib.pyplot as plt
 3 plt.style.use('ggplot')
 4 plt.rcParams['axes.unicode_minus'] = False
 5 
 6 def getWordcloud(text):
 7     text = ''.join(str(s) for s in text['content'] if s)
 8     word_list = jieba.cut(text, cut_all=False)
 9     stopwords = [line.strip() for line in open(r'./StopWords.txt', 'r').readlines()]    # 导入停用词
10     clean_list = [seg for seg in word_list if seg not in stopwords] #去除停用词
11     clean_text = ''.join(clean_list)
12     # 生成词云
13     cloud = WordCloud(
14         font_path = r'C:/Windows/Fonts/msyh.ttc',
15         background_color = 'white',
16         max_words = 800,
17         max_font_size = 64
18     )
19     word_cloud = cloud.generate(clean_text)
20     # 绘制词云
21     plt.figure(figsize=(12, 12))
22     plt.imshow(word_cloud)
23     plt.axis('off')
24     plt.show()
25     
26 if __name__ == '__main__':
27     text = getText()
28     getSemi(text)
29     getWordcloud(text)

词云:

 

posted @ 2019-04-28 19:30  黄观国  阅读(2144)  评论(0编辑  收藏  举报