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