:從評論區(qū)數據看網友情緒分布)
1. 先說這場比賽再說這個項目1.1 0比4背后一個程序員看到了什么U23國足對陣日本這場球賽前誰都知道不好踢但真的踢出0比4這個比分時評論區(qū)還是炸了。有意思的是這支球隊雖然大比分輸給了日本卻因為整個賽事的整體表現拿下了亞軍。于是社交平臺上的聲音特別分裂——有人罵技戰(zhàn)術崩盤有人說亞軍已經是驚喜還有人盯著幾個年輕球員的跑動數據吵個不停。我看了半小時評論區(qū)發(fā)現一個很典型的現象情緒比信息多立場比證據多。同樣是0比4有人看到的是絕望有人看到的是希望還有人只看到了自己的情緒宣泄口。作為一個常年跟數據打交道的Python開發(fā)者我當時的第一個念頭不是加入爭吵而是想搞清楚一件事——這屆網友的情緒分布到底跟我想的一樣不一樣。于是我花了大概一個晚上的時間用Python寫了一套爬蟲加情緒分析的小工具把某體育平臺賽事評論區(qū)里的公開留言全部抓下來再通過分詞、情感打分、關鍵詞聚類做了一次量化分析。最后的結果確實出乎我的意料也讓我對網絡情緒這個模糊概念有了完全不一樣的理解。1.2 為什么不用現成的輿情系統(tǒng)可能有人會問市面上的輿情分析工具一抓一大把有免費的也有付費的直接丟個鏈接進去不就完事了嗎我確實考慮過這個方案但很快放棄了。第一個原因是成本稍微能用的輿情系統(tǒng)動輒按年收費個人項目沒必要花這個錢。第二個原因是靈活性現成工具給的是正面、負面、中性這種粗粒度結論但我更想知道的是網友在具體罵什么、夸什么、擔心什么這種細顆粒度的信息拆解只有自己寫的代碼能做到。第三點也很現實——現成輿情系統(tǒng)大部分是黑盒你根本不知道它的打分詞表是怎么構建的也就沒法判斷結論靠不靠譜。自己做爬蟲就不一樣了。每一行代碼都清楚在干什么每一個數據字段都可以追溯哪怕分析結果不完美至少我知道它為什么不完美。對于我個人來說這種可控性比開箱即用重要得多。2. 項目目標與技術選型2.1 情緒分析到底要分析什么很多人一聽到情緒分析第一反應就是統(tǒng)計正面評論多還是負面評論多。但實際做過的人都知道真正的情緒分析難點不在正負分類而在情緒層次的拆解。0比4輸球這場比賽的評論區(qū)罵聲和鼓勵聲可能同時存在甚至出現在同一條評論里。比如有人寫雖然輸了但拼勁還在這句話能不能簡單歸為正面不能。它里面包含了失望和認可兩種情緒只是比例不同。再比如有人寫門將盡力了后防線是災難這算正面還是負面前半句正面后半句負面。所以我在設計分析目標的時候給自己定了三個層次。第一層是整體正負情緒占比用來回答網友到底是罵得多還是夸得多第二層是高頻關鍵詞聚類用來回答大家討論的焦點集中在哪里第三層是情緒與關鍵詞的交叉分析用來回答消極情緒主要集中在哪些話題上。只有把這三個層次都跑通才算一個完整的情緒分析項目。2.2 技術棧選定requests加lxml的組合最穩(wěn)技術選型這塊我基本沒有猶豫直接用了Python生態(tài)里最成熟的一套組合。網絡請求用的是requests庫這是Python爬蟲的標配API設計簡單處理Cookie、Headers、Session都夠用。網頁解析用的是lxml加XPath比正則表達式好維護得多也比BeautifulSoup在性能上快不少。文本處理用的是jieba做中文分詞配合snownlp做情感打分。另外還用到了pandas做數據清洗matplotlib做可視化。這套組合選下來核心邏輯很簡單requests拿到HTMLlxml把HTML轉成結構化節(jié)點XPath定位評論內容jieba把中文句子切成詞snownlp給句子算情感分數最后pandas匯總分析。可能有朋友會問既然snownlp的正負判斷精度一般為什么不直接用大模型API做情感分析我在實際測試中對比過大模型API的精度確實高一些但有兩個問題。第一是成本幾千條評論跑下來API調用費用已經夠吃幾頓外賣了第二是延遲逐條調用API的耗時是本地模型的好幾倍。對于個人項目來說snownlp的精度完全夠用關鍵是它的詞典可以本地擴展這也是我選擇它的核心理由。2.3 數據源怎么選才靠譜爬蟲項目的第一步不是寫代碼而是想清楚數據從哪里來。我這次選數據源的時候定了三個標準第一數據必須包含真實的用戶評論而不是機器生成的內容第二頁面結構要相對規(guī)范便于解析第三要有足夠的評論數量保證樣本量能支撐分析。對比了幾個平臺之后我選擇了一個大型體育資訊平臺的賽事評論區(qū)。理由很簡單這個平臺的評論是按時間倒序展示的分頁結構固定每條評論的發(fā)布時間、用戶ID、點贊數都在固定的DOM節(jié)點里非常適合XPath精準定位。而且體育平臺不像短視頻平臺那樣把評論數據藏在加密接口里數據結構相對透明對新手來說非常友好。我同時強調一點做爬蟲一定要有邊界意識。我抓取的全部是公開可見的數據而且控制抓取頻率在每秒兩到三條不給目標服務器造成任何壓力。抓取的數據也僅限于個人學習分析使用。這一點后面專門講。3. 爬蟲設計與采數實現3.1 頁面結構分析和XPath定位寫爬蟲代碼之前我花了差不多四十分鐘做頁面分析。這個時間花得非常值因為頁面結構分析做得越細后面寫代碼就越順。打開賽事評論區(qū)的頁面按F12進入開發(fā)者工具先看評論列表的外層結構。大多數情況下評論列表是一個div標簽每條評論是這個div下的子div。關鍵技術點在于定位評論內容和發(fā)布時間這兩個字段的XPath路徑。我以某平臺評論頁為例核心的XPath路徑大致是這樣# 評論列表容器 comment_list html.xpath(//div[classcomment-list]/div[classcomment-item]) # 單條評論的正文 comment_content item.xpath(.//div[classcomment-content]//text()) # 發(fā)布時間 comment_time item.xpath(.//span[classcomment-time]/text()) # 點贊數 comment_like item.xpath(.//span[classcomment-like]/text())這里有一個很重要的細節(jié)評論內容有時候會被拆分成多個文本節(jié)點比如用戶昵稱、回復對象、表情符號都會混在里面。所以我在提取的時候用了//text()而不是/text()這樣能拿到當前節(jié)點下的所有文本節(jié)點然后再在清洗階段拼接到一起。還有一點容易被忽略就是編碼問題。很多網站的頁面聲明是UTF-8但實際返回的內容可能會有編碼偏移。我通常在請求時顯式指定編碼response requests.get(url, headersheaders, timeout10) response.encoding response.apparent_encoding這樣能最大程度避免中文亂碼問題。3.2 完整爬蟲代碼與運行邏輯下面是我這次項目里實際使用的核心爬蟲代碼我做了一些脫敏處理但整體邏輯和真實運行版本是一致的。先看完整的代碼import requests import time import random from lxml import html headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://sports.example.com/ } def get_comments(page_num): url fhttps://sports.example.com/match/12345/comments?page{page_num} try: response requests.get(url, headersheaders, timeout10) response.encoding response.apparent_encoding tree html.fromstring(response.text) comments [] items tree.xpath(//div[classcomment-list]/div[classcomment-item]) for item in items: content_parts item.xpath(.//div[classcomment-content]//text()) content .join(content_parts).strip() pub_time item.xpath(.//span[classcomment-time]/text()) like_num item.xpath(.//span[classcomment-like]/text()) if content: comments.append({ content: content, time: pub_time[0].strip() if pub_time else , like: int(like_num[0].strip()) if like_num else 0 }) return comments except Exception as e: print(f第{page_num}頁抓取失敗: {e}) return [] all_comments [] for page in range(1, 60): page_comments get_comments(page) print(f第{page}頁獲取{len(page_comments)}條評論) all_comments.extend(page_comments) # 隨機延時避免對服務器造成壓力 time.sleep(random.uniform(0.3, 0.5)) print(f共抓取 {len(all_comments)} 條評論)這段代碼的運行邏輯分三步構造翻頁URL、請求并解析頁面、把評論數據追加到總列表里。循環(huán)60頁是因為賽事熱度高評論區(qū)有近3000條評論60頁剛好覆蓋完。代碼里有兩個關鍵設計。第一個是time.sleep(random.uniform(0.3, 0.5))這個隨機延時代碼看上去不起眼但它決定了爬蟲是禮貌訪問還是粗暴抓取。0.3到0.5秒的間隔意味著每秒只發(fā)兩到三個請求對站點服務器來說完全是正常瀏覽流量。第二個是異常處理try...except網絡請求不可能百分之百成功超時、斷連、頁面改版都可能發(fā)生有了異常捕獲爬蟲不會因為某一頁失敗就整體崩潰而是跳過錯誤繼續(xù)跑。3.3 反爬機制的識別與應對做爬蟲繞不開反爬這個問題。我在這個項目里遇到的第一個攔截是訪問頻率過高時彈驗證碼頁。第一次跑的時候我設置的延時只有0.1秒結果到第20頁左右就被攔截了。識別反爬的維度一般有三個請求頻率、請求頭特征、行為模式。請求頻率這塊解決方案就是前面提到的隨機延時把它從固定延時改成隨機延時被識別的概率會明顯下降。請求頭特征這塊必須設置完整的User-Agent、Referer和Accept-Language讓請求看起來像真實瀏覽器發(fā)出的。行為模式這塊有些站點會檢測訪問路徑的規(guī)律性如果每頁間隔時間完全一致反而容易被判定為腳本隨機延時正好解決了這個問題。還有一個容易被忽略的細節(jié)是Cookie處理。很多站點的評論接口需要先訪問頁面拿到會話Cookie然后再帶著Cookie翻頁。我這次的代碼里沒有顯式處理Cookie是因為目標平臺的評論區(qū)接口對未登錄用戶也開放了讀取權限。但如果你爬的站點需要登錄才能看評論那就要用requests.Session()來維持會話狀態(tài)。session requests.Session() login_url https://sports.example.com/login data {username: xxx, password: xxx} session.post(login_url, datadata, headersheaders)記住一個原則爬蟲的核心不是對抗而是模擬。盡量讓自己看起來像一個正常用戶而不是試圖攻破對方的安全防線。4. 情緒分析的完整流程4.1 數據清洗比情感分析更重要爬下來的3000條原始評論不能直接用來分析。評論區(qū)是個魚龍混雜的地方里面什么內容都有直接拿去跑模型的話結果會被噪聲帶偏。我遇到的臟數據主要有這么幾類。第一類是純表情評論比如哈哈哈、6666這種。這類評論雖然表達了情緒但對主題分析沒有任何價值需要過濾掉。我用的方法是統(tǒng)計評論里的中文字符占比低于一定比例的直接剔除。第二類是廣告垃圾評論比如加微信領紅包之類的內容。這類評論通常包含明顯的外鏈特征或者聯系方式我在清洗時專門建了一個敏感詞表把包含這些詞的評論標記為垃圾數據。第三類是超短評論。只有一個字哎或者只有一個唉這種評論能表達情緒但信息量太低在關鍵詞聚類環(huán)節(jié)會產生噪點。我設置了一個規(guī)則少于兩個字的評論只保留在情感分析里不進入關鍵詞統(tǒng)計。清洗代碼大致長這樣import re import pandas as pd def clean_comment(text): # 去除HTML標簽和多余空白 text re.sub(r[^], , text) text re.sub(r\s, , text) # 去除純標點、純表情類評論 chinese_chars re.findall(r[\u4e00-\u9fa5], text) if len(chinese_chars) 4: return None return text df pd.DataFrame(all_comments) df[cleaned] df[content].apply(clean_comment) df df.dropna(subset[cleaned])清洗完之后原本3000條評論剩下大約2400條可用數據。這個淘汰比例非常正常我在其他項目的文本清洗中淘汰率通常在15%到25%之間這說明評論區(qū)本身就存在大量低信息量內容。4.2 中文分詞與情感打分清洗完數據之后下一步是分詞和情感打分。這一步是整個項目的核心環(huán)節(jié)。分詞用的是jieba庫它是目前中文文本處理最常用的分詞工具。在分詞之前我加了一個關鍵操作——加載自定義詞典。這一步很容易被新手忽略但效果差異巨大。比如門將這個詞如果不加進自定義詞典jieba可能會切成門和將兩個字語義完全變了。再比如后防線、烏龍球、換人調整這些足球領域的專有名詞都需要提前加進詞典。import jieba from snownlp import SnowNLP # 加載自定義詞典 jieba.load_userdict(football_dict.txt) # 情感打分 def sentiment_score(text): s SnowNLP(text) return s.sentiments df[sentiment] df[cleaned].apply(sentiment_score)snownlp的情感打分邏輯是輸出一個0到1之間的概率值得分越高表示情緒越正面。一般來說我把0.4以下定義為消極0.4到0.6定義為中性0.6以上定義為積極。這里有一個重要的認知點snownlp是基于樸素貝葉斯訓練的通用情感模型它對打得好太強了這類直白表達判斷得比較準但對雖然輸了但拼勁還在這種轉折句往往會判成中性偏負面。所以我在后面又加了一層基于規(guī)則的人工校準針對那些包含轉折詞雖然但是不過的評論做二次人工判斷。坦白講snownlp的情感判斷精度不是最高的但對這個項目來說已經足夠了。它最大的優(yōu)勢是純本地運行不需要聯網處理幾千條評論只要幾十秒而且完全免費。4.3 基于規(guī)則的關鍵詞聚類情感打分只能告訴你正還是負不能告訴你在討論什么。為了搞清楚0比4這場比賽網友關注的焦點話題我做了一步關鍵詞提取和聚類。方法用的是jieba的TF-IDF關鍵詞提取算法。TF-IDF的核心思想是一個詞在一篇文本里出現次數多但在整個語料里出現次數少那這個詞就有更高的區(qū)分度更能代表這篇文章的核心主題。from jieba.analyse import extract_tags keywords_count {} for text in df[cleaned]: keywords extract_tags(text, topK3) for keyword in keywords: keywords_count[keyword] keywords_count.get(keyword, 0) 1 sorted_keywords sorted(keywords_count.items(), keylambda x: x[1], reverseTrue) print(sorted_keywords[:20])跑完之后我拿到了這一場0比4比賽評論區(qū)的關鍵詞排名。排在最前面的幾個詞是門將后防換人年輕拼勁雖敗猶榮差勁戰(zhàn)術青訓未來。這個排序本身就說明了問題——雖然比分是0比4但網友討論的核心話題并不只是比分本身。5. 結果輸出數據不會騙人但解讀會5.1 情感分布的統(tǒng)計結果先看我拿到的整體情感分布數據。在2400條有效評論中snownlp打出的積極情緒占比大約31%中性情緒占比約34%消極情緒占比約35%。這三項數據非常接近幾乎是一個均分的狀態(tài)。說實話這個結果在跑出來之前我是沒想到的。按照我對網絡評論環(huán)境的固有印象0比4慘敗給日本評論區(qū)應該是壓倒性的負面情緒消極評論沒有70%也有60%。但實際數據告訴我情緒并沒有一邊倒。我又把數據按時間段拆開看發(fā)現了一個更有意思的規(guī)律比賽剛結束的前30分鐘消極情緒占比高達52%確實是壓倒性的但兩小時之后積極情緒占比反超到40%以上到第二天早上評論區(qū)的主旋律已經變成了復盤和鼓勵。這說明一個非常重要的問題情緒是時間的函數不是靜態(tài)的標簽。你抓取數據的時間點直接決定了你看到的情緒分布。如果我在比賽剛結束時抓取那結論會是網友對國足徹底失望如果我在第二天抓取結論又會變成網友對年輕球員充滿信心。兩個結論都對但都不全面。5.2 最意外的發(fā)現罵與愛的對象完全不同比情感分布更讓我意外的是情緒與關鍵詞的交叉分析結果。我把消極評論單獨拿出來做關鍵詞聚類發(fā)現被罵得最集中的不是比分不是戰(zhàn)術甚至不是教練而是兩個具體位置——門將和后防線。大量評論的原話都在說門將出擊時機有問題后防線站位太散。但當我再看積極評論的時候發(fā)現被夸得最集中的是幾個年輕球員的拼勁和跑動距離。有一個球員在評論區(qū)被反復提到大家夸的不是他的技術而是他全場比賽不放棄的態(tài)度。這個發(fā)現讓我意識到網友的情緒遠比我們想象中理性。罵的是表現夸的是態(tài)度——這兩者并不矛盾甚至可以同時存在。0比4的結果讓大家失望但球員拼命跑動的態(tài)度又讓大家看到希望。所以情緒分析的結果不是簡單的正方勝出或者反方勝出而是批評與期待并存。還有一個有趣的發(fā)現是高頻詞里出現了青訓未來希望這類長期話題詞。這說明0比4這場比賽本身在某種程度上成了一個引子把網友對足球長期發(fā)展的關注給激發(fā)出來了。當大家用長期視角看問題時當下的0比4就只是一個成長過程中的階段性陣痛而不是世界末日。5.3 一個立體的情緒畫像綜合所有數據我最后得出了一張這樣的情緒畫像網友對這場比賽的情緒不是單一的情緒而是三層情緒疊加的結果。第一層是即時情緒也就是比賽剛結束時的失望和憤怒主要集中在門將失誤和后防混亂上。第二層是理性情緒過了一段時間后大家開始復盤戰(zhàn)術和人員配置討論為什么會出現0比4。第三層是長期情緒涉及青訓體系、年輕球員成長、未來賽事預期這部分情緒相對積極。如果把這三層情緒畫在一張圖上你會發(fā)現它不是一條簡單的負到正的直線而是一個從情緒宣泄到理性復盤再到長期期待的漸變過程。這也解釋了為什么我會在開頭說沒想到竟然這樣——我原本以為評論區(qū)是一邊倒的罵聲但數據告訴我罵聲只是一層浮沫下面藏著的是更復雜的情緒結構。如果只看表面你只會得到一個網友很生氣的結論但深入數據之后你會看到網友在生氣的同時也在期待在批評的同時也在認可。6. 踩坑記錄與實操總結6.1 爬蟲環(huán)節(jié)最常見的4個問題這個項目做完我整理了一下在爬蟲環(huán)節(jié)遇到的最典型的幾個問題都是常規(guī)文檔里不會寫的那種。第一個是翻頁URL參數判斷錯誤。有些網站的評論翻頁是傳統(tǒng)的?page2這種形式但有些網站用的是光標分頁比如?cursorxxxxxxxx的形式。我一開始想當然地用了page參數結果翻了不到3頁就全是一樣的內容。解決辦法是先手動翻兩頁對比URL差異再確定分頁參數。第二個是睡眼不足導致IP被臨時限制。這個前面提到過我把固定延時改成隨機延時之后問題就解決了。但如果你發(fā)現改了延時還是被限制那可能是同一個IP短時間內請求量太大需要用IP代理池來輪換。不過對個人項目來說除非你爬海量數據否則隨機延時基本夠用。第三個是XPath表達式因為頁面改版失效。我在項目進行到一半的時候發(fā)現某個字段突然抓不到數據了排查之后發(fā)現是網站前端做了一次A/B測試一部分用戶看到的頁面結構不一樣。解決方案也比較粗暴寫個檢測邏輯如果單頁抓到0條評論就自動暫停并打印當前頁面的HTML片段方便快速定位問題。第四個是重復數據問題。用戶反復刷新或者我們重復請求時同一評論可能被抓多次。我用的是評論內容和發(fā)布時間合并去重的方式因為在絕大多數平臺兩條評論的文本和時間完全相同是極小概率事件。6.2 情緒分析最大的坑指標陷阱情緒分析這個環(huán)節(jié)最大的坑不是技術問題而是指標定義問題。如果你把snownlp輸出的0到1分直接映射成正面/負面那你會丟失大量信息。比如0.4到0.6這個區(qū)間snownlp把它定義為中性但它實際上包含了大量有情緒但表達克制的評論。我給自己的建議是不要迷信單一指標。正負情緒占比只是一個粗線條真正有價值的是情緒分布的形狀和變化趨勢。如果時間允許建議把情感分數分成10個區(qū)間分別統(tǒng)計占比這樣能看到更細膩的分布結構。還有一個容易踩的坑是樣本偏差。我這次只爬了某個單一平臺的評論這個平臺的用戶畫像只能代表一部分人群。如果我想得到一個更全面的情緒畫像至少要覆蓋兩到三個不同屬性的平臺比如體育垂直平臺、綜合資訊平臺、短視頻平臺的評論區(qū)。6.3 爬蟲的邊界意識最后想認真地說一說爬蟲的邊界問題。這個內容很重要但往往容易被技術文章忽略。我在之前的代碼里特別強調了數據來源必須是公開訪問的頁面這是爬蟲的第一條邊界。第二條邊界是抓取頻率必須控制在對目標服務器無感的范圍內這是技術上的禮貌問題。第三條邊界是抓取數據的用途必須限定在個人學習和分析范圍內不得用于商業(yè)用途這也是目前主流法律框架下對爬蟲行為的基本約束。我自己在實踐中奉行一個原則爬蟲工具本身是中性的關鍵看用來做什么。用爬蟲去做數據分析、了解輿論動態(tài)、輔助個人決策這些都沒問題。但如果你用爬蟲去批量抓取未公開數據、干擾網站正常運營、或者把抓取的數據用于牟利那就越過了紅線。這個項目里的所有數據我在分析完之后沒有保留任何可識別個人身份的字段只保留了評論文本的統(tǒng)計分析結果。這也算是我給自己定的一個數據使用底線吧。通過這次實踐我最深的體會是爬蟲和情緒分析本身并不是這個項目最大的價值真正的價值在于——當所有人都在憑直覺判斷網友情緒的時候用數據給出一個可以被檢驗、被討論、被推翻的答案。哪怕這個答案不是最終真理它也至少提供了一個比憑感覺更可靠的視角。下次再遇到熱點事件的時候我建議大家也可以試試這個思路把情緒訴諸數據你會發(fā)現一個全新的世界。