Bubbles~blog

用爱发电

给网易云音乐评论量排行

说起来爬网易的评论量的想法很早之前就有了,但是一直没去实现,这两天突然很想写点啥,就决定爬它了,不过水平有限,写的爬虫很简陋

准备工作

既然要写的是个爬虫我们就要先去分析我们爬的页面,分析我们需要的数据在哪,首先要把思路确定下来

既然是要爬歌曲评论,我们就要尽可能多地收集歌曲,不难发现网易云的所有歌曲都有一个唯一的id号,你肯定会想到直接遍历这个id,但可惜的是它们的id并不是顺序递增的,很多id都是空的,这个办法肯定是不行的

不过我们还有其他办法,网易云里有很多大大小小的歌单,通过收集这些歌单我们可以找到网易云上的很多歌曲,虽然这样也并不是很全面,但是想想都没有什么歌单收录的歌估计也没人听,评论也没啥爬取的必要

获取歌单

所以首先我们要爬取的是歌单的链接,点开首页的歌单页面发现里面大大小小的歌单有几十页,而且有很多分类,为了数据的全面性和代表性,我们先把所有分类页面的链接爬下来然后爬取这些歌单的id,这样就能照顾到各种类型的歌曲了

这一步很简单,直接用xpath解析下即可

from lxml import etree
def a():
    htm=requests.get('http://music.163.com/discover/playlist')
    res=etree.HTML(htm.content)
    p=res.xpath('//dd/a[@data-cat]/@href')
    return p

然后我们就遍历这些链接来得到歌单数据,也很简单,要注意的是建立个缓存列表,以免爬了很多重复的歌单

lists=a()
    playlists=[]
    for list in lists:
        for i in range(2):
            html=requests.get('http://music.163.com'+list+'&order=hot&limit=35&offset='+str(i*35))
            resp=etree.HTML(html.content)
            p=resp.xpath('//ul/li/div/div/a/@data-res-id')
            for x in p:
                if re.match('^\d{4,}\d$', x) and x not in playlists:
                    playlists.append(x)

其中offset参数是现在页面从哪开始显示歌单,这里代表我们每种分类爬取两页歌单

获取歌曲数据

现在我们要做的就是通过歌单来获取歌曲信息,这里我们就需要用到网易云的歌单api了,我们之前需要的数据都是直接在服务器返回的html源码里面的,所以我们直接用xpath解析就可以得到,但是后面我们需要的数据都是用js动态生成的,靠的是js来异步请求服务器获取数据并加载到页面里,现在常见的多是json来返回数据,python也内置了对json的支持,使用起来也非常方便

def get_song(plist):
 
    url1='http://music.163.com/api/playlist/detail?id='+plist
    attem=0
    end=False
    while attem<3 and not end:
        try:
            rep1=requests.get(url1,timeout=5).content
            end=True
        except :
            attem+=1
    if attem=3:
        return
    result=json.loads(rep1,encoding='utf-8')['result']

对api接口用get请求歌单id即可获取歌单的详细信息,包含里面所有歌曲的id及作者等等,可惜的就是没有评论量,不然倒是全了,不过这两个本来就是分属两个不同的应用,咱也不能强求,此处使用json解析后会把里面的数据转化为python的字典,方便了我们的操作,这里用的是python27,使用python3的需要稍微改一点,其实此处的重试也不是很必要,因为目前来看请求歌单的这个api反应还挺不错,不过等下请求评论的就比较坑了

接下来就是最重要的获取评论量了,这个我们也是通过接口来实现,不过这个使用的是post请求,而且需要附带的参数也更多

headers = {
    'Cookie': '000',
    'Referer': 'http://music.163.com/'
}
params='SaqiO/n92D51RtreCoN3Z4LwsaVYjEDCq2tYcrKMEcaFwykvQRiBbJeB9AE+w9z3fvzONl6T2Mj52kfUt52Vc2pk2nZBZkITAmkqHvKkhC79zTewCzmPUwnnYso7K4vaAZgT6cJqUdhgWW0AZJ0tVeRrqZx41I2OHmMQqb0Uzde6xbe8jbOI5UahgimP8U1z'
 
encSecKey='889276bcae87656f2d50e72bbdc1ae645a610cd4d9b9b95c2f7516e93da5a6c7292f2ef2db3f85509b7b5afdd3ff66c61e18b5c4f0da165d2225cc09f50093b9251e04572c5802cc4d3872154db677c91279c620e0303721721190f74418777ef7d074a6e9af28cfd6ef1abd15561aca88283c3206f82fafd5d58baefe23fbdc'
 
