:雪球股票情緒數(shù)據(jù)采集與反爬優(yōu)化)
1. 項目緣起與整體設(shè)計思路1.1 為什么盯上了雪球網(wǎng)的股票情緒數(shù)據(jù)做量化或者做投資研究的朋友都知道股價短期波動很大程度上受市場情緒驅(qū)動。雪球網(wǎng)作為國內(nèi)活躍的投資者社區(qū)每天產(chǎn)生大量關(guān)于個股的討論帖、評論和轉(zhuǎn)發(fā)這些文本里藏著非常真實的散戶與機構(gòu)情緒。如果能把這些數(shù)據(jù)穩(wěn)定地采集下來做情感分析、熱度排名、異動預(yù)警價值是相當直接的。但問題在于雪球網(wǎng)的數(shù)據(jù)并不是靜態(tài)躺在HTML里的。它的帖子列表、評論、熱度指標大量依賴前端異步渲染很多內(nèi)容要等JavaScript執(zhí)行完才出現(xiàn)在DOM里。早期我用傳統(tǒng)的requests BeautifulSoup去抓拿到的往往是一個空殼頁面關(guān)鍵數(shù)據(jù)一個都取不到。后來換成Selenium能跑通但速度慢、資源占用高跑幾百個頁面機器就開始發(fā)燙而且穩(wěn)定性堪憂動不動就超時。最終我把方案鎖定在Playwright上。它原生支持現(xiàn)代瀏覽器內(nèi)核能真實執(zhí)行頁面JS等待機制比Selenium更聰明還自帶請求監(jiān)聽能力可以順手把接口返回的JSON數(shù)據(jù)截下來。這套組合下來采集效率和穩(wěn)定性都上了一個臺階。這篇文章就把我完整的實戰(zhàn)過程拆開講包括框架選型、頁面結(jié)構(gòu)分析、采集腳本編寫、反爬應(yīng)對、數(shù)據(jù)清洗入庫以及我踩過的那些坑。1.2 整體架構(gòu)從采集到落庫的完整鏈路在動手寫代碼之前我習(xí)慣先把整條鏈路畫清楚不然寫到一半容易返工。這個項目的整體設(shè)計分成四層采集層Playwright驅(qū)動瀏覽器負責打開雪球個股頁面、滾動加載、等待渲染、抓取DOM文本同時監(jiān)聽網(wǎng)絡(luò)請求截獲接口數(shù)據(jù)。解析層對抓到的原始內(nèi)容做結(jié)構(gòu)化處理提取股票代碼、帖子標題、正文、發(fā)布時間、點贊數(shù)、評論數(shù)、作者等字段。清洗層去重、去廣告、去表情符號、統(tǒng)一時間格式、過濾無效短文本。存儲層先落本地CSV或SQLite做緩沖再批量寫入MySQL或直接喂給后續(xù)的情感分析模塊。這樣分層的好處是每一層職責單一出問題好定位。比如某天發(fā)現(xiàn)數(shù)據(jù)變少了我可以先看采集層日志確認是頁面沒加載出來還是解析規(guī)則失效還是清洗時被誤過濾了。1.3 為什么是Playwright而不是Scrapy或純接口逆向這里得說清楚選型邏輯因為很多人第一反應(yīng)是直接逆向接口不香嗎。純接口逆向確實快找到雪球的API構(gòu)造請求參數(shù)直接拿JSON。但雪球的接口通常帶簽名參數(shù)和時效性token這些參數(shù)由前端JS動態(tài)生成逆向成本高而且一旦對方調(diào)整算法你的腳本立刻報廢維護成本極高。Scrapy本身是優(yōu)秀的爬蟲框架但它對動態(tài)渲染的支持要靠scrapy-playwright中間件配置鏈路長調(diào)試起來不夠直觀。而且Scrapy的異步模型和Playwright的瀏覽器上下文管理結(jié)合時容易出現(xiàn)上下文泄漏。Playwright的優(yōu)勢在于所見即所得。瀏覽器里能看到什么你就能抓到什么。它的wait_for_selector、wait_for_load_state等API讓等待邏輯非常自然。更重要的是Playwright可以監(jiān)聽response事件把頁面加載過程中所有XHR/fetch請求的響應(yīng)體拿到手。這意味著我可以DOM抓一份 接口截一份雙保險哪條路通走哪條。提示如果你的目標站點接口簽名極其復(fù)雜優(yōu)先考慮Playwright的請求監(jiān)聽方案而不是硬啃JS逆向。監(jiān)聽拿到的響應(yīng)體往往就是干凈的JSON省去大量解析工作。2. 環(huán)境搭建與核心工具選型細節(jié)2.1 Python環(huán)境與Playwright安裝我用的Python版本是3.10這個版本對異步支持和類型提示都比較友好。虛擬環(huán)境用venv就行沒必要上conda輕量。安裝Playwright分兩步很多人只做了第一步就報錯pip install playwright playwright install chromium第一行裝的是Python庫第二行才是下載瀏覽器內(nèi)核。如果你只裝庫不裝內(nèi)核運行時會提示找不到瀏覽器可執(zhí)行文件。我一般只裝chromium因為項目不需要Firefox和WebKit省磁盤空間。如果你在國內(nèi)網(wǎng)絡(luò)環(huán)境下下載內(nèi)核慢可以設(shè)置鏡像環(huán)境變量后再執(zhí)行playwright install速度會明顯改善。2.2 依賴清單與版本鎖定這個項目除了Playwright還需要幾個輔助庫庫名用途備注playwright瀏覽器自動化核心版本鎖定1.40pandas數(shù)據(jù)清洗與落盤處理表格數(shù)據(jù)極方便jieba中文分詞后續(xù)情感分析預(yù)處理sqlalchemy數(shù)據(jù)庫ORM批量入庫loguru日志記錄比logging好用太多我強烈建議用requirements.txt鎖版本。Playwright的API在不同大版本間偶有變動比如早期wait_for_timeout的用法和現(xiàn)在就有差異。鎖版本能避免昨天還能跑今天更新完就崩的尷尬。2.3 瀏覽器啟動參數(shù)的關(guān)鍵配置啟動瀏覽器時參數(shù)配置直接決定采集的穩(wěn)定性和隱蔽性。我常用的配置是這樣的from playwright.sync_api import sync_playwright with sync_playwright() as p: browser p.chromium.launch( headlessTrue, args[ --disable-blink-featuresAutomationControlled, --no-sandbox, --disable-dev-shm-usage, ] ) context browser.new_context( user_agentMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, viewport{width: 1920, height: 1080}, localezh-CN, )這里幾個點值得展開說。--disable-blink-featuresAutomationControlled是為了抹掉navigator.webdriver這個自動化特征很多站點的風控會檢測它。--disable-dev-shm-usage在容器環(huán)境里能避免共享內(nèi)存不足導(dǎo)致的崩潰。user_agent和viewport要設(shè)置得像真實用戶默認的Playwright UA一眼就能被識別。注意headless模式雖然快但部分站點的風控對無頭瀏覽器更敏感。如果發(fā)現(xiàn)采集頻繁被攔可以臨時切到headlessFalse觀察確認是渲染問題還是風控問題。2.4 請求監(jiān)聽把接口數(shù)據(jù)順手截下來這是Playwright相比Selenium最讓我驚喜的能力。通過監(jiān)聽response事件我可以在頁面加載過程中捕獲所有網(wǎng)絡(luò)響應(yīng)captured [] def handle_response(response): if /status/list in response.url or /comment/list in response.url: try: captured.append(response.json()) except Exception: pass page.on(response, handle_response) page.goto(target_url, wait_untilnetworkidle)networkidle表示等網(wǎng)絡(luò)請求基本停止后再繼續(xù)這樣能確保異步接口都返回了。捕獲到的JSON往往比DOM解析更干凈字段完整、沒有HTML標簽污染。我實測下來雪球的帖子列表接口返回的數(shù)據(jù)結(jié)構(gòu)非常規(guī)整直接就能映射成表格。3. 雪球頁面結(jié)構(gòu)分析與采集策略3.1 目標頁面拆解個股討論區(qū)我以某只個股的討論區(qū)頁面為例。頁面主要包含幾塊內(nèi)容頂部是股票基本信息和實時行情中間是帖子流每條帖子有作者、發(fā)布時間、正文摘要、點贊數(shù)、評論數(shù)底部是加載更多按鈕或無限滾動觸發(fā)點。關(guān)鍵難點在于帖子流是懶加載的初始只渲染十幾條必須滾動到底部才會觸發(fā)下一批。而且部分帖子的正文是折疊的需要點擊展開全文才能拿到完整內(nèi)容。我的策略是先滾動加載到目標數(shù)量再統(tǒng)一解析。滾動時用page.mouse.wheel模擬真實滾輪比直接改scrollTop更不容易被識別。3.2 選擇器定位穩(wěn)比快重要選擇器我踩過最大的坑就是用了帶隨機hash的class名。雪球的前端框架會給元素生成類似_3xK9a這樣的動態(tài)類名今天能用明天改版就廢。我的原則是優(yōu)先用語義化的屬性、文本內(nèi)容、層級關(guān)系定位。比如帖子容器可以用>posts page.query_selector_all(div[class*timeline__item]) for post in posts: title_el post.query_selector(h3) content_el post.query_selector(div[class*content]) author_el post.query_selector(a[href*/u/])用class*做模糊匹配比精確匹配抗改版能力強很多。3.3 滾動加載的節(jié)奏控制無限滾動的節(jié)奏很關(guān)鍵。滾太快頁面還沒加載完就滾走了數(shù)據(jù)丟失滾太慢效率低下。我的做法是每滾一屏等待一個固定的短時間再檢查帖子數(shù)量是否增加last_count 0 while True: page.mouse.wheel(0, 2000) page.wait_for_timeout(1500) current_count len(page.query_selector_all(div[class*timeline__item])) if current_count last_count: break last_count current_count if current_count 200: breakwait_for_timeout雖然被一些人詬病為硬等待但在滾動場景下它比wait_for_selector更實用因為新內(nèi)容出現(xiàn)的位置不固定。1500毫秒是我實測下來比較穩(wěn)的值太快容易漏太慢浪費時間。4. 完整采集腳本實現(xiàn)與關(guān)鍵環(huán)節(jié)4.1 主流程骨架整個采集腳本的主流程我拆成幾個函數(shù)每個函數(shù)只干一件事def collect_stock_posts(stock_code, max_posts200): with sync_playwright() as p: browser p.chromium.launch(headlessTrue, args[...]) context browser.new_context(...) page context.new_page() captured [] page.on(response, lambda r: handle_response(r, captured)) url fhttps://xueqiu.com/S/{stock_code} page.goto(url, wait_untilnetworkidle) scroll_to_load(page, max_posts) dom_data parse_dom(page) api_data parse_api(captured) merged merge_data(dom_data, api_data) browser.close() return mergedDOM和接口兩路數(shù)據(jù)合并時我用帖子ID或發(fā)布時間作者作為聯(lián)合主鍵去重。實測下來兩路數(shù)據(jù)重合度大概七成合并后能補全不少字段。4.2 數(shù)據(jù)解析與字段映射解析出來的原始數(shù)據(jù)需要映射成統(tǒng)一結(jié)構(gòu)。我定義的字段包括stock_code、post_id、title、content、author、publish_time、like_count、comment_count、source標記來自DOM還是API。時間字段特別要注意雪球顯示的是3分鐘前2小時前這種相對時間需要轉(zhuǎn)換成絕對時間戳。我寫了個轉(zhuǎn)換函數(shù)def parse_relative_time(text): now datetime.now() if 分鐘前 in text: m int(re.search(r(\d), text).group(1)) return now - timedelta(minutesm) if 小時前 in text: h int(re.search(r(\d), text).group(1)) return now - timedelta(hoursh) if 天前 in text: d int(re.search(r(\d), text).group(1)) return now - timedelta(daysd) return now這個函數(shù)看著簡單但如果不處理后續(xù)做時間序列分析時全是字符串根本沒法用。4.3 數(shù)據(jù)清洗與落庫清洗環(huán)節(jié)我做了幾件事去掉正文里的HTML標簽、過濾純表情和純符號的帖子、去除重復(fù)內(nèi)容、把點贊數(shù)1.2萬這種格式轉(zhuǎn)成數(shù)字12000。def clean_content(text): text re.sub(r[^], , text) text re.sub(r\s, , text).strip() if len(text) 5: return None return text落庫我用SQLAlchemy批量插入每500條提交一次。單條插入在數(shù)據(jù)量大時性能極差批量提交能把入庫時間壓縮到十分之一。5. 反爬應(yīng)對與穩(wěn)定性優(yōu)化5.1 請求頻率與行為模擬雪球?qū)Ω哳l訪問是有風控的。我的策略是單只股票采集完成后隨機休眠3到8秒再采下一只。同一會話內(nèi)不要連續(xù)快速翻頁滾動節(jié)奏也要有隨機性。import random time.sleep(random.uniform(3, 8))隨機休眠比固定休眠更自然。固定間隔的請求模式很容易被識別為機器行為。5.2 會話與Cookie管理Playwright的context天然隔離Cookie。我一般一個context采一只股票采完就關(guān)避免Cookie累積觸發(fā)風控。如果需要登錄態(tài)可以手動登錄一次后保存storage_state后續(xù)復(fù)用context.storage_state(pathstate.json) # 下次 context browser.new_context(storage_statestate.json)這樣既保留了登錄態(tài)又不用每次重新登錄。5.3 異常重試與斷點續(xù)采網(wǎng)絡(luò)抖動、頁面超時是常態(tài)。我給每個采集任務(wù)包了重試邏輯最多重試3次每次重試前換一個contextfor attempt in range(3): try: data collect_stock_posts(code) break except Exception as e: logger.warning(f第{attempt1}次失敗: {e}) time.sleep(5)同時記錄已采集的股票代碼到本地文件重啟腳本時跳過已完成的實現(xiàn)斷點續(xù)采。這個細節(jié)在采幾百只股票時能省大量時間。6. 常見問題與排查技巧實錄6.1 頁面加載了但抓不到數(shù)據(jù)這是最常見的問題。原因通常是等待時機不對。networkidle有時會因為頁面有長輪詢請求而一直不觸發(fā)。我的解決辦法是改用domcontentloaded加顯式等待關(guān)鍵元素page.goto(url, wait_untildomcontentloaded) page.wait_for_selector(div[class*timeline__item], timeout15000)6.2 采集到一半被攔截如果發(fā)現(xiàn)前幾十條正常后面突然返回空或跳驗證頁基本是觸發(fā)風控了。排查順序先看是不是頻率太高降低速度再看UA和指紋是否暴露檢查navigator.webdriver最后考慮是否需要登錄態(tài)。6.3 常見問題速查表問題現(xiàn)象可能原因解決方向抓到空殼頁面JS未執(zhí)行完改用顯式等待元素數(shù)據(jù)重復(fù)滾動觸發(fā)多次加載用post_id去重時間字段混亂相對時間未轉(zhuǎn)換統(tǒng)一轉(zhuǎn)絕對時間戳腳本跑久崩潰內(nèi)存泄漏定期重啟context接口監(jiān)聽不到URL匹配規(guī)則錯打印所有response.url排查提示調(diào)試接口監(jiān)聽時先把所有response的URL打印出來找到真正的數(shù)據(jù)接口再寫匹配規(guī)則別憑猜測寫。7. 數(shù)據(jù)應(yīng)用與后續(xù)擴展方向采下來的數(shù)據(jù)我主要用在兩個地方。一是做個股情緒熱度排名統(tǒng)計每只股票在單位時間內(nèi)的討論量變化討論量突增往往對應(yīng)著消息面異動。二是做文本情感分析用jieba分詞后接一個情感分類模型把帖子分成正面、中性、負面再算情緒指數(shù)。后續(xù)還可以擴展的方向不少。比如把采集頻率提高到分鐘級做實時情緒監(jiān)控比如把多只股票的數(shù)據(jù)聚合做板塊級別的情緒對比再比如結(jié)合行情數(shù)據(jù)驗證情緒指標和股價的相關(guān)性。這些都屬于數(shù)據(jù)采完之后的下游應(yīng)用前提是采集這一環(huán)足夠穩(wěn)。我個人在實際操作中的體會是Playwright這套方案最大的價值不在于能抓到而在于抓得穩(wěn)、好維護。選擇器基于語義、接口監(jiān)聽做兜底、異常重試加斷點續(xù)采這幾點做到位一個采集腳本能穩(wěn)定跑幾個月不用大改。真正費時間的從來不是寫第一版而是后續(xù)的維護所以前期在穩(wěn)定性上的投入絕對值得。