作业要求来自于https://edu.cnblogs.com/campus/gzcc/GZCC-16SE2/homework/3075
爬虫综合大作业
- 选择一个热点或者你感兴趣的主题。
- 选择爬取的对象与范围。
- 了解爬取对象的限制与约束。
- 爬取相应内容。
- 做数据分析与文本分析。
- 形成一篇文章,有说明、技术要点、有数据、有数据分析图形化展示与说明、文本分析图形化展示与说明。
- 文章公开发布。
1. 数据爬取
我们本次爬取的对象是一首名为《five hours》的经典电音流行歌曲,Five Hours是Erick Orrosquieta于2014年4月发行的单曲,当年这首单曲就出现在奥地利,比利时,法国,荷兰,挪威,瑞典和瑞士的榜单中。
而在爬虫部分主要是调用官方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 评论爬取
具体代码如下:
from urllib import request
import json
import pymysql
from datetime import datetime
import re ROOT_URL = 'http://music.163.com/api/v1/resource/comments/R_SO_4_%s?limit=%s&offset=%s'
LIMIT_NUMS = 50 # 每页限制爬取数
DATABASE = 'emp' # 数据库名
TABLE = 'temp1' # 数据库表名
# 数据表设计如下:
'''
commentId(varchar)
content(text) likedCount(int)
userId(varchar) time(datetime)
'''
PATTERN = re.compile(r'[\n\t\r\/]') # 替换掉评论中的特殊字符以防插入数据库时报错 def getData(url):
if not url:
return None, None
headers = {
"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',
"Host": "music.163.com",
}
print('Crawling>>> ' + url)
try:
req = request.Request(url, headers=headers)
content = request.urlopen(req).read().decode("utf-8")
js = json.loads(content)
total = int(js['total'])
datas = []
for c in js['comments']:
data = dict()
data['commentId'] = c['commentId']
data['content'] = PATTERN.sub('', c['content'])
data['time'] = datetime.fromtimestamp(c['time']//1000)
data['likedCount'] = c['likedCount']
data['userId'] = c['user']['userId']
datas.append(data)
return total, datas
except Exception as e:
print('Down err>>> ', e)
pass def saveData(data):
if not data:
return None
conn = pymysql.connect(host='localhost', user='root', passwd='', db='emp', charset='utf8mb4') # 注意字符集要设为utf8mb4,以支持存储评论中的emoji表情
cursor = conn.cursor()
sql = 'insert into ' + TABLE + ' (commentId,content,likedCount,time,userId) VALUES (%s,%s,%s,%s,%s)' for d in data: try:
#cursor.execute('SELECT max(c) FROM '+TABLE)
#id_ = cursor.fetchone()[0] cursor.execute(sql, (d['commentId'], d['content'], d['likedCount'], d['time'], d['userId']))
conn.commit()
except Exception as e:
print('mysql err>>> ',d['commentId'],e)
pass cursor.close()
conn.close() if __name__ == '__main__':
songId = input('歌曲ID:').strip()
total,data = getData(ROOT_URL%(songId, LIMIT_NUMS, 0))
saveData(data)
if total:
for i in range(1, total//50+1):
_, data = getData(ROOT_URL%(songId, LIMIT_NUMS, i*(LIMIT_NUMS)))
saveData(data)
实际操作过程中,网易云官方对于API的请求是有限制的,有条件的可以采用更换代理IP来防反爬,而这一次作业在爬取数据的时候由于前期操作过度,导致被BAN IP,数据无法获取,之后是通过挂载虚拟IP才实现数据爬取的。
本次采用的是单线程爬取,所以IP封的并不太频繁,后面会对代码进行重构,实现多线程+更换IP来加快爬取速度。
根据获取评论的API,请求URL有3个可变部分:每页限制数limit和评论总偏移量offset,通过API分析得知:当offeset=0时,返回json数据中包含有评论总数量total。
本次共爬取5394条数据(避免盲目多爬被封ID)
1.2 用户信息爬取
具体代码如下:
from urllib import request
import json
import pymysql
import re ROOT_URL = 'https://music.163.com/api/v1/user/detail/'
DATABASE = 'emp'
TABLE_USERS = 'temp2'
TABLE_COMMENTS = 'temp1'
# 数据表设计如下:
'''
id(int) userId(varchar)
gender(char) userName(varchar)
age(int) level(int)
city(varchar) sign(text)
eventCount(int) followedCount(int)
followsCount(int) recordCount(int)
avatar(varchar)
'''
PATTERN = re.compile(r'[\n\t\r\/]') # 替换掉签名中的特殊字符以防插入数据库时报错
headers = {
"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',
"Host": "music.163.com",
}
def getData(url):
if not url:
return None
print('Crawling>>> ' + url)
try:
req = request.Request(url, headers=headers)
content = request.urlopen(req).read().decode("utf-8")
js = json.loads(content)
data = {}
if js['code'] == 200:
data['userId'] = js['profile']['userId']
data['userName'] = js['profile']['nickname']
data['avatar'] = js['profile']['avatarUrl']
data['gender'] = js['profile']['gender']
if int(js['profile']['birthday'])<0:
data['age'] = 0
else:
data['age'] =(2018-1970)-(int(js['profile']['birthday'])//(1000*365*24*3600))
if int(data['age'])<0:
data['age'] = 0
data['level'] = js['level']
data['sign'] = PATTERN.sub(' ', js['profile']['signature'])
data['eventCount'] = js['profile']['eventCount']
data['followsCount'] = js['profile']['follows']
data['followedCount'] = js['profile']['followeds']
data['city'] = js['profile']['city']
data['recordCount'] = js['listenSongs'] saveData(data)
except Exception as e:
print('Down err>>> ', e)
pass
return None def saveData(data):
if not data:
return None
conn = pymysql.connect(host='localhost', user='root', passwd='', db='emp', charset='utf8mb4') # 注意字符集要设为utf8mb4,以支持存储签名中的emoji表情
cursor = conn.cursor()
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)'
try: 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']))
conn.commit()
except Exception as e:
print('mysql err>>> ',data['userId'],e)
pass
finally:
cursor.close()
conn.close() def getID():
conn = pymysql.connect(host='localhost', user='root', passwd='', db='emp', charset='utf8mb4')
cursor = conn.cursor()
sql = 'SELECT userId FROM '+TABLE_COMMENTS
try:
cursor.execute(sql)
res = cursor.fetchall()
return res
except Exception as e:
print('get err>>> ', e)
pass
finally:
cursor.close()
conn.close()
return None if __name__ == '__main__':
usersID = getID()
for i in usersID:
getData(ROOT_URL+i[0].strip())
根据获取用户信息的API,请求URL有1个可变部分:用户ID,前一部分已经将每条评论对应的用户ID也存储下来,这里只需要从数据库取用户ID并抓取信息即可(对应以上的5394条数据)。
至此,已经完成了歌曲评论和对应用户信息的抓取。接下来,对抓取到的数据进行清洗及可视化分析。
2 数据清洗 & 可视化
处理代码如下:
import pandas as pd
import pymysql
from pyecharts import Bar,Pie,Line,Scatter,Map TABLE_COMMENTS = 'temp1'
TABLE_USERS = 'temp2'
DATABASE = 'emp' conn = pymysql.connect(host='localhost', user='root', passwd='', db='emp', charset='utf8mb4')
sql_users = 'SELECT id,gender,age,city FROM '+TABLE_USERS
sql_comments = 'SELECT id,time FROM '+TABLE_COMMENTS
comments = pd.read_sql(sql_comments, con=conn)
users = pd.read_sql(sql_users, con=conn) # 评论时间(按天)分布分析
comments_day = comments['time'].dt.date
data = comments_day.id.groupby(comments_day['time']).count()
line = Line('评论时间(按天)分布')
line.use_theme('dark')
line.add(
'',
data.index.values,
data.values,
is_fill=True,
)
line.render(r'./评论时间(按天)分布.html')
# 评论时间(按小时)分布分析
comments_hour = comments['time'].dt.hour
data = comments_hour.id.groupby(comments_hour['time']).count()
line = Line('评论时间(按小时)分布')
line.use_theme('dark')
line.add(
'',
data.index.values,
data.values,
is_fill=True,
)
line.render(r'./评论时间(按小时)分布.html')
# 评论时间(按周)分布分析
comments_week = comments['time'].dt.dayofweek
data = comments_week.id.groupby(comments_week['time']).count()
line = Line('评论时间(按周)分布')
line.use_theme('dark')
line.add(
'',
data.index.values,
data.values,
is_fill=True,
)
line.render(r'./评论时间(按周)分布.html') # 用户年龄分布分析
age = users[users['age']>0] # 清洗掉年龄小于1的数据
age = age.id.groupby(age['age']).count() # 以年龄值对数据分组
Bar = Bar('用户年龄分布')
Bar.use_theme('dark')
Bar.add(
'',
age.index.values,
age.values,
is_fill=True,
)
Bar.render(r'./用户年龄分布图.html') # 生成渲染的html文件 # 用户地区分布分析
# 城市code编码转换
def city_group(cityCode):
city_map = {
'': '北京',
'': '天津',
'': '上海',
'': '重庆',
'5e': '重庆',
'': '香港',
'': '澳门',
'': '河北',
'': '山西',
'': '内蒙古',
'': '辽宁',
'': '吉林',
'': '黑龙江',
'': '江苏',
'': '浙江',
'': '安徽',
'': '福建',
'': '江西',
'': '山东',
'': '河南',
'': '湖北',
'': '湖南',
'': '广东',
'': '广西',
'': '海南',
'': '四川',
'': '贵州',
'': '云南',
'': '*',
'': '陕西',
'': '甘肃',
'': '青海',
'': '宁夏',
'': '*',
'': '*',
'': '其他',
}
return city_map[cityCode[:2]] city = users['city'].apply(city_group)
city = city.id.groupby(city['city']).count()
map_ = Map('用户地区分布图')
map_.add(
'',
city.index.values,
city.values,
maptype='china',
is_visualmap=True,
visual_text_color='#000',
is_label_show=True,
)
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。具体代码如下:
import numpy as np
import pymysql
from snownlp import SnowNLP
from pyecharts import Bar TABLE_COMMENTS = 'temp1'
DATABASE = 'emp'
SONGNAME = 'five hours' def getText():
conn = pymysql.connect(host='localhost', user='root', passwd='', db=DATABASE, charset='utf8')
sql = 'SELECT id,content FROM '+TABLE_COMMENTS
text = pd.read_sql(sql%(SONGNAME), con=conn)
return text def getSemi(text):
text['content'] = text['content'].apply(lambda x:round(SnowNLP(x).sentiments, 2))
semiscore = text.id.groupby(text['content']).count()
bar = Bar('评论情感得分')
bar.use_theme('dark')
bar.add(
'',
y_axis = semiscore.values,
x_axis = semiscore.index.values,
is_fill=True,
)
bar.render(r'情感得分分析.html') text['content'] = text['content'].apply(lambda x:1 if x>0.5 else -1)
semilabel = text.id.groupby(text['content']).count()
bar = Bar('评论情感标签')
bar.use_theme('dark')
bar.add(
'',
y_axis = semilabel.values,
x_axis = semilabel.index.values,
is_fill=True,
)
bar.render(r'情感标签分析.html')
结果:
词云生成采用jieba分词库分词,wordcloud生成词云,具体代码如下:
from wordcloud import WordCloud
import matplotlib.pyplot as plt
plt.style.use('ggplot')
plt.rcParams['axes.unicode_minus'] = False def getWordcloud(text):
text = ''.join(str(s) for s in text['content'] if s)
word_list = jieba.cut(text, cut_all=False)
stopwords = [line.strip() for line in open(r'./StopWords.txt', 'r').readlines()] # 导入停用词
clean_list = [seg for seg in word_list if seg not in stopwords] #去除停用词
clean_text = ''.join(clean_list)
# 生成词云
cloud = WordCloud(
font_path = r'C:/Windows/Fonts/msyh.ttc',
background_color = 'white',
max_words = 800,
max_font_size = 64
)
word_cloud = cloud.generate(clean_text)
# 绘制词云
plt.figure(figsize=(12, 12))
plt.imshow(word_cloud)
plt.axis('off')
plt.show() if __name__ == '__main__':
text = getText()
getSemi(text)
getWordcloud(text)
词云: