python爬取網易雲音樂歌曲評論信息


  網易雲音樂是廣大網友喜聞樂見的音樂平台,區別於別的音樂平台的最大特點,除了“它比我還懂我的音樂喜好”、“小清新的界面設計”就是它獨有的評論區了——————各種故事匯,各種金句頻出。我們可以透過歌曲的評論數來判斷一個歌者的市場分量和歌曲的流行度。
言歸正傳,如果我們想要簡單爬取指定歌曲的評論內容來做詞雲或者其他相關數據分析,有沒有容易上手的好方法呢?

  首先,我們打開網易雲音樂的網頁版:https://music.163.com/,隨便選擇一首歌曲,如林志炫版本的《煙花易冷》:https://music.163.com/#/song?id=25723157。透過網址很容易發現每首歌都有一個對應的id。所以原則上我們只要搜素對應歌曲進到播放頁,就能得到每首歌的網址還有其id號。換言之,只要我們能爬一首歌的評論內容,原則上就可以輕易做成循環,爬取多首歌的所有評論了。

  進入網頁的“最新評論區”,我們每點擊底下的“下一頁”,網站的url並沒有任何變化,說明整個評論區的內容都是通過Ajax異步請求技術得到的。打開瀏覽器F12,進入開發者工具,選擇Network,在凌亂的數據包中,我們選擇XHR(XmlHttpRequest)就可以篩選出Ajax的請求包:

  根據上圖的Initiator字段,我們很容易知道這個url請求的觸發js文件-core.js和對應地址:https://s3.music.126.net/web/s/core.js?fdf161fd0a1799f7c23ec9c48ada5d1f.我們姑且將它在瀏覽器下打開,右鍵save as保存到本地。

根據name字段,很容易發現"R_SO_4_"后面緊跟的25723157正是歌曲的id。我們雙擊第一個name進入,界面右邊清晰顯示,此處請求的url為:

https://music.163.com/weapi/v1/resource/comments/R_SO_4_25723157?csrf_token=
界面如下:
 
通過General下Request Method:字段,清晰看到這是一個post請求。我們可以根據頁面信息清晰構造請求url以及請求頭headers,至於data字段: params:以及encSecKey:雜亂,看不出什么規律:
  1. params:
    hF0h1ltDyC7QxVba4h3X9QDPlcV1PlskubPgFA5I7raqfLal7mCSoDkfsx5E9ljZxENaLhbVJEAPSuOT4j6jV+uJ70/rt5EoehnZTVy4PrcFlbuIOXg6n8HLKRRVpaZVDrDLkLanUn9kr9U2+93M9MZzIpObtFWlY4eqJ1/2BQ+VEnyJ8wnAMYBvYv1ctvKV
  2. encSecKey:
    86ea0c77d5ab69f26f3f24638bc80758ebba86c0a28b8024db2065b21d4e885816ee463aa3020378007d90a83f83e81bea5f09dfc3433e0df46d82488124127af220dfef0d5c71d0029de68e7b8c836e9f4dc3162a662bcfd2af1fac98bf12dbe6e4a6be7e1b48b4fcee1ee7970cbd0149c33d3a78cc11956345b6b50a8f5ad8

 我們不停點擊網頁評論區下一頁,進行抓包,發現每一頁對應的form data中params以及encSecKey字段內容都是不一樣的,所以如果不能找到規律,我們就只能爬取第一頁的熱門評論和最新評論,做不到翻頁爬取所有評論。

還好知乎上熱心的大神“平胸小仙女”已經完成了探索並還原了整個加密過程,附上參考信息:https://www.zhihu.com/question/36081767,感興趣的可以參考其講解。

這里需要分析前文拿到的core.js文件,由於該js文件非常龐大,我們可以根據關鍵詞encSecKey檢索,定位到關鍵代碼段,也就有了下面提到的幾個重要加密函數:

仔細分析這段函數,並在瀏覽器F12控制台下運算,發現a(16)每次都是隨機生成一段長度為16的隨機字符串。

