一、需求
好久沒有碰爬蟲了,竟不知道從何入手。偶然看到一篇知乎的評論(https://www.zhihu.com/question/20799742/answer/99491808),一時興起就也照葫蘆畫瓢般嘗試做一做。本文主要是通過網頁的歌名搜索,然后獲取到頁面上的搜索結果,最后自行選擇下載搜索結果中的哪條歌曲。
二、應用
在這個過程中,有很多坑,但還好撐過去了。過程中主要用到的東西有 fiddler抓包查看日志、瀏覽器JS的分析、python ASE的加密、request包 的簡單應用、jsonpath包的運用、python基礎的列表、字典、格式化的簡單運用。
三、說明
1、本文主要參考引用了以下博文,感謝大神們的思路和過程:
https://www.zhihu.com/question/36081767
https://www.cnblogs.com/nienie/p/8511999.html
https://www.zhihu.com/question/20799742/answer/99491808
https://www.cnblogs.com/mxk123/p/11832247.html(CSS選擇器學習)
https://www.cnblogs.com/songzhenhua/p/10260992.html (也是CSS選擇器學習)
https://www.cnblogs.com/yuluoxingkong/p/10019246.html (可以借鑒無界面話的瀏覽器模式)
2、本文僅供娛樂和學習,切勿用於商業用途,如有發現,概不負責!
四、正文
上圖就是我們的目標頁面,很簡單的實現搜索和下載音樂的功能。
根據搜索地址(https://music.163.com/#/search/m/?s=難得有情人&type=1),本文主要的思路有:
思路一
1、使用beautifulsoup 把整個網頁load下來,看HTML中是否有歌曲的ID,我們主要是用ID來進行下載,有個歌曲外鏈的下載地址(http://music.163.com/song/media/outer/url?id=id.mp3
),我也不知道從哪里來的,可以根據ID來下載歌曲。現在看下網頁的HTML源碼,對着歌名右鍵——檢查,可以看到歌名、歌曲的id、歌手等信息,然后使用beautifulsoup 進行獲取就可以了。但是現在關鍵信息都放在了iframe里面,暫時不知道怎么抓取。本文就先不深入闡述,有時間再研究一下。(補:網上搜了一下可以使用webdriver來實現跨iframe抓取數據,因此也就使用該種方法了,不多說直接放代碼,代碼比第二種少了一些,不過速度較慢)
------------------------------------補上思路一的代碼,效果和思路二是一樣的----------------------------------------
import requests # 用於獲取網頁內容的模塊 from bs4 import BeautifulSoup # 用於解析網頁源代碼的模塊 from selenium import webdriver from selenium.webdriver.chrome.options import Options def handle_hmtl(search_name): chrome_options = Options() chrome_options.add_argument('--headless') driver = webdriver.Chrome('chromedriver', chrome_options=chrome_options) link = "https://music.163.com/#/search/m/?s=" + search_name + "&type=1" # 要搜索的鏈接 driver.get(link) iframe_elemnt = driver.find_element_by_id("g_iframe") # 因為直接獲取不到iframe的內容,因此使用web_driver driver.switch_to.frame(iframe_elemnt) # 關鍵步驟,跳轉到iframe里面,就可以獲取HTML內容 soup = BeautifulSoup(driver.page_source, "html.parser") # 通過 BeautifulSoup 模塊解析網頁,具體請參考官方文檔。 L = [] # 存儲結果的列表 nu = 0 for value in soup.select( "div[class='srchsongst'] div[class^='item f-cb h-flag']"): # 獲取到關鍵class:srchsongst下面的所有元素,結果是一個列表,使用的是CSS的方式 D = {'num': 'null', 'name': 'null', 'id': 'null', 'singer': 'null', 'song_sheet': 'null'} # 初始化字典 D['num'] = nu # 用來計算num D['name'] = value.b.attrs['title'] # 歌名 D['id'] = value.a.attrs['data-res-id'] # 歌曲ID D['singer'] = '/'.join([i.string for i in value.select('a[href^="/artist?i"]')]) # 歌唱者 D['song_sheet'] = value.select('a[class^="s-fc3"]')[0].attrs['title'] # 專輯 L.append(D) nu += 1 return L def load_song(num, result): """ result 是一個列表 num 是一個str """ if isinstance(int(num), int): num = int(num) if num >= 0 and num <= len(result): song_id = result[num]['id'] song_down_link = "http://music.163.com/song/media/outer/url?id=" + result[num]['id'] + ".mp3" # 根據歌曲的 ID 號拼接出下載的鏈接。歌曲直鏈獲取的方法參考文前的注釋部分。 print("歌曲正在下載...") response = requests.get(song_down_link, headers=headers).content # 親測必須要加 headers 信息,不然獲取不了。 f = open(result[num]['name'] + ".mp3", 'wb') # 以二進制的形式寫入文件中 f.write(response) f.close() print("下載完成.\n\r") else: print("你輸入的數字不在歌曲列表范圍,請重新輸入") else: print("請輸入正確的歌曲序號") if __name__ == '__main__': headers={ # 偽造瀏覽器頭部,不然獲取不到網易雲音樂的頁面源代碼。 'User-Agent':'Mozilla/5.0 (Windows NT 6.3; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.80 Safari/537.36', 'Referer':'http://93.174.95.27', } search_name = input("請輸入你想要在網易雲音樂中搜索的單曲:") result = handle_hmtl(search_name) print("%3s %-35s %-20s %-20s " % ("序號", " 歌名", "歌手", "專輯")) for i in range(len(result)): print("%3s %-35s %-20s %-20s " % ( result[i]["num"], result[i]["name"], result[i]["singer"], result[i]["song_sheet"])) num = input("請輸入你想要下載歌曲的序號/please input the num you want to download:") load_song(num, result) # 下載歌曲
思路二
2、第二種思路就是參考博文中,直接獲取接口返回的內容,查看接口是否返回歌曲的關鍵信息,同樣右鍵檢查一下,歌詞的關鍵信息是這個接口(https://music.163.com/weapi/cloudsearch/get/web?csrf_token=)返回的,而請求參數是:params和encSecKey,看那么長的一串,肯定是經過加密的了。
從參考博文中可以得知,該參數是經過JS加密得到的,至於如何知道是經過JS加密得到的,我也不太清楚。從瀏覽器的initiator項可以這個地址使用了叫core_xxx的JS文件,因此,我們點擊JS項,查看該JS文件,果然有該文件。如果沒有加載出來該JS文件,那需要在F12的開發者工具的“Disable cashe”勾選上(因為本地緩存了之后瀏覽器就不加載了)。通過preview來查看JS的代碼,我們可以搜索一下我們想要的那兩個關鍵字段encSecKey, 搜索后發現有一個關鍵的函數 window.asrsea,而這個函數又是一個d 的函數。
因此我們需要知道window.asrsea 的4個參數,JSON.stringify(i1x)、bxX3x(["流淚", "強"])、bxX3x(RW5b.md)、bxX3x(["愛心", "女孩", "驚恐", "大笑"])。這里我們也使用fiddler抓包,然后分別打印出這幾個參數的值。【注:這里有個踩坑點,就是參照的博文中,打印的是i1x 而不是JSON.stringify(i1x) 這個。但是打印出i1x之后會有個風險就是使用轉義字符的時候容易出錯,因此建議打印出全部】,可以看出,JSON.stringify(i1x) 這個參數打印出了好多個值,這里只能一一試錯,我暫時沒有好的辦法定位到哪個是目標接口調用的。同樣的辦法可以找出那4個參數:
first_param = r'{"hlpretag":"<span class=\"s-fc7\">","hlposttag":"</span>","s":"' + search_name + r'","type":"1","offset":"0","total":"true","limit":"30","csrf_token":""}' second_param = "010001" third_param = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7" forth_param = "0CoJUm6Qyw8W8jud"
fiddler抓包再說一下,如果出現使用fiddler抓包但是原網頁顯示不全的話,那就需要安裝好fiddler的證書之后再試。
知道了這4個參數之后,可以通過JS文件中的內容獲取到加密的方式,就是ASE加密,模式是CBC模式,偏移量為vi ;知道了這些參數,就可以使用python的ASE加密進行加密,代碼如下:【注:這里有個坑:參考的博文中可能使用的ASE的版本不一致導致了加密算法報錯,不加.encoding('utf-8') 的話會出現二進制的報錯,另外需要先轉成轉碼變成二進制之后再進行16位補齊】,這樣就可以得到params 值,為什么說encSecKey 是常量呢,可以觀察到encSecKey 的值是d 函數中的h.encSecKey,這里我們給定一個i 之后,就可以得到固定的encSecKey值。知道了這兩個參數之后,就可以獲取到完整的返回值了。
def AES_encrypt(text, key, iv): text = text.encode('utf-8') pad = 16 - len(text) % 16 text = text + ( pad * chr(pad)).encode('utf-8') # 需要轉成二進制,且可以被16整除 key = key.encode('utf-8') iv = iv.encode('utf-8') encryptor = AES.new(key, AES.MODE_CBC, iv) encrypt_text = encryptor.encrypt(text) # .encode('utf-8') encrypt_text = base64.b64encode(encrypt_text) return encrypt_text.decode('utf-8')
def get_params(): # 獲取params 參數的函數 iv = "0102030405060708" first_key = forth_param second_key = 16 * 'F' h_encText = AES_encrypt(first_param, first_key, iv) h_encText = AES_encrypt(h_encText, second_key, iv) return h_encText
得到的json報文之后,可以使用jsonpath 包來進行處理,提取我們需要的值,詳細的Jsonpath的規則可參考(https://www.cnblogs.com/aoyihuashao/p/8665873.html):
def handle_json(ressult_str): """通過request返回的json結果,對結果進行處理""" ressult_str = ressult_b.decode('utf-8') # 結果轉碼為str類型 json_text = json.loads(ressult_str) # 加載為json格式 i = 0 L = [] for i in range(len(jsonpath(json_text, '$..songs[*].id'))): # 根據id獲取列表條數 D = {'num': 'null', 'name': 'null', 'id': 'null', 'singer':'null', 'song_sheet':'null'} # 初始化字典 D['num'] = i D['name'] = '/'.join(jsonpath(json_text, "$..songs["+str(i)+"].name")) # 獲取名稱 D['id'] = str(jsonpath(json_text, "$..songs["+str(i)+"].id")[0]) # 獲取ID且獲取第一個ID值並轉化為str類型 D['singer'] = '/'.join(jsonpath(json_text, "$..songs["+str(i)+"].ar[*].name")) # 獲取歌手列表 al_list = jsonpath(json_text, "$..songs["+str(i)+"].al.name") # 獲取專輯列表 al = '/'.join(al_list) # 將獲取的專輯列表合並 D['song_sheet'] = "《" + al + "》" L.append(D) return L
下面附上全部代碼(只為實現功能,還有些bug需要優化):
# coding = utf-8 #from bs4 import BeautifulSoup # 用於解析網頁源代碼的模塊 from binascii import b2a_hex, a2b_hex from jsonpath import jsonpath from Crypto.Cipher import AES import requests # 用於獲取網頁內容的模塊 import base64 import json def get_params(): # 獲取params 參數的函數 iv = "0102030405060708" first_key = forth_param second_key = 16 * 'F' h_encText = AES_encrypt(first_param, first_key, iv) h_encText = AES_encrypt(h_encText, second_key, iv) return h_encText def get_encSecKey(): encSecKey = "257348aecb5e556c066de214e531faadd1c55d814f9be95fd06d6bff9f4c7a41f831f6394d5a3fd2e3881736d94a02ca919d952872e7d0a50ebfa1769a7a62d512f5f1ca21aec60bc3819a9c3ffca5eca9a0dba6d6f7249b06f5965ecfff3695b54e1c28f3f624750ed39e7de08fc8493242e26dbc4484a01c76f739e135637c" return encSecKey def AES_encrypt(text, key, iv): text = text.encode('utf-8') pad = 16 - len(text) % 16 text = text + ( pad * chr(pad)).encode('utf-8') # 需要轉成二進制,且可以被16整除 key = key.encode('utf-8') iv = iv.encode('utf-8') encryptor = AES.new(key, AES.MODE_CBC, iv) encrypt_text = encryptor.encrypt(text) # .encode('utf-8') encrypt_text = base64.b64encode(encrypt_text) return encrypt_text.decode('utf-8') def get_json(url, params, encSecKey): data = { "params": params, "encSecKey": encSecKey } response = requests.post(url, headers=headers, data=data) return response.content def handle_json(ressult_str): """通過request返回的json結果,對結果進行處理""" ressult_str = ressult_b.decode('utf-8') # 結果轉碼為str類型 json_text = json.loads(ressult_str) # 加載為json格式 i = 0 L = [] for i in range(len(jsonpath(json_text, '$..songs[*].id'))): # 根據id獲取列表條數 D = {'num': 'null', 'name': 'null', 'id': 'null', 'singer':'null', 'song_sheet':'null'} # 初始化字典 D['num'] = i D['name'] = '/'.join(jsonpath(json_text, "$..songs["+str(i)+"].name")) # 獲取名稱 D['id'] = str(jsonpath(json_text, "$..songs["+str(i)+"].id")[0]) # 獲取ID且獲取第一個ID值並轉化為str類型 D['singer'] = '/'.join(jsonpath(json_text, "$..songs["+str(i)+"].ar[*].name")) # 獲取歌手列表 al_list = jsonpath(json_text, "$..songs["+str(i)+"].al.name") # 獲取專輯列表 al = '/'.join(al_list) # 將獲取的專輯列表合並 D['song_sheet'] = "《" + al + "》" L.append(D) return L def load_song(num, result): if isinstance(int(num), int):
num = int(num) if num >= 0 and num <= len(result): song_id = ressult[num]['id'] song_down_link = "http://music.163.com/song/media/outer/url?id=" + ressult[num]['id'] + ".mp3" # 根據歌曲的 ID 號拼接出下載的鏈接。歌曲直鏈獲取的方法參考文前的注釋部分。 print("歌曲正在下載...") response = requests.get(song_down_link, headers=headers).content # 親測必須要加 headers 信息,不然獲取不了。 f = open(ressult[num]['name'] + ".mp3", 'wb') # 以二進制的形式寫入文件中 f.write(response) f.close() print("下載完成.\n\r") else: print("你輸入的數字不在歌曲列表范圍,請重新輸入") else: print("請輸入正確的歌曲序號") if __name__ == "__main__": search_name = input("請輸入你想要在網易雲音樂中搜索的單曲:") headers = { 'Cookie': 'appver=1.5.0.75771;', 'Referer': 'http://music.163.com/' } first_param = r'{"hlpretag":"<span class=\"s-fc7\">","hlposttag":"</span>","s":"' + search_name + r'","type":"1","offset":"0","total":"true","limit":"30","csrf_token":""}' second_param = "010001" third_param = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7" forth_param = "0CoJUm6Qyw8W8jud" url = "https://music.163.com/weapi/cloudsearch/get/web?csrf_token=" params = get_params() encSeckey = get_encSecKey() ressult_b = get_json(url, params, encSeckey) ressult = handle_json(ressult_b) # 過濾出需要的數據,存入到result中 print("%3s %-35s %-20s %-20s " %("序號", " 歌名", "歌手", "專輯") ) for i in range(len(ressult)): print("%3s %-35s %-20s %-20s " %(ressult[i]["num"], ressult[i]["name"], ressult[i]["singer"], ressult[i]["song_sheet"])) num = input("請輸入你想要下載歌曲的序號/please input the num you want to download:") load_song(num, ressult) # 下載歌曲