data={
    "params":params,
    "encSecKey":encSecKey
}

首先头部要加上Referer,Cookie可以随便设一个,User-agent它倒是没有检查,重要的是下面要传递的data,这是网易云页面上的js计算出来的值,有兴趣的可以去研究下它的算法,这里我们直接在页面上用f12看下复制过来就行了,而且一次复制,无限使用,后面可以一直用它

我们需要的id已经保存在之前的result字典里了,还有歌手信息一起,这里直接取用就好了

for song in result['tracks']:
 
        url2='https://music.163.com/weapi/v1/resource/comments/R_SO_4_%s?csrf_token=' % (song['id'])
        attempt=0
        success=False
        while attempt<3 and not success:
            try:
                comms=requests.post(url2,headers=headers,data=data,timeout=5).content
                success=True
            except Exception as e:
                print e
                attempt=attempt+1
        if attempt==3:
            continue
        comment=json.loads(comms,encoding='utf-8')
        for arts in song['artists']:
            art_name=arts['name']

这里特别注意要加上异常重试,因为这个api经常抽风,发个请求过去没人理是常有的事
现在其实我们需要的数据都已经拿到了,接下来需要把它存到数据库里,这里我选用了sqlite3,比较轻便快速,而且python也直接内置了对它的支持,调用起来很方便

先建立一个表

conn = sqlite3.connect('mu.db')
 
c = conn.cursor()
c.execute('CREATE TABLE Song(rowid integer PRIMARY KEY AUTOINCREMENT,NAME varchar(200),Artist varchar(100),Comment varchar(20))')
conn.commit()
conn.close()

这里的rowid是一个自增的id,其实不要也无所谓,后面我们直接把数据导出成csv文件

然后插入数据

        pg='insert into Song (rowid,NAME,Artist,Comment) values (NULL,\'%s\',\'%s\',\'%s\')' % (song['name'],art_name,str(comment['total']))
 
        print pg
        aq=0
        ee=False
        while a<3 and not ee:
            try:
                inset(pg)
                break
            except Exception as e:
            aq+=1
        if aq==3:
            continue

这里我把插入语句单独写了一个函数,这是有原因的,有时候我们数据一多进行插入操作时会阻塞在那里,这又是个单线程的,一阻塞就卡死在那了,sqlite3,的参数里又没有timeout,所以我们得想办法给它添上,这里我使用了一个超时装饰器,在函数执行超时时抛出异常

import sys,threading
 
class KThread(threading.Thread):
 
 
    def __init__(self, *args, **kwargs):
 
        threading.Thread.__init__(self, *args, **kwargs)
 
        self.killed = False
 
 
 
    def start(self):
 
        """Start the thread."""
 
        self.__run_backup = self.run
 
        self.run = self.__run     # Force the Thread to install our trace.
 
        threading.Thread.start(self)
 
 
 
    def __run(self):
 
        """Hacked run function, which installs the
 
        trace."""
 
        sys.settrace(self.globaltrace)
 
        self.__run_backup()
 
        self.run = self.__run_backup
 
 
 
    def globaltrace(self, frame, why, arg):
 
        if why == 'call':
 
          return self.localtrace
 
        else:
 
          return None
 
 
 
    def localtrace(self, frame, why, arg):
 
        if self.killed:
 
          if why == 'line':
 
            raise SystemExit()
 
        return self.localtrace
 
 
 
    def kill(self):
 
        self.killed = True
 
 
 
class Timeout(Exception):
 
    """function run timeout"""
 
 
 
def timeout(seconds):
 
    """超时装饰器,指定超时时间
 
    若被装饰的方法在指定的时间内未返回,则抛出Timeout异常"""
 
    def timeout_decorator(func):
 
        """真正的装饰器"""
 
 
 
        def _new_func(oldfunc, result, oldfunc_args, oldfunc_kwargs):
 
            result.append(oldfunc(*oldfunc_args, **oldfunc_kwargs))
 
 
 
        def _(*args, **kwargs):
 
            result = []
 
            new_kwargs = { # create new args for _new_func, because we want to get the func return val to result list
 
                'oldfunc': func,
 
                'result': result,
 
                'oldfunc_args': args,
 
                'oldfunc_kwargs': kwargs
 
            }
 
            thd = KThread(target=_new_func, args=(), kwargs=new_kwargs)
 
            thd.start()
 
            thd.join(seconds)
 
            alive = thd.isAlive()
 
            thd.kill() # kill the child thread
 
            if alive:
 
                raise Timeout(u'function run too long, timeout %d seconds.' % seconds)
 
            else:
 
                return result[0]
 
        _.__name__ = func.__name__
 
        _.__doc__ = func.__doc__
 
        return _
 
    return timeout_decorator