> function a(a){var d,e,b="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",c="";for(d=0;a>d;d+=1)e=Math.random()*b.length,e=Math.floor(e),c+=b.charAt(e);return c}
< undefined
> a(16)
< "WugFoWvqBro7YomO"
> a(16)
< "TNIMHadoojG3O1yJ"
function b(a,b){
    var c=CryptoJS.enc.Utf8.parse(b),
    d=CryptoJS.enc.Utf8.parse("0102030405060708"),
    e=CryptoJS.enc.Utf8.parse(a),
    f=CryptoJS.AES.encrypt(e,c,{iv:d,mode:CryptoJS.mode.CBC});
    return f.toString()
    }

 

function e(a,b,d,e){
    var f={};
    return f.encText=c(a+e,b,d),f
    }
function c(a,b,c){var d,e;return setMaxDigits(131),d=new RSAKeyPair(b,"",c),e=encryptedString(d,a)}

 

觀察function b 函數,可以看到密鑰偏移量iv是0102030405060708,模式是CBC,加密方法是AES。

觀察function a函數,由於其隨機性,我們可以假定i=a(16)=F*16,化繁為簡,不會影響加密過程。

function d(d,e,f,g){
    var h={},
    i=a(16);
    return h.encText=b(d,g),
    h.encText=b(h.encText,i),
    h.encSecKey=c(i,e,f),h
    }

 至此算是拿到了加密算法,下面就可以愉快的爬取了,代碼參考了知乎網友:平胸小仙女以及廖長安的評論:

# 參考地址:https://www.zhihu.com/question/36081767
# 完美兼容win10、python3.6,由於python3.6下pycrypto庫已經停止維護,可以安裝pyCryptodome庫代替,pyCyrpto庫的后續分支,有一個叫pyCryptodome的庫,是前代的延伸版。
import sys
import codecs
import requests,json,os
import base64
import Crypto
from Crypto.Cipher import AES


class Spider():

    def __init__(self,idNum):
        #user-Agent字段直接從瀏覽器中復制過來即可,請求頭中其他字段非必須項,也可以從瀏覽器中找到所有字段都放到Request Headers
        self.header = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.221 Safari/537.36 SE 2.X MetaSr 1.0',
                       'Referer': 'http://music.163.com/'}
        self.url = 'https://music.163.com/weapi/v1/resource/comments/R_SO_4_'+idNum+'?csrf_token='   #每一次的base_url只有歌曲id不同,構造url即可。

    def __get_jsons(self,url,page):
        # 獲取兩個參數
        music = WangYiYun()
        text = music.create_random_16()
        params = music.get_params(text,page)

        encSecKey = music.get_encSEcKey(text)
        fromdata = {'params' : params,'encSecKey' : encSecKey}
        jsons = requests.post(url, data=fromdata, headers=self.header)
        #print(jsons.raise_for_status())
        # 打印返回來的內容,是個json格式的
        #print(jsons.content)
        return jsons.text

    def json2list(self,jsons):
        '''把json轉成字典,並把他重要的信息獲取出來存入列表'''
        # 可以用json.loads()把它轉成字典
        #print(json.loads(jsons.text))
        users = json.loads(jsons)
        comments = []
        for user in users['comments']:
            # print(user['user']['nickname']+' : '+user['content']+'   點贊數:'+str(user['likedCount']))
            name = user['user']['nickname']
            content = user['content']
            # 點贊數
            likedCount = user['likedCount']
            #提取所需json中所需的字段構造字典
            user_dict = {'name': name, 'content': content, 'likedCount': likedCount}
            #將提取的字典信息追加到列表中
            comments.append(user_dict)
        return comments

    def run(self,idNum):
        self.page = 1
        while True:
            jsons = self.__get_jsons(self.url,self.page)
            comments = self.json2list(jsons)
            non_bmp_map = dict.fromkeys(range(0x10000, sys.maxunicode + 1), 0xfffd)

##            print(str(comments[0]).translate(non_bmp_map))
            print('self.page = '+str(self.page)) #控制台打印正在爬取的頁碼數
            print(idNum) #打印正在爬取的歌曲id
            #在該腳本同級目錄下生成“comments”文件夾
            dirName = u'{}'.format('comments')
            if not os.path.exists(dirName):
                os.makedirs(dirName)
            with open(".\comments\\"+idNum+".txt","a",encoding='utf-8') as f:  #結果寫入txt文件
