富股吧評論:從接口分析到數(shù)據(jù)存儲)
如果你關(guān)注過東方財(cái)富網(wǎng)的股吧評論大概率會發(fā)現(xiàn)一種奇特現(xiàn)象某只股票明明業(yè)績平平評論區(qū)里卻一片“起飛”的歡呼另一只基本面扎實(shí)的股票股吧里反倒罵聲不斷。這種散戶情緒和基本面數(shù)據(jù)之間的背離恰恰是很多人做輿情監(jiān)控、短線情緒判斷甚至量化策略時不想錯過的信號。用python爬取東方財(cái)富網(wǎng)股吧評論再用情感分析把這些“看好”“稀爛”“沖就完事”還原成可量化的情緒分?jǐn)?shù)是一條非常順手的技術(shù)路徑。這篇文章是這個系列的第一篇先把最底層的數(shù)據(jù)源打通怎樣把股吧評論穩(wěn)定地抓下來、存成結(jié)構(gòu)化數(shù)據(jù)為后續(xù)的情感建模做原料準(zhǔn)備。我一直覺得爬蟲最難的部分不是寫代碼而是搞清楚數(shù)據(jù)到底藏在哪、長什么樣。股吧這類動態(tài)頁面尤其典型你直接去網(wǎng)頁源碼里搜評論關(guān)鍵詞大概率搜不到真正的內(nèi)容是通過接口動態(tài)加載回來的。所以這一篇會從頁面分析開始帶你把接口找出來再封裝成可復(fù)用的爬蟲函數(shù)中間穿插一些我實(shí)際踩過的坑。如果你按照這篇走完至少能拿到一份干凈、可入庫的東方財(cái)富股吧評論數(shù)據(jù)。1. 為什么把第一站選在東方財(cái)富股吧1.1 股吧評論是難得的“散戶情緒樣本”做股票相關(guān)內(nèi)容的人多多少少都想過一個問題普通投資者的情緒能不能被量化答案是可以但前提是要有足夠大、足夠真實(shí)的文本來源。東方財(cái)富股吧恰好滿足這個條件它是國內(nèi)股民討論熱度最高的社區(qū)之一每只股票都有自己的獨(dú)立討論區(qū)評論更新頻繁而且大多數(shù)發(fā)言都是用戶即興寫出來的口語化嚴(yán)重情緒表達(dá)非常直白。相比上市公司公告、研報(bào)這類正式文本股吧評論的語言粗糙、觀點(diǎn)極端但正因如此它反而更適合做情感分析訓(xùn)練樣本。你可以從評論里提取出“看漲”“看跌”“中性”三類信號再結(jié)合發(fā)布時間、熱度指標(biāo)做趨勢分析。很多量化團(tuán)隊(duì)會把股吧情緒指數(shù)作為輔助因子不是沒有道理的。當(dāng)然我在這里先強(qiáng)調(diào)一句爬取數(shù)據(jù)請嚴(yán)格遵守平臺規(guī)則和相關(guān)法律法規(guī)只做個人學(xué)習(xí)研究使用不要抓取非公開數(shù)據(jù)更不要去撞接口頻率、抓用戶隱私。后面我會在代碼里刻意加請求延時也是這個原因。1.2 這個系列我只打算做兩件事抓下來讀懂它既然是系列文章的第一篇我不想一開始就鋪得太大。整個項(xiàng)目可以拆成兩條主線第一條是把數(shù)據(jù)穩(wěn)定地抓下來包括評論內(nèi)容、作者、發(fā)布時間、閱讀量、點(diǎn)贊數(shù)等第二條是對文本做情感分析比如先用現(xiàn)成的SnowNLP跑一版再用LSTM或BERT做更精細(xì)的分類。這一篇先完成第一條線的前半段爬取帖子列表。為什么不是直接爬每一條樓中樓回復(fù)因?yàn)榈谝徊阶铌P(guān)鍵的是打通數(shù)據(jù)鏈路、把數(shù)據(jù)形態(tài)搞清楚。帖子列表里的標(biāo)題和摘要本身就已經(jīng)包含大量觀點(diǎn)足夠后續(xù)情感分析用了。真到需要分析“評論里的評論”時在現(xiàn)有代碼基礎(chǔ)上再往詳情頁鉆一層即可設(shè)計(jì)思路是一樣的。2. 動手前先把這三件事想清楚2.1 環(huán)境Python版本、IDE與依賴庫如果你還沒裝Python建議直接裝3.8及以上版本我這里用的是Python 3.10。太老的版本對類型注解、第三方庫的兼容性會差一些沒必要給自己添堵。IDE方面PyCharm和VS Code都行個人更推薦VS Code輕量、插件豐富配好Python擴(kuò)展后寫爬蟲足夠順手。依賴庫沒有太多花哨的東西核心就這幾個requests發(fā)HTTP請求拉取接口數(shù)據(jù)beautifulsoup4解析HTML和清洗文本lxml作為BeautifulSoup的解析器速度比純Python的html.parser更快pandas最后統(tǒng)一處理表格數(shù)據(jù)、導(dǎo)出CSVopenpyxl如果你想把數(shù)據(jù)存成Excel需要裝這個在終端里執(zhí)行pip install requests beautifulsoup4 lxml pandas openpyxl如果你用Anaconda直接用conda安裝也可以。這里不展開講Python安裝的每一步網(wǎng)上資料很多但有一點(diǎn)值得提醒安裝后務(wù)必確認(rèn)pip指向的是你正在用的Python版本可以在命令行執(zhí)行python -m pip --version檢查避免裝了一堆庫結(jié)果腳本里導(dǎo)入失敗。2.2 合規(guī)個人研究如何爬才不越界每次寫爬蟲教程我都要專門說合規(guī)因?yàn)檫@事太重要了。爬蟲本身不違法但如果用來抓取非公開數(shù)據(jù)、繞過訪問控制、大規(guī)模影響網(wǎng)站正常運(yùn)行那就越界了。具體到東方財(cái)富股吧我的實(shí)踐原則只有四條只抓公開可見的數(shù)據(jù)不碰需要登錄后才能看到的用戶隱私字段。請求頻率控制在人類手動瀏覽的節(jié)奏之下例如每頁之間至少間隔1秒。不把抓下來的數(shù)據(jù)用于商業(yè)用途或二次傳播。不通過構(gòu)造惡意參數(shù)去試探接口漏洞也不嘗試?yán)@驗(yàn)證碼或封禁機(jī)制。這些原則寫起來簡單但真正執(zhí)行起來容易被“再抓一頁就夠”的僥幸心理打破。我的建議是直接在代碼里寫死time.sleep()把延時變成硬約束。2.3 技術(shù)選型為什么是requests而不是selenium剛開始學(xué)爬蟲的人容易陷入一個誤區(qū)看到網(wǎng)頁內(nèi)容加載不出來第一反應(yīng)是上Selenium模擬瀏覽器。Selenium確實(shí)萬能但代價(jià)是啟動瀏覽器、渲染頁面、等待網(wǎng)絡(luò)每一步都很慢而且內(nèi)存占用高。對于股吧這種數(shù)據(jù)其實(shí)是通過接口返回的頁面用Selenium屬于大炮打蚊子。我選擇requests直接請求接口原因很直接接口返回的是結(jié)構(gòu)化JSON解析起來比HTML容易得多。請求次數(shù)少、速度快對服務(wù)器更友好。代碼簡單后續(xù)如果要爬多只股票只要改參數(shù)就能復(fù)用。如果你要爬的網(wǎng)站沒有接口內(nèi)容全部寫在HTML源碼里那用requests加BeautifulSoup也夠了。只有遇到極端的動態(tài)渲染頁面比如內(nèi)容依賴復(fù)雜JavaScript計(jì)算生成才需要上Selenium或Playwright。股吧不屬于這種情況所以這篇文章里完全不用模擬瀏覽器。3. 拆開股吧頁面評論其實(shí)不在網(wǎng)頁源碼里3.1 直接在HTML里搜關(guān)鍵詞為什么搜不到我第一次爬股吧時習(xí)慣性地用requests.get把帖子列表頁拉下來然后想著用BeautifulSoup去解析帖子標(biāo)題。結(jié)果發(fā)現(xiàn)HTML源碼里只有頁面框架、導(dǎo)航欄和一堆JavaScript變量帖子內(nèi)容一個都沒出現(xiàn)。當(dāng)時我還以為是反爬后來才知道是太天真股吧的評論數(shù)據(jù)是頁面加載完成后通過異步請求從后端接口拿到的。這個現(xiàn)象在今天的網(wǎng)頁里非常普遍術(shù)語叫前后端分離。頁面只負(fù)責(zé)搭骨架數(shù)據(jù)由JavaScript代碼從另一個URL請求回來再填充到頁面上。所以我們爬蟲要做的不是跟HTML死磕而是找到那個真正返回評論數(shù)據(jù)的XHR請求。3.2 用開發(fā)者工具揪出真實(shí)的數(shù)據(jù)接口這一步是整個爬蟲的核心也最容易被教程一帶而過。我建議你親手走一遍流程以后換成任何網(wǎng)站都會了。用Chrome或Edge打開東方財(cái)富網(wǎng)任意一只股票的股吧頁面比如貴州茅臺吧。按F12打開開發(fā)者工具切換到Network面板再按CtrlR刷新頁面。此時Network面板里會出現(xiàn)幾十條請求不要慌我們只關(guān)心XHR類型的請求。在篩選欄里輸入XHR然后再看請求名稱。你要找的是那種返回內(nèi)容里有帖子標(biāo)題、作者、時間字段的接口。通常它的名字會帶list、data、post之類的關(guān)鍵詞。點(diǎn)開一條請求切到Preview或Response頁簽如果能看到大段JSON格式的數(shù)據(jù)基本就是它了。我當(dāng)時抓到的接口地址形如https://guba.eastmoney.com/interface/GetData.aspx參數(shù)里包含股票代碼、頁碼、每頁條數(shù)等信息。但你看到的時候接口參數(shù)可能已經(jīng)變了這很正常。關(guān)鍵是學(xué)會怎么去看請求的URL和參數(shù)而不是死記硬背一個地址。從請求里你能看到這樣幾條關(guān)鍵信息請求方式GET還是POST請求URL帶?后面的query參數(shù)請求頭至少需要User-Agent和Referer返回格式JSON還是JSONP3.3 小測試先手動請求一次接口找到接口后先在命令行里做一次最小請求驗(yàn)證確認(rèn)能通再寫完整爬蟲。我習(xí)慣用Python交互式環(huán)境或?qū)懸粋€臨時腳本import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: https://guba.eastmoney.com/, } url https://guba.eastmoney.com/interface/GetData.aspx params { path: list,600519, p: 1, ps: 20, } resp requests.get(url, paramsparams, headersHEADERS, timeout10) print(resp.status_code) print(resp.text[:500])如果返回的是一段以var開頭或包裹在括號里的文本不要慌那多半是JSONP格式需要做一次清理。如果返回狀態(tài)碼403第一件事先檢查User-Agent和Referer絕大多數(shù)403都是請求頭缺失導(dǎo)致的。這一步非常值得花時間做扎實(shí)因?yàn)榻涌谀懿荒芡Q定了后面所有代碼的走向。4. 寫爬蟲封裝請求、解析字段、翻頁循環(huán)4.1 請求頭偽裝與公共參數(shù)封裝接口確認(rèn)沒問題后就可以把爬蟲寫成函數(shù)了。我習(xí)慣先把請求頭、公共參數(shù)、超時時間統(tǒng)一管理起來避免在翻頁循環(huán)里反復(fù)復(fù)制粘貼。import json import time import datetime import requests import pandas as pd from bs4 import BeautifulSoup BASE_URL https://guba.eastmoney.com/interface/GetData.aspx HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: https://guba.eastmoney.com/, Accept: application/json, text/plain, */*, } def build_params(stock_code, page, page_size20): return { path: flist,{stock_code}, p: page, ps: page_size, }這里有一個非常實(shí)用的經(jīng)驗(yàn)page_size不要設(shè)太大。有人覺得一頁拉100條省事但很多接口對單次返回條數(shù)有隱藏限制你設(shè)大了它反而只返回默認(rèn)值甚至直接報(bào)錯。先設(shè)20跑一次看返回條數(shù)再根據(jù)情況調(diào)整。4.2 解析接口返回中的關(guān)鍵字段接口返回的文本可能不是純JSON而是帶了一個回調(diào)外殼。我寫了一個通用函數(shù)把這種情況處理掉def clean_response(text): 清理JSONP外殼提取出真正的JSON部分 text text.strip() # 處理 var data (...) 這種形式 if in text: text text.split(, 1)[1].strip() # 處理 ({...}) 這種形式 if text.startswith(() and text.endswith()): text text[1:-1] return json.loads(text)clean_response返回的通常是字典里面包含帖子列表。每家網(wǎng)站的字段名不一樣我在實(shí)際工作中遇到比較多的是post_id、post_title、post_abstract、user_nickname、post_publish_time、read_count、comment_count、like_count這些。具體字段名以你抓到的返回為準(zhǔn)但解析邏輯是一致的def parse_post(post): 把單條帖子轉(zhuǎn)成標(biāo)準(zhǔn)化字典 title BeautifulSoup(post.get(post_title, ), lxml).get_text().strip() content BeautifulSoup(post.get(post_abstract, ), lxml).get_text().strip() return { post_id: post.get(post_id), title: title, content: content, author: post.get(user_nickname), publish_time: post.get(post_publish_time), read_count: post.get(read_count), comment_count: post.get(comment_count), like_count: post.get(like_count), }這里用BeautifulSoup再清洗一遍字段值是因?yàn)槟承┨訕?biāo)題里會殘留\n、\u3000之類的東西甚至可能有HTML標(biāo)簽。清洗文本這個步驟雖然不起眼但如果你直接拿去訓(xùn)練模型會發(fā)現(xiàn)數(shù)據(jù)噪聲大到結(jié)果根本沒法看。4.3 翻頁、去重與斷點(diǎn)保存拿到單頁數(shù)據(jù)以后翻頁就簡單了但有幾個細(xì)節(jié)必須處理第一接口返回空列表時應(yīng)該立刻停止而不是繼續(xù)空跑到最大頁數(shù)。第二遇到請求異常不能直接整個程序崩潰要捕獲后重試。第三爬到一半可能被中斷所以要有斷點(diǎn)續(xù)爬的思路。下面這段是我常用的翻頁函數(shù)def fetch_page(stock_code, page, retry3): params build_params(stock_code, page) for attempt in range(retry): try: resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) resp.raise_for_status() data clean_response(resp.text) # 根據(jù)實(shí)際返回結(jié)構(gòu)取列表 posts data.get(re, []) if isinstance(posts, dict): posts posts.get(list, []) return posts except Exception as e: print(f第{page}頁第{attempt1}次請求失敗: {e}) time.sleep(2) return []翻頁主邏輯def crawl_stock(stock_code, max_pages10, delay1.2): all_posts [] seen_ids set() for page in range(1, max_pages 1): posts fetch_page(stock_code, page) if not posts: print(f第{page}頁無數(shù)據(jù)停止翻頁) break new_count 0 for post in posts: parsed parse_post(post) if not parsed[post_id] or parsed[post_id] in seen_ids: continue seen_ids.add(parsed[post_id]) all_posts.append(parsed) new_count 1 print(f第{page}頁完成新增{new_count}條累計(jì){len(all_posts)}條) time.sleep(delay) return all_postsseen_ids是我從實(shí)際項(xiàng)目中總結(jié)出來的一個強(qiáng)制要求。接口偶發(fā)情況下會在不同頁碼返回同一批帖子如果你不去重最終數(shù)據(jù)里會出現(xiàn)大量完全一樣的評論清洗階段還得再花力氣處理。斷點(diǎn)續(xù)爬這塊最簡單的方式是把已經(jīng)抓到的post_id集合寫入一個文件下次啟動時先加載它。這樣萬一你抓了2000條后斷網(wǎng)不需要從頭再來import os def load_seen_ids(filepath): if not os.path.exists(filepath): return set() df pd.read_csv(filepath) return set(df[post_id].astype(str)) def save_posts(all_posts, filepath): df pd.DataFrame(all_posts) df df.drop_duplicates(subsetpost_id) df.to_csv(filepath, indexFalse, encodingutf-8-sig)這樣crawl_stock主函數(shù)里先用load_seen_ids初始化集合每抓完一頁或累計(jì)到一定數(shù)量就調(diào)一次save_posts即使中斷也不會損失太多進(jìn)度。5. 數(shù)據(jù)清洗與存儲給情感分析準(zhǔn)備干凈的原料5.1 清洗規(guī)則去標(biāo)簽、去重、時間格式統(tǒng)一數(shù)據(jù)爬下來只是第一步能不能給情感分析用取決于清洗質(zhì)量。我見過很多人在這一步偷懶結(jié)果模型訓(xùn)練出來效果很差還以為是算法不行其實(shí)是數(shù)據(jù)里全是空值和亂碼。我的清洗流程一般分四步第一步去HTML標(biāo)簽。上面解析時已經(jīng)用BeautifulSoup處理過但有些摘要字段可能還會殘留所以我會再統(tǒng)一過一遍。第二步去空白字符和特殊符號。\u3000、\xa0、連續(xù)空格全部替換掉。中英文標(biāo)點(diǎn)混用的問題先不處理等做分詞時再說。第三步去重復(fù)數(shù)據(jù)。上面已經(jīng)用post_id去重但有時同一條內(nèi)容會被不同用戶轉(zhuǎn)發(fā)所以還需要再按“標(biāo)題內(nèi)容”做一次去重。第四步統(tǒng)一時間格式。股吧返回的時間可能是2024-01-15 10:30:00也可能是Unix時間戳甚至還可能是毫秒級時間戳。我有一個轉(zhuǎn)換函數(shù)def normalize_time(ts): if ts is None: return None if isinstance(ts, str): return ts.strip() try: ts int(ts) # 毫秒級時間戳轉(zhuǎn)成秒級 if ts 10**12: ts ts / 1000 return datetime.datetime.fromtimestamp(ts).strftime(%Y-%m-%d %H:%M:%S) except Exception: return str(ts)這個函數(shù)在后續(xù)做時間序列分析、按小時統(tǒng)計(jì)情緒變化時非常有用。5.2 存儲CSV和SQLite兩種方案清洗后的數(shù)據(jù)可以存成CSV方便直接查看和用pandas做分析。如果數(shù)據(jù)量大、需要頻繁查詢我更推薦存SQLite輕量、單文件、跨平臺后續(xù)接情感分析腳本也方便。CSV存儲很簡單df pd.DataFrame(all_posts) df[publish_time] df[publish_time].apply(normalize_time) df df.dropna(subset[content]) df df[df[content].str.strip() ! ] df df.drop_duplicates(subset[title, content]) df.to_csv(guba_600519.csv, indexFalse, encodingutf-8-sig)SQLite的話用Python自帶的sqlite3就能搞定import sqlite3 conn sqlite3.connect(guba.db) df.to_sql(guba_comments, conn, if_existsreplace, indexFalse) conn.close()to_sql是pandas自帶的方法底層會幫你建表字段名直接沿用DataFrame的列名。對于這個項(xiàng)目來說完全夠用不用手寫建表語句。從進(jìn)化的角度看我建議你先把CSV方案跑通確認(rèn)數(shù)據(jù)形態(tài)沒問題之后再考慮遷移到SQLite。不要一上來就搭一套復(fù)雜的數(shù)據(jù)庫數(shù)據(jù)和模型都沒跑通先保持簡單。6. 我踩過的坑反爬、IP頻率與半路斷掉6.1 被限制時最常見的三種表現(xiàn)東方財(cái)富股吧的整體反爬強(qiáng)度不算變態(tài)但也不是毫無防御。我在實(shí)際抓取中遇到過三種情況一旦出現(xiàn)就要立刻停下來檢查第一種請求返回403。這種情況九成是請求頭問題尤其是User-Agent。有些爬蟲框架默認(rèn)的UA是Python-requests/x.x服務(wù)端一眼就能識別。所以一定要顯式設(shè)置成瀏覽器UA并且?guī)蟁eferer。第二種返回200但數(shù)據(jù)為空。接口沒有報(bào)錯但返回的列表是空。這可能是你請求參數(shù)不對也可能是被服務(wù)端靜默過濾了高頻IP。遇到這種情況先降低請求頻率再檢查參數(shù)。第三種請求偶爾成功偶爾失敗間隔幾次就超時。這是頻率限制的典型信號。不要硬扛直接把延時從1秒調(diào)到3秒再觀察一段時間。我把這些問題整理成了一張表方便你排查現(xiàn)象可能原因處理方式403請求頭缺失或UA被識別補(bǔ)全User-Agent、Referer模擬瀏覽器200但列表為空參數(shù)錯誤或接口字段變化回到開發(fā)者工具重新核對參數(shù)偶發(fā)超時請求頻率過高增加sleep間隔降低翻頁速度數(shù)據(jù)大量重復(fù)接口翻頁不穩(wěn)定用post_id和內(nèi)容做雙重去重6.2 延遲、重試和斷點(diǎn)續(xù)爬的兜底方案關(guān)于爬蟲的穩(wěn)定性我想分享一個觀念穩(wěn)定比快更重要。很多人喜歡用協(xié)程、多線程把爬蟲寫成飆車模式一秒鐘發(fā)幾十個請求抓完十幾萬條數(shù)據(jù)。這種玩法在小型項(xiàng)目里可能沒問題但如果被對端限制整個任務(wù)前功盡棄的概率也很大。我的做法一向是單線程、帶延時、帶重試、帶斷點(diǎn)。單線程的好處是邏輯簡單不會出現(xiàn)請求順序錯亂的問題延時控制在1到2秒既不招搖也足夠快。一個股票吧最多抓幾十頁總共用不了一分鐘真的沒必要再優(yōu)化速度。重試機(jī)制也很重要。網(wǎng)絡(luò)請求是不可靠的偶爾一次超時很常見。fetch_page里我已經(jīng)寫了最多重試3次的邏輯每次重試間隔2秒。如果3次都失敗就返回空列表讓主循環(huán)自然停止保住已經(jīng)抓到的數(shù)據(jù)。斷點(diǎn)續(xù)爬我再次強(qiáng)調(diào)非常關(guān)鍵。我的一個朋友抓其他網(wǎng)站的數(shù)據(jù)沒有斷點(diǎn)邏輯抓到一半服務(wù)器超時幾個小時的進(jìn)度全丟了。從那以后我再也不寫沒有狀態(tài)的爬蟲。每次新增數(shù)據(jù)后立刻落盤哪怕程序中途崩了最多損失最后一頁的數(shù)據(jù)。6.3 用爬到的數(shù)據(jù)做什么先跑一遍簡單統(tǒng)計(jì)數(shù)據(jù)存儲完之后可以先做一點(diǎn)最基礎(chǔ)的統(tǒng)計(jì)分析驗(yàn)證數(shù)據(jù)質(zhì)量也為后續(xù)的情感分析做一個鋪墊。比如統(tǒng)計(jì)一下共爬了多少條評論、哪些帖子閱讀量和評論數(shù)最高、評論時間分布是什么樣的。import pandas as pd df pd.read_csv(guba_600519.csv) df[publish_time] pd.to_datetime(df[publish_time], errorscoerce) print(df.shape) print(df.describe()) print(df[publish_time].dt.hour.value_counts().sort_index())這些數(shù)字能讓你直觀感受到數(shù)據(jù)的分布如果發(fā)現(xiàn)發(fā)布時間集中在一個異常的時間段或者某天的數(shù)據(jù)量猛增可能意味著抓取過程中有些頁面被漏掉了也可能是當(dāng)天該股票確實(shí)出了大新聞。這種對數(shù)據(jù)的敏感度是后續(xù)做情感分析時判斷結(jié)果是否合理的基礎(chǔ)。到這里爬取股吧評論這條路已經(jīng)通了。你手里的數(shù)據(jù)包含標(biāo)題、摘要、作者、時間、閱讀量、評論量和點(diǎn)贊量已經(jīng)是一份能支撐后續(xù)分析的干凈數(shù)據(jù)。下一篇我會把這批數(shù)據(jù)接進(jìn)情感分析流程先用SnowNLP這種輕量方案快速跑一版情緒得分再對比一下LSTM的效果。到時候你會發(fā)現(xiàn)前面花在頁面分析和數(shù)據(jù)清洗上的時間每一分鐘都不會白費(fèi)。