【爬蟲】爬取網易雲音樂評論2
在上一篇【爬蟲】爬取網易雲音樂評論中已經實現了基本功能,有一篇完整的代碼。
現在繼續完善,避免篇幅過長重開一篇。
1、嘗試從歌名找到songid。
又是爬蟲!
首先還是觀察瀏覽器的行為,搜索「鼓樓」載入XHR,其中果然有鼓樓(趙雷)的songid。
爬取應該不難,但這裡會有個問題是:輸入「鼓樓」,返回的可不止是趙雷的鼓樓,還有前沖翻唱的、王凡瑞的鼓樓先生……都算作相關。那麼瀏覽器是如何提取我們要的這一首呢?
事實上瀏覽器根本無從判斷,只有靠我們人工選擇。因此爬蟲做到這裡,自然也是無法判斷。
這裡有兩個方案:
- 直接選排在第1個的
- 圖形界面呈現出來,讓用戶選
第1中方案簡單省事,但……不負責任。反正交互GUI也是計劃內的,所以我選擇第2種方案。
12.18更
把post部分想簡單了,難點在於post參數如何構造出來。難度太大,準備換selenium 的webdriver。
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to locate element 定位frame中的元素
通過下面的代碼,可以用瀏覽器自動打開網頁,自動輸入歌名並提交:
from selenium import webdriverimport timebrowser = webdriver.Chrome()url=http://music.163.com/#/search/m/browser.get(url)time.sleep(1)frame1 = browser.find_element_by_css_selector(".g-iframe")browser.switch_to.frame(frame1)#定位browser.find_element_by_id("m-search-input").send_keys("鼓樓")browser.find_element_by_css_selector(".btn").click()print(browser.page_source)
page_source就是此刻頁面的html代碼,可以看到songid已經有了。
下面通過正則,從中提取出songid
以下是一個周期,根據這個周期構造pattern,從中提取加粗的4項:
<div class="item f-cb h-flag "><div class="td"><div class="hd"><a id="song_447926067" class="ply " title="播放" data-res-copyright="1" data-res-type="18" data-res-id="447926067" data-res-action="play" data-res-from="32" data-res-data="鼓樓"></a></div></div><div class="td w0"><div class="sn"><div class="text"><a href="/song?id=447926067"><b title="鼓樓 - (The Drum Tower)"><span class="s-fc7">鼓樓</span></b></a><span title="The Drum Tower" class="s-fc8"> - (The Drum Tower)</span></div></div></div><div class="td"><div class="opt hshow"><a class="u-icn u-icn-81 icn-add" href="javascript:;" title="添加到播放列表" hidefocus="true" data-res-copyright="1" data-res-type="18" data-res-id="447926067" data-res-action="addto" data-res-from="32" data-res-data="鼓樓"></a><span data-res-id="NL447926067" data-res-action="fav" data-res-type="18" class="icn icn-fav" title="收藏"></span><span data-res-id="NL447926067" data-res-action="share" data-res-type="18" class="icn icn-share" title="分享"></span><span data-res-id="447926067" data-indexid="NL447926067" data-res-action="download" data-res-type="18" class="icn icn-dl" title="下載"></span></div></div><div class="td w1"><div class="text"><a href="/artist?id=6731">趙雷</a></div></div><div class="td w2"><div class="text"><a class="s-fc3" href="/album?id=35069014" title="《無法長大》">《無法長大》</a></div></div><div class="td">04:41</div></div>
但是正則抓取不到,比較browser.page_source與瀏覽器的網頁源代碼,有點差別:
原以為瀏覽器已經很逼真,畢竟打開的是實實在在的瀏覽器啊!
參考這裡selenium爬蟲被檢測到 該如何破?還有Python爬蟲|深入請求(四)常見的反爬機制以及應對方法
【解決】
並不是被反爬,只是提交歌名後XHR載入需要時間,所以加一段等待,等待頁面重新渲染完成以後再取page_source,就和瀏覽器一樣了。
from selenium.webdriver.support.ui import WebDriverWaitWebDriverWait(browser, 10).until(lambda driver: driver.find_element_by_class_name("srchsongst"))
然後從page_source里取出(songid,title,singer,album),用的正則。
pat = re.compile( r<div cla.*?song_(.*?)" cla.*?b title="(.*?)">.*?artist.*?>(.*?)</a>.*?album.*?le="(.*?)">.*?</div>)song_lst = pat.findall(text, re.S)
正則的構造需要細心一些,一開始我寫出了(.*?).*?這樣的形式,一直跑不完……大概是進入了死循環狀態。
最後就是調用tkinter,讓用戶從眾多相關歌曲中,選擇某一首。這個功能封裝在函數users_choice(song_lst)中,返回song_seq。這個函數還待寫……估計會花點時間,所以暫時粗暴地來了一行return 1,方便在外部進行功能測試。
外部拿到song_seq以後,就很容易取得song_id。 song_id= song_lst[song_seq][0]。
最後,為了檢驗今天的成果,做個測試:
if __name__==__main__: #print(請輸入歌名) #song_name=input_songname() songname=路口 print("下面開始查找相關歌曲") song_id=find_songname(songname) print("with songname=路口,song_id=",song_id) songname=鼓樓 song_id=find_songname(songname) print("with songname=鼓樓,song_id=",song_id)
沒有問題~就剩tk了~
12.19 更
現在,find_songname.py的基礎功能已經全部實現,在模塊內部測試,從input(songname)到output(songid)可以走通。
其中會兩次調用tk,第一次是彈窗請求用戶輸入歌名,第二次是請求用戶選擇一首歌)
tkinter有個叫scrollbar的組件,但是只能作用於listbox、text、canvas,所以為了加入滾動條,保留選擇功能,我採用scrollbar+listbox。
示例代碼:
from tkinter import *root = Tk()scrollbar = Scrollbar(root)scrollbar.pack( side = RIGHT, fill=Y )mylist = Listbox(root, yscrollcommand = scrollbar.set )for line in range(100): mylist.insert(END, "This is line number " + str(line))mylist.pack( side = LEFT, fill = BOTH )scrollbar.config( command = mylist.yview )mainloop()
效果是這樣:
為了更貼合我的需要,修改如下:
from tkinter import *root = Tk()scrollbar = Scrollbar(root)scrollbar.pack( side = RIGHT, fill=Y )mylist = Listbox(root,yscrollcommand = scrollbar.set,selectmode = MULTIPLE )for line in range(100): mylist.insert(END, "This is line number " + str(line))mylist.pack( side = LEFT, fill = BOTH )scrollbar.config( command = mylist.yview )def ok(): print(mylist.curselection())Button(text="提交", command=ok).pack(side=RIGHT)mainloop()
但這裡會產生一個關於作用域的問題:
我們想要拿到的選中項序號,正是ok()的返回值。
def ok(): print(mylist.curselection())
問題是:
ok()只能在Button構建中調用,無法在外面單獨調用(原因可能是:ok函數中的mylist依賴command相關的環境Button(text="提交", command=ok)
但是Button構建中調用ok()之後的返回值,沒有綁給任何變數,我們又拿不到。
不知道表達清楚沒有,搜到也有人問過同樣的問題:
求助:tkinter button command如何獲得返回值
Python:tkinter-Parent獲取彈出窗口的返回值_doris_新浪博客
tkinter學習-事件綁定與窗口 - Ruby - C語言編程開發(沒看懂)
昨天主要在看tk,最終選擇了listbox+scrollbar的組合完成了界面,如下。昨晚糾結的參數傳遞問題也解決好了。
下面來修正【正則匹配的問題】。
把匹配有錯的這一條html代碼拿出來,如下:
<div class="item f-cb h-flag even "><div class="td"><div class="hd"><a id="song_498365461" class="ply " title="播放" data-res-copyright="1" data-res-type="18" data-res-id="498365461" data-res-action="play" data-res-from="32" data-res-data="鼓樓"></a></div></div><div class="td w0"><div class="sn"><div class="text"><a href="/song?id=498365461"><b title="都demo">都demo</b></a></div></div></div><div class="td"><div class="opt hshow"><a class="u-icn u-icn-81 icn-add" href="javascript:;" title="添加到播放列表" hidefocus="true" data-res-copyright="1" data-res-type="18" data-res-id="498365461" data-res-action="addto" data-res-from="32" data-res-data="鼓樓"></a><span data-res-id="NL498365461" data-res-action="fav" data-res-type="18" class="icn icn-fav" title="收藏"></span><span data-res-id="NL498365461" data-res-action="share" data-res-type="18" class="icn icn-share" title="分享"></span><span data-res-id="498365461" data-indexid="NL498365461" data-res-action="download" data-res-type="18" class="icn icn-dl" title="下載"></span></div></div><div class="td w1"><div class="text"><a href="/artist?id=12147204"><span class="s-fc7">鼓樓</span></a></div></div><div class="td w2"><div class="text"><a class="s-fc3" href="/album?id=35945055" title="《都demo》">《都demo》</a></div></div><div class="td">03:05</div></div>
原本匹配用的pat是:
pat = re.compile(
r<div cla.*?song_(.*?)" cla.*?b title="(.*?)">.*?artist.*?>(.*?)</a>.*?album.*?le="(.*?)">.*?</div>)出錯點在倒數第4行,本意提取「鼓樓」兩個字,結果把span標籤一起取出來了,對應pat中這一句artist.*?>(.*?)</a>.*?
修改思路是在()內加上[^<][^s],表示匹配結果不要以<s開頭。修改後的pat:
pat = re.compile(
r<div cla.*?song_(.*?)" cla.*?b title="(.*?)">.*?artist.*?d+">([^<][^s].*?)</.*?album.*?le="(.*?)">.*?</div>)至此,find_songid.py已經改完,運行效果如下:
接下里,在spider中引入 find_songid.py,並在其main函數中調用find_songid.py中的函數
運行過程截圖:
但是因為設計第一版代碼時只考慮爬一首歌,保存時編號從0-99,而設計的MySQL表結構設定id為primary key,這就導致了第二首歌的評會因為違反唯一約束條件而全部保存失敗。
【修改代碼】
思路:
1、加兩個全局變數用於計數,初始值為0,
keyword_base_index=0和comment_base_index=0
2、在每首歌保存結束後,根據數據長度修改變數的值
keyword_base_index=int(keyword_base_index)+len(top_keywords)comment_base_index=int(comment_base_index)+len(comments_lst)
貼一小塊相關代碼:
keyword_base_index=0def save_to_keywords(songid, top_keywords): conn = mysql.connector.connect(user=root, password=1234, use_unicode=True) # 通常我們在連接MySQL時傳入use_unicode=True,讓MySQL的DB-API始終返回Unicode cursor = conn.cursor() global keyword_base_index for i, keyword in enumerate(top_keywords): i=i+int(keyword_base_index) cursor.execute(insert into 163music.keywords(top_id, keyword,weight,songid) values (%s, %s,%s, %s), [i, keyword[0], keyword[1], songid]) keyword_base_index=int(keyword_base_index)+len(top_keywords) conn.commit() cursor.close()
【重新運行】
comment和keywords都能保存成功
但觀察到,表結構設計有一些不合理之處:雖然可以通過group by songid來分組查看某一首歌的信息,但第二首歌的id落在了100-199範圍,跟人們習慣的0-99還是不一樣的。
另外,song_name也可以一起存庫,看起來更直觀。
所以下一步就是,【優化表的結構】,將在【爬蟲】網易雲音樂評論爬取3(完善資料庫)記錄。
附:
【這一版的代碼】
#spider.pyimport requestsimport jsonimport osfrom Crypto.Cipher import AESimport base64import codecsimport mysql.connectorimport find_songnamedef aesEncrypt(text, secKey): pad = 16 - len(text) % 16 # print(type(text)) # print(type(pad)) # print(type(pad * chr(pad))) # text = text + str(pad * chr(pad)) if isinstance(text, bytes): # print("type(text)==bytes") text = text.decode(utf-8) # print(type(text)) text = text + str(pad * chr(pad)) encryptor = AES.new(secKey, 2, 0102030405060708) ciphertext = encryptor.encrypt(text) ciphertext = base64.b64encode(ciphertext) return ciphertextdef rsaEncrypt(text, pubKey, modulus): text = text[::-1] # rs = int(text.encode(hex), 16)**int(pubKey, 16)%int(modulus, 16) rs = int(codecs.encode(text.encode(utf-8), hex_codec), 16) ** int(pubKey, 16) % int(modulus, 16) return format(rs, x).zfill(256)modulus = 00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7nonce = 0CoJUm6Qyw8W8judpubKey = 010001def createSecretKey(size): return (.join(map(lambda xx: (hex(ord(xx))[2:]), str(os.urandom(size)))))[0:16]def get_it_comments(url): headers = { User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36} comment_list = [] count = 0 for i in range(10): # 一個i獲取10個評論 text = { username: , password: , rememberLogin: true, offset: i * 10 } text = json.dumps(text) secKey = createSecretKey(16) encText = aesEncrypt(aesEncrypt(text, nonce), secKey) encSecKey = rsaEncrypt(secKey, pubKey, modulus) payload = { params: encText, encSecKey: encSecKey } r = requests.post(url, headers=headers, data=payload) r.raise_for_status() # print(r.headers) # print(r.text) # 把返回的json格式轉換成字典,方便提取關鍵字 try: r_dic = json.loads(r.text) except: print("json.loads(r.text)出錯了") # print(r_dic) comments = r_dic["comments"] print() # print(共有{0}條評論.format(len(comments))) # 與其print不如assert assert len(comments) == 10 for comment in comments: # print(comment) comment_list.append([count, comment[content]]) count += 1 # 也可用正則取出評論的nickname和content print(已經get第{0}條評論.format(len(comment_list))) return comment_listimport pynlpirdef parse_words(s): pynlpir.open() #print(type(s))#<class str> #print(s) key_words = pynlpir.get_key_words(s, weighted=True) pynlpir.close() return key_wordsdef dic2str(dicc): lst = [] for key in dicc: lst.append((key, dicc[key])) return lstdef get_and_save_top_keywords(songid): dic_keywords = {} url = http://music.163.com/weapi/v1/resource/comments/R_SO_4_ + songid + ?csrf_token= comments_lst = get_it_comments(url) assert comments_lst, comments_lst is Null print(爬取部分已經完成) for i,comment in comments_lst: # print(comment)#[0, 一聽就著了迷] global comment_base_index try: save_comment(i,comment,songid) except Exception as e: print("comment保存失敗:", i,comment) print(e) else: print("comment保存成功:",i, comment) # 保存的同時當場解析 try: key_words = parse_words(comment) for key_word in key_words: # print(key_word[0], , key_word[1])#雷子 6.6 dic_keywords.update({key_word[0]: dic_keywords.get(key_word[0], 0) + key_word[1]}) except Exception as e: print(comment解析失敗:,i,comment) print(e) else: print("comment解析成功:",i,comment) finally: print() print(所有評論的解析與保存已完成) comment_base_index=int(comment_base_index)+len(comments_lst) print(sorted(dic2str(dic_keywords))) top_keywords = list(sorted(dic2str(dic_keywords), key=lambda x: -x[1]))[:10] print(下面開始保存top_keywords到資料庫) try: save_to_keywords(songid, top_keywords) except Exception as e: print("top_keywords保存失敗:", top_keywords) print(e) else: print("top_keywords保存成功:", top_keywords) return top_keywordsdef init_tables(): conn = mysql.connector.connect(user=root, password=1234, use_unicode=True) # 通常我們在連接MySQL時傳入use_unicode=True,讓MySQL的DB-API始終返回Unicode cursor = conn.cursor() # 如果是第二次運行,先把第一次的結果刪掉 cursor.execute(drop database if exists + 163music) cursor.execute(create database if not exists + 163music) cursor.execute( create table if not exists 163music.comments(top_id varchar(10) primary key, comment varchar(200),songid varchar(20))) cursor.execute( create table if not exists 163music.keywords(top_id varchar(10) primary key, keyword varchar(10),weight varchar(20),songid varchar(20))) conn.commit() cursor.close()comment_base_index=0def save_comment(i,comment,songid): #print(comment_lst,songid)#97 [97, 謝謝] 447926067 # 後面再加,如果有其他songid,insert;如果是一個songid第二次運行,update conn = mysql.connector.connect(user=root, password=1234, use_unicode=True) # 通常我們在連接MySQL時傳入use_unicode=True,讓MySQL的DB-API始終返回Unicode cursor = conn.cursor() #去掉非法字元,否則入庫報錯 comment=comment.encode(gbk,ignore).decode(gbk) i=int(comment_base_index)+int(i) cursor.execute(insert into 163music.comments(top_id,comment,songid) values (%s,%s,%s) , [i,comment,songid]) conn.commit() cursor.close()keyword_base_index=0def save_to_keywords(songid, top_keywords): conn = mysql.connector.connect(user=root, password=1234, use_unicode=True) # 通常我們在連接MySQL時傳入use_unicode=True,讓MySQL的DB-API始終返回Unicode cursor = conn.cursor() global keyword_base_index for i, keyword in enumerate(top_keywords): i=i+int(keyword_base_index) cursor.execute(insert into 163music.keywords(top_id, keyword,weight,songid) values (%s, %s,%s, %s), [i, keyword[0], keyword[1], songid]) keyword_base_index=int(keyword_base_index)+len(top_keywords) conn.commit() cursor.close()def get_songid_by_name(songname): headers = { User-Agent: Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36} for i in range(10): # 一個i獲取10個評論 text = { username: , password: , rememberLogin: true, keywords:songname } text = json.dumps(text) secKey = createSecretKey(16) encText = aesEncrypt(aesEncrypt(text, nonce), secKey) encSecKey = rsaEncrypt(secKey, pubKey, modulus) payload = { params: encText, encSecKey: encSecKey } url=http://music.163.com/weapi/search/suggest/web?csrf_token= r = requests.post(url, headers=headers, data=payload) r.raise_for_status() print(r.headers) print(r.text) # 把返回的json格式轉換成字典,方便提取關鍵字 try: r_dic = json.loads(r.text) except: print("json.loads(r.text)出錯了") print(r_dic) #comments = r_dic["comments"] print()if __name__ == __main__: # 在資料庫中新建兩張表,comments和keywords print("下面載資料庫中新建兩張表") init_tables() print("下面開始主流程") #songname=鼓樓 song_id_lst=find_songname.get_songid_lst() print(下面開始一首一首獲取評論) for song_id in song_id_lst: print("下面開始對id為{0}的歌進行爬取和分析".format(song_id)) top_keywords = get_and_save_top_keywords(song_id) assert top_keywords, top_keywords is Null for i, top_keyword in enumerate(top_keywords): print(top{0} {1} {2}.format(i, top_keyword[0], top_keyword[1]))
#find_songname.pyfrom tkinter import *import tkinter.messagebox as messageboxfrom selenium import webdriverfrom selenium.webdriver.support.ui import WebDriverWaitimport timeseq_selected = []def users_choice(song_lst): print("請選擇:") root=Tk() #第1行 row1 = Frame(root) row1.pack(fill="x", side=TOP) Label(row1, text=找到相關歌曲 + str(len(song_lst)) + 首,請選擇:, width_=40, font=("宋體", 16, "bold")).pack(side=LEFT) #第2行 #row2 = Frame(root,bg=yellow) #row2.pack(fill="x", side=TOP) scrollbar = Scrollbar(root,orient = VERTICAL) scrollbar.pack(side=LEFT,fill=Y) mylist = Listbox(root, yscrollcommand=scrollbar.set, selectmode=MULTIPLE,width_=80) for i, song in enumerate(song_lst): mylist.insert(END, {0}:{1} by {2} ({3}).format(i, song[1], song[2], song[3])) mylist.pack(side=LEFT, fill=X) scrollbar.config(command=mylist.yview) #第一行 def cancel(): root.destroy() def ok(): global seq_selected seq_selected = mylist.curselection() root.destroy() button1=Button(row1, text="取消", command=cancel).pack(side=RIGHT) button2=Button(row1, text="提交", command=ok).pack(side=RIGHT) root.mainloop() return#return item#先假設用戶選擇了第一個def find_songname(songname): browser = webdriver.Chrome() #最小化窗口,因為用戶不關心過程 #browser.minimize_window()報錯Message: unknown command: session/67b6fb8a56cad99cc5646646267e71c4/window/minimize url = http://music.163.com/#/search/m/ browser.get(url) #先定位到g-iframe這個frame frame1 = browser.find_element_by_css_selector(".g-iframe") browser.switch_to.frame(frame1) # 再定位到輸入框,輸入songname browser.find_element_by_id("m-search-input").send_keys(songname) #再定位到搜索按鈕,點擊 browser.find_element_by_css_selector(".btn").click() # 必須等待,否則網頁還沒開始載入 WebDriverWait(browser, 10).until(lambda driver: driver.find_element_by_class_name("srchsongst")) #拿到渲染後的網頁, text = browser.page_source browser.close() #print(text) #用正則提取歌曲信息,保存到song_lst pat = re.compile( r<div cla.*?song_(.*?)" cla.*?b title="(.*?)">.*?artist.*?d+">([^<][^s].*?)</.*?album.*?le="(.*?)">.*?</div>) # pat=r<div class="item.*?<a id="song_(.*?)" class="ply.*?><b title="(.*?).*?artist?id=.*?>([^s].*?)</a></div>.*?title="(.*?).*?</div></div> song_lst = pat.findall(text, re.S) print(展示相關歌曲{0}首.format(len(song_lst))) for song in song_lst: print(song) #呈現給用戶,讓用戶選擇 print(調用tk展示song_lst,讓用戶選擇) users_choice(song_lst) global seq_selected print(在tk中,用戶選擇了:,seq_selected) song_id=[] #TypeError: tuple object is not callable for seq in seq_selected: song_id.append(song_lst[seq][0]) return song_idclass InputApp(Tk): def __init__(self): super().__init__() self.song_name = StringVar() # 2指定root屬性 self.title(input_songname) self.resizable(1, 0)#不懂 self.geometry(180x60) self.setup_UI() def setup_UI(self): # 3在root內創建第1行…… row1 = Frame(self) row1.pack(fill="x") Label(row1, text=請輸入歌名:, width_=12).pack(side=LEFT) Entry(row1, textvariable=self.song_name, width_=20).pack(side=LEFT) #第2行 row2 = Frame(self) row2.pack(fill="x") #這個順序保證"確定"在左 Button(row2, text="取消", command=self.cancel).pack(side=RIGHT) btn2=Button(row2, text="確定", command=self.ok).pack(side=RIGHT) def cancel(self): self.destroy() def ok(self): # 返回輸入 assert self.song_name,"輸入為空" song_name=self.song_name.get() self.destroy()def input_songname(): #1創建root主窗口 root = InputApp() root.mainloop()#為什麼開兩個 song_name=root.song_name.get() return song_namedef get_songid_lst(): print(請輸入歌名) song_name = input_songname() print(用戶已輸入:, song_name) print("下面開始查找相關歌曲") song_id = find_songname(song_name) assert song_id, 用戶沒有選擇,請重新啟動 print("已經拿到songid,song_id=", song_id) return song_idif __name__==__main__: get_songid_lst() #users_choice([[1,2,3,4],[5,6,7,8]])
【Tkinter學習】
參考:
Python Tkinter簡明教程
tkinter 彈出窗口 傳值回到 主窗口(兩個例子)
新手學習StringVar(),IntVar()遇到問題求助,Python交流,技術交流區,魚C論壇 - Powered by Discuz!
Tkinter 控制項詳細介紹 - CSDN博客
PYTHON Tkinter GUI
Tkinter顏色方案舉例 - 孔紮根 - 博客園(顏色)
Python GUI之tkinter布局管理(布局pack)
TKinter布局之pack - 孔紮根 - 博客園
Python Tkinter Grid布局管理器詳解(布局grid)
《Python編程》筆記(十) - 克里斯的小屋 - CSDN博客
Tkinter教程之Listbox篇 - CSDN博客
推薦閱讀:
※Python爬取拉勾網所有的職位信息(一)
※關於在Python中安裝Scrapy 框架遇到的問題的解決方案
※python爬取QQ音樂
※PYTHON爬蟲將相對路徑轉化為絕對路徑