##                print(len(comments))
                for ii in range(len(comments)):
                    f.write(str(comments[ii]).translate(non_bmp_map))
                    f.write('\n')
##                    print(ii)
                f.close()
            # 當這一頁的評論數少於20條時,證明已經獲取完
##            self.write2sql(comments)
            if len(comments) < 100 :   #當limits設置為100時,默認每次服務器請求結果100條comments,當小於此數,意味爬到最后一頁。
                print('評論已經獲取完')
                break
            self.page +=1

# 找出post的兩個參數params和encSecKey
class WangYiYun():

    def __init__(self):
        # 在網易雲獲取的三個參數

        self.second_param = '010001'
        self.third_param = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7'
        self.fourth_param = '0CoJUm6Qyw8W8jud'

    def create_random_16(self):
        '''獲取隨機十六個字母拼接成的字符串'''
        return (''.join(map(lambda xx: (hex(ord(xx))[2:]), str(os.urandom(16)))))[0:16]

    def aesEncrypt(self, text, key):

        # 偏移量
        iv = '0102030405060708'
        # 文本

        pad = 16 - len(text) % 16
        text = text + pad * chr(pad)  #補齊文本長度


        encryptor = AES.new(bytearray(key,'utf-8'), AES.MODE_CBC, bytearray(iv,'utf-8'))

       # encryptor = AES.new(key, 2, iv)

        ciphertext = encryptor.encrypt(bytearray(text,'utf-8'))
##        print(bytearray(key,'utf-8'))
        ciphertext = base64.b64encode(ciphertext)
        return ciphertext

    def get_params(self,text,page):
        '''獲取網易雲第一個參數'''
        # 第一個參數
        if page == 1:
            self.first_param = '{rid: "", offset: "0", total: "true", limit: "100", csrf_token: ""}'
            #rid: "R_SO_4_557581284",經測試該值可以置空,不影響結果的執行。
        else:
            self.first_param = '{rid: "", offset:%s, total: "false", limit: "100", csrf_token: ""}'%str((page-1)*20)  #limit參數可以靈活設置,默認為20,設置為100,爬取效率可以提高


        params = self.aesEncrypt(self.first_param, self.fourth_param).decode('utf-8')
        params = self.aesEncrypt(params, text)

        return params

    def rsaEncrypt(self, pubKey, text, modulus):
        '''進行rsa加密'''
        text = text[::-1]
        rs = int(codecs.encode(text.encode('utf-8'), 'hex_codec'), 16) ** int(pubKey, 16) % int(modulus, 16)
        return format(rs, 'x').zfill(256)

    def get_encSEcKey(self,text):
        '''獲取第二個參數'''
        pubKey = self.second_param
        moudulus = self.third_param
        encSecKey = self.rsaEncrypt(pubKey, text, moudulus)
        return encSecKey

def main():
    idPs = ['557581284','32019002']   #花粥《紙短情長》以及Zedd / Jon Bellion的《beautiful now》,可根據需要在網易雲音樂查找歌曲ID后替換,列表元素越多,爬取的循環次數越多
    for jj in range(len(idPs)):
        idNum = idPs[jj]
        spider = Spider(idNum)  #根據Spider類實例化spider對象
        spider.run(idNum) #調用spider對象的run方法

if __name__ == '__main__':

    main()

需要說明的是,爬蟲如果頻率過快,數量過多,服務器會封IP。因此更完備的爬蟲項目,是需要設置代理和IP池的。

PS:

1、知乎同樣有熱心的網友肖飛發現並提供了網易雲音樂官方的評論api接口,且屬於非加密的get請求,參數同樣是offset、limit等,問題難度一下子成倍地下降,API接口形式:http://music.163.com/api/v1/resource/comments/R_SO_4_516997458,自行更換url后的id即可,感興趣的朋友可以自行嘗試!

2、另外,不想執着於破解post表單參數的朋友,可以試着用python+selenium+PhantomJs的方式模擬用戶操作,點擊翻頁后,再直接解析頁面元素,這樣可以做到“可見即可爬”,不過效率會略低一些。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM