网易云音乐爬虫 & 数据可视化分析

转载请注明出处:https://www.jianshu.com/p/e90215172c85
个人博客:Archiew's blog
源码:https://github.com/Archiewyq/music_163 欢迎 star (●'◡'●)

1. 数据爬取

爬虫部分主要是调用官方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}

工具:
Python3.6
sublime3
MySQL(数据存储)
scrapy(数据清洗)
pyecharts(可视化工具库)

* 关于网易云音乐官方API,后期会整理一下做个汇总放在GitHub上。

1.1 评论爬取

实际操作过程中,网易云官方对于API的请求是有限制的,有条件的可以采用更换代理IP来防反爬,本次采用的是单线程爬取,所以IP封的并不太频繁,后面会对代码进行重构,实现多线程+更换IP来加快爬取速度。

根据获取评论的API,请求URL有3个可变部分:歌曲ID、每页限制数limit和评论总偏移量offset,通过API分析得知:当offeset=0时,返回json数据中包含有评论总数量total,所以根据API可设计爬虫如下:

# -*- coding:utf8 -*-
# python3.6
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 = ''   # 数据库名
TABLE = ''  # 数据库表名
# 数据表设计如下:
'''
id(int)             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='****', passwd='****', db='****', charset='utf8mb4') # 注意字符集要设为utf8mb4,以支持存储评论中的emoji表情
    cursor = conn.cursor()
    sql = 'insert into ' + TABLE + ' (id,commentId,content,likedCount,time,userId) VALUES (%s,%s,%s,%s,%s,%s)'
    for d in data:
        try:
            cursor.execute('SELECT max(id) FROM '+TABLE)
            id_ = cursor.fetchone()[0]
            cursor.execute(sql, (id_+1,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//EVERY_PAGE_NUMS+1):
                _, data = getData(ROOT_URL%(songId, LIMIT_NUMS, i*(LIMIT_NUMS)))
                saveData(data)

以上代码实现了单线程爬取网易云音乐某首歌曲的评论并存储进数据库(在这里其实有个坑!!!不过不影响,后面会讲到)。实际上,API返回的不仅仅包含代码中所提到的信息,具体可自行测试,我们还想要得到评论对应的用户的具体信息,但是这个API返回的用户信息不全面,所以接下来,针对评论对应的用户信息进行抓取。

1.2 用户信息爬取

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

# -*- coding:utf8 -*-
# python3.6
from urllib import request
import json
import pymysql
import re

ROOT_URL = 'https://music.163.com/api/v1/user/detail/'
DATABASE = '****'
TABLE_USERS = '****'
TABLE_COMMENTS = '****'
# 数据表设计如下:
'''
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\/]') # 替换掉签名中的特殊字符以防插入数据库时报错

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['followCount'] = js['profile']['follows']
            data['fanCount'] = js['profile']['followeds']
            data['city'] = js['profile']['city']
            data['recordCount'] = js['listenSongs']
    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='****', passwd='****', db=DATABASE, charset='utf8mb4') # 注意字符集要设为utf8mb4,以支持存储签名中的emoji表情
    cursor = conn.cursor()
    sql = 'insert into ' + TABLE + ' (id,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,%s)'
    try:
        cursor.execute('SELECT max(id) FROM '+TABLE_USERS)
        id_ = cursor.fetchone()[0]
        cursor.execute(sql, (id_+1,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='****', passwd='****', db=DATABASE, 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:
        data = getData(ROOT_URL+i[0].strip())
        saveData(data)

以上代码实现了单线程爬取网易云音乐用户信息并存储进数据库。至此,已经完成了歌曲评论和对应用户信息的抓取。接下来,对抓取到的数据进行清洗及可视化分析。


抓取到的评论数据

抓取到的用户信息数据

2 数据清洗 & 可视化

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

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

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

API返回的用户信息中,城市分为province和city两个字段,本此分析中只保存了city字段。实际上字段值是一个城市code码,具体对照在这里下载

在这部分,利用Python的数据处理库pandas进行数据处理,利用可视化库pyecharts进行数据可视化。处理代码如下:

# -*- coding:utf8 -*-
# python3.6
import pandas as pd
import pymysql
from pyecharts import Bar,Pie,Line,Scatter,Map

TABLE_COMMENTS = '****'
TABLE_USERS = '****'
DATABASE = '****'

conn = pymysql.connect(host='localhost', user='****', passwd='****', db=DATABASE, 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 = {
        '11': '北京',
        '12': '天津',
        '31': '上海',
        '50': '重庆',
        '5e': '重庆',
        '81': '香港',
        '82': '澳门',
        '13': '河北',
        '14': '山西',
        '15': '内蒙古',
        '21': '辽宁',
        '22': '吉林',
        '23': '黑龙江',
        '32': '江苏',
        '33': '浙江',
        '34': '安徽',
        '35': '福建',
        '36': '江西',
        '37': '山东',
        '41': '河南',
        '42': '湖北',
        '43': '湖南',
        '44': '广东',
        '45': '广西',
        '46': '海南',
        '51': '四川',
        '52': '贵州',
        '53': '云南',
        '54': '西藏',
        '61': '陕西',
        '62': '甘肃',
        '63': '青海',
        '64': '宁夏',
        '65': '新疆',
        '71': '台湾',
        '10': '其他',
    }
    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')

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


评论数时间(按天)分布

评论数时间(按周)分布

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

评论数时间(按小时)分布

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

用户年龄分布

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

用户地区分布

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

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

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


2018-12-17

歌评文本分析

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

# -*- coding:utf8 -*-
# python3.6

import numpy as np
import pymysql
from snownlp import SnowNLP
from pyecharts import Bar

TABLE_COMMENTS = '****'
DATABASE = '****'
SONGNAME = '****'

def getText():
    conn = pymysql.connect(host='localhost', user='root', passwd='root', 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)
评论词云

中文停用词下载

Todo:

  • [ ] 对评论内容做进一步分析 2018-12-17
  • [ ] 代码重构,增加多线程等 2018-12-19
  • [ ] 代码开源在Github上 2018-12-19
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 这个真是画歪了,无力回天(尴尬)
    覃格尔阅读 90评论 0 2
  • 将近半年的时间,慢慢养成了早起的习惯,不管多晚睡觉,早上六点醒了肯定睡不着。今天早上醒来没有和往常一样看书,有点心...
    依然如水阅读 176评论 0 0
  • 曾经经历失败的美丽是最炫美的。从失败中走出的美丽犹如经历过风雨磨励的铿锵玫瑰会更有魅力。正如花盆里长不出苍松...
    文竹君Fan阅读 240评论 0 1
  • 从想念一个人 到无人可想 从等待一个人 到无人可等 好像一切都失去了意义 灰白的天空 灰白的心情 一个人听雨
    昨夜的街灯阅读 158评论 0 0