现在我们就可以给我们的数据库操作函数加上这个装饰器了

@timeout(3)
 
def inset(pq):
 
    con = sqlite3.connect('mu.db')
    cp = con.cursor()
    cp.execute(pq)
    con.commit()
    con.close()

到现在其实我们的爬虫主体已经完成了,也完全可以跑了,我在本地测试了一下发现运行正常后就兴冲冲地扔vps上跑了,而且速度比本地快得多,几分钟就爬了一千多首,本来以为跑个半天就能出结果,结果到了1000多一会就爬不动了,重试发现一条都爬不了了,一检查果然ip被评论api给ban了。。。

然而奇葩的是我在本地爬了好几千都没问题,真的是醉了,开始我还以为是因为我的vps在国外,结果借了隔壁老王的腾讯云还是被ban,虽然爬的数据倒是多了点

没办法,只能想办法找代理

建立代理池

网上免费的代理很多,不过听说绝大多数都用不了,我找了个网站爬了爬试试

import requests
 
import os
from lxml import etree
 
url='http://www.xicidaili.com/nn/'
 
fp = open('host.txt', 'a+')
header = {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/'
                  '537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'}
 
for i in range(50):
    urls=url+str(i+1)
    htm=requests.get(url,headers=header).content
    res=etree.HTML(htm)
    p1=res.xpath('//tr[@class="odd"]/td[2]/text()')
    p2=res.xpath('//tr[@class="odd"]/td[3]/text()')
    p3=res.xpath('//tr[@class="odd"]/td[6]/text()')
    for x in range(len(p1)):
        iports=p1[x]+'\t'+p2[x]+'\t'+p3[x]+'\n'
        fp.write(iports)

结果都没来得及给爬虫用上,随便一测就是一大堆用不了,我又找了个稳定点的,不过这个的ip比较少,而且数据也是动态js加载的,我还愣是没找到返回数据的数据包,而且还要科学上网才能进,所以就拿selenium写了一个

import requests
 
import os
from lxml import etree
 
url='http://www.xicidaili.com/nn/'
 
fp = open('host.txt', 'a+')
header = {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/'
                  '537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36'}
 
for i in range(50):
    urls=url+str(i+1)
    htm=requests.get(url,headers=header).content
    res=etree.HTML(htm)
    p1=res.xpath('//tr[@class="odd"]/td[2]/text()')
    p2=res.xpath('//tr[@class="odd"]/td[3]/text()')
    p3=res.xpath('//tr[@class="odd"]/td[6]/text()')
    for x in range(len(p1)):
        iports=p1[x]+'\t'+p2[x]+'\t'+p3[x]+'\n'
        fp.write(iports)

这个提供的爬虫倒是有很多能用的,但是速度上实在是慢了点,延迟高的可能得十几秒。。。

这当然是没法给爬虫用的,而且我也被折腾的不行了,当然我们也可以买代理,这样的当然是比免费的稍微强一点,不过貌似效果也有限

后来发现一个比较好的解决方案是使用拨号vps,它的ip是可变的,因为上网是通过宽带拨号,拨一次号就改一次ip,但是价格也很昂贵,有钱的话可以去玩玩这个,也有日租的套餐

最后嘛还是放弃了使用代理,既然本地能跑就直接在本机跑算了,开上个一天也可以爬很多数据了,反正目标也不是把曲库都给存了,只要排行榜的前一百是靠谱的就OK了

至于最后的结果我们可以直接导出为csv文件,sqlite3也支持这样的命令

sqlite3 -header -csv mu.db "select * from Song;" > xx.csv

然后我们可能会发现有很多重复的歌曲,毕竟是按歌单爬,这也很正常,我们可以用wps里带的删除重复项将它们删除,最后附上爬了一晚上的部分成果

嗯,周董的地位还是无可撼动