公司动态
散户情绪不是玄学:Python 爬东方财富股吧并量化的方法
很多人一听到「散户情绪」就觉得是玄学仿佛只有盘面高手靠直觉才能感知。其实东方财富股吧里每天几百万条发帖就是最原始的散户情绪池子。帖子里有人喊「满仓干」有人骂「这票废了」有人发长长的复盘贴。把这些内容按条数抓下来再用一套简单的规则给每条打分散户情绪就能从感觉变成数字。举个真实的例子。某只票当天跌停股吧前排热帖大概率是一堆「割肉了」「被套牢」的抱怨这时候平均情绪分掉到负 0.6 以下很正常过两天出个利好公告热帖画风一转满屏「加仓」「看好反转」分数又能弹回正区间。你不需要读懂每一句只要把这种整体摆动量出来趋势就出来了。这篇文章就做两件事。第一用 Python 按你指定的条数精确抓取东方财富股吧的帖子抓满就停不浪费请求。第二用词典法给每条帖子算一个情绪分最后聚合成一个能看的情绪指标。代码里会带上类型注解、dataclass、日志记录和重试策略都是真正能跑的工程写法。先找到股吧的接口打开任意一只股票的股吧页面比如贵州茅台600519按 F12 打开开发者工具切到 Network 面板刷新页面筛选 XHR 请求。你会看到一个名为Get的接口大致长这样https://guba.eastmoney.com/api/Get?code600519page1page_size20sort0code是股票代码page是页码page_size是每页条数sort0表示按时间排序。返回的是一段 JSON帖子数组在list字段里每条包含post_id、post_content、post_publish_time、user_nickname等字段。拿到这个接口爬虫的骨头就出来了。有一点要提前说返回的post_content经常夹着 HTML 标签和图片占位符比如img src...、nbsp;之类。直接拿去打分会把噪声算进去。抓到内容后先做一次清洗把标签剥掉只留文字importredefclean_html(raw:str)-str:textre.sub(r[^],,raw)textre.sub(r[a-z];,,text)returntext.strip()清洗放在fetch_posts里塞进GubaPost.content之前这样后面所有环节拿到的都是纯文本情绪词典统计才准。还有个坑post_id在不同接口里有时是字符串有时是整数统一转成str能少踩不少 Equal 比较的坑。先把数据结构定好用 dataclass 把单条帖子框住后面无论是存盘还是打分都围绕这个结构转不至于在字典里乱翻 key。fromdataclassesimportdataclass,asdictfromtypingimportOptionaldataclassclassGubaPost:post_id:strstock_code:strnickname:strcontent:strpublish_time:strsentiment:float0.0defto_dict(self)-dict:returnasdict(self)sentiment字段先给个默认值 0.0等会儿打完分再回填。这样帖子模型和情绪计算解耦逻辑清楚。配置日志和代理爬取过程中如果不打日志出了问题你连哪页挂了都不知道。直接上标准库 logging别用一堆 print。importlogging logging.basicConfig(levellogging.INFO,format%(asctime)s [%(levelname)s] %(message)s,)loggerlogging.getLogger(guba_crawler)代理这块要单独说。股吧对高频访问不是完全没反应单 IP 猛抓几百页很容易被限流。做批量抓取时稳定的代理能省掉很多麻烦。我习惯用亿牛云的 HTTP 代理它走用户名密码鉴权代码里这样挂proxy_meta{host:http://proxy.16yun.cn,port:31000,user:你的亿牛云用户名,password:你的亿牛云密码,}proxy_urlfhttp://{proxy_meta[user]}:{proxy_meta[password]}{proxy_meta[host].split(//)[1]}:{proxy_meta[port]}proxies{http:proxy_url,https:proxy_url}把proxies透传给 requests 就行。亿牛云按量计费平时小规模抓几十页用不上真要跑几千条时它能把 IP 干净这件事兜住。抓之前还有个格式细节。东方财富的股票代码带市场前缀规则沪市主板是 60xxxx深市主板是 00xxxx创业板是 30xxxx科创板是 688xxx北交所是 8xxxxx。股吧接口认的就是这六位纯数字code前面不加 SH、SZ 那种前缀。要抓多只票把这些代码丢进一个列表循环调用fetch_posts即可每只票单独存一份 CSV后面拼表时按stock_code区分。按条数精准抓取核心需求是按条数抓而不是无脑翻到底。下面这个函数接收目标条数每翻一页把帖子累加够了就 break避免多抓浪费配额。每翻一页之间加一个随机间隔比固定 sleep 更像真人也能避免把请求节奏锤得太规律被识别。用random.uniform在 1 到 3 秒之间抖一下importrandomdeffetch_posts(stock_code:str,target_count:int,proxies:dict)-List[GubaPost]:posts:List[GubaPost][]page1page_size20whilelen(posts)target_count:params{code:stock_code,page:page,page_size:page_size,sort:0,}try:resprequests.get(BASE_URL,paramsparams,proxiesproxies,timeout10)resp.raise_for_status()itemsresp.json().get(list,[])exceptrequests.RequestExceptionasexc:logger.warning(第 %s 页抓取失败: %s,page,exc)breakifnotitems:logger.info(已经没有更多帖子第 %s 页为空,page)breakforiteminitems:iflen(posts)target_count:breakrawitem.get(post_content,)posts.append(GubaPost(post_idstr(item.get(post_id,)),stock_codestock_code,nicknameitem.get(user_nickname,),contentclean_html(raw),publish_timeitem.get(post_publish_time,),))logger.info(已抓取 %s/%s 条,len(posts),target_count)page1time.sleep(random.uniform(1,3))returnposts[:target_count]这里有个细节内层循环也判断了len(posts) target_count因为一页 20 条目标可能是 35 条第 2 页抓到 40 条时多出来的 5 条得切掉否则就超了。最后return posts[:target_count]再兜一层确保数量精确。另外注意content在入模前就过了clean_html前面说的标签噪声在这一步清掉后面打分拿到的全是纯文字。给请求加重试网络抖动、代理偶发超时都正常直接抛异常中断整个任务不划算。写个简单的重试装饰器失败就退避几秒再试。importtimefromfunctoolsimportwrapsfromtypingimportCallable,TypeVar TTypeVar(T)defretry(times:int3,delay:float2.0)-Callable:defdecorator(func:Callable[...,T])-Callable[...,T]:wraps(func)defwrapper(*args,**kwargs)-T:last_exc:ExceptionException(unknown error)forattemptinrange(1,times1):try:returnfunc(*args,**kwargs)exceptrequests.RequestExceptionasexc:last_excexc logger.warning(第 %s 次重试%s 秒后继续,attempt,delay)time.sleep(delay*attempt)raiselast_excreturnwrapperreturndecorator把retry()套到真正的请求函数上前一次示例里的requests.get单独抽出来即可。退避时间按delay * attempt递增避免瞬间疯狂重试把对面打毛。把情绪量化出来量化不一定要上深度学习。对股吧这种口语化、情绪浓的文本词典法又快又解释得清。先准备两份词表POS_WORDS[涨,利好,加仓,看好,抄底,拉升,反弹,盈利,赚,满仓]NEG_WORDS[跌,利空,割肉,看空,套牢,暴跌,亏损,雷,崩,清仓]defscore_sentiment(text:str)-float:possum(text.count(w)forwinPOS_WORDS)negsum(text.count(w)forwinNEG_WORDS)totalposnegiftotal0:return0.0returnround((pos-neg)/total,3)score_sentiment返回 -1 到 1 之间的数1 代表全是正面词负 1 代表全是负面词0 表示没命中任何词。用str.count而不是分词是为了轻量股吧帖子短、口语多逐字统计反而更稳。词典法有个绕不开的短板它认字不认语境。「不跌」「没利空」这种带否定的表达会被str.count当成正面算进去分数就飘了。帖子短、否定句占比不高整体指标不会被带偏太多但想更准可以加一层简单处理把内容按句号断句句子里同时出现否定词不、没、别和情绪词时把这条情绪词的权重翻负。改起来就几行NEG_MARKERS[不,没,别,无]defscore_sentiment_v2(text:str)-float:posneg0forsentinre.split(r[。\n],text):has_negany(minsentforminNEG_MARKERS)forwinPOS_WORDS:ifwinsent:pos-1ifhas_negelse1forwinNEG_WORDS:ifwinsent:neg-1ifhas_negelse1totalposnegiftotal0:return0.0returnround(pos/total,3)这么改之后「这不跌才怪」这种反讽还是会算错但那种属于人读都费劲的句子先放过它。工程上做到 80 分够用剩下的 20 分交给更大的语料和模型不在这篇文章的范围内。跑完抓取后回灌分数defenrich_sentiment(posts:List[GubaPost])-List[GubaPost]:forpostinposts:post.sentimentscore_sentiment(post.content)returnposts聚合成一个能看的指标单条分数没意义聚起来才有用。比如算平均情绪、正负帖占比再存成 CSV 方便后续画图或接进你自己的看板。importcsvfromstatisticsimportmeandefsummarize(posts:List[GubaPost],out_path:str)-dict:scores[p.sentimentforpinposts]avground(mean(scores),3)ifscoreselse0.0pos_ratioround(sum(1forsinscoresifs0)/len(scores),3)ifscoreselse0.0withopen(out_path,w,newline,encodingutf-8-sig)asf:writercsv.DictWriter(f,fieldnames[post_id,nickname,content,sentiment])writer.writeheader()forpinposts:writer.writerow(p.to_dict())logger.info(平均情绪分 %s正面帖占比 %s已存 %s,avg,pos_ratio,out_path)return{avg_sentiment:avg,pos_ratio:pos_ratio,count:len(posts)}utf-8-sig编码是为了让 Excel 直接打开中文不乱码这个坑踩过一次就记住了。avg_sentiment持续为正且走高说明散户整体偏乐观一直为负说明恐慌情绪在堆积。把这个值按天画折线你就能直观看到一只股票的情绪拐点比盯着单条帖子靠谱。把情绪分画出来CSV 躺在硬盘里还是数字画一张图才看得清走势。用 matplotlib 读 CSV按发布时间排序把每条帖子的sentiment点出来再叠一条当日均值线importcsvfromdatetimeimportdatetimeimportmatplotlib.pyplotaspltfrommatplotlib.datesimportdate2numdefplot_sentiment(csv_path:str)-None:times,scores[],[]withopen(csv_path,encodingutf-8-sig)asf:forrowincsv.DictReader(f):try:tdatetime.strptime(row[publish_time][:19],%Y-%m-%d %H:%M:%S)exceptValueError:continuetimes.append(date2num(t))scores.append(float(row[sentiment]))plt.scatter(times,scores,s12,alpha0.5,label单帖情绪)plt.axhline(sum(scores)/len(scores),colorred,label均值)plt.legend()plt.show()散点能看出当天情绪的离散程度点全挤在正值区说明一边倒看多点上下乱蹦说明多空分歧大这种日子往往对应放量震荡。均值红线往哪边偏就是当天散户的整体立场。把多天的数据按stock_code拼起来就能画出一只票的情绪曲线和 K 线叠在一起看常常比技术指标更早反映情绪拐点。把抓取定时跑起来单日快照只能看个截面情绪真正有价值的地方在变化。让脚本每天固定时间跑一次把当天的平均情绪分追加进同一张表时间一长就自然形成序列。不用上复杂调度框架一个无限循环加time.sleep就能先跑起来importtimefromdatetimeimportdatetimedefdaily_job(stock_code:str,target:int,out_csv:str)-None:whileTrue:postsfetch_posts(stock_code,target,proxies)postsenrich_sentiment(posts)summarysummarize(posts,out_csv)logger.info(%s 任务完成: %s,datetime.now().date(),summary)time.sleep(86400)正式环境建议交给系统的 cron 或任务计划程序比进程里死循环省心进程挂了也不会丢调度。跑之前记得把time.sleep的间隔、目标条数按你的代理配额定好别一觉醒来把当月流量吃完。为什么非要按条数抓最后回头说一句按条数这件事。很多人写爬虫习惯「抓到没有为止」对股吧这种信息流站点全量抓既没意义又烧代理。你做情绪面板每天同口径抓 200 条前后才可比今天抓 200 明天抓 5000两条均值摆在一起会自己骗自己。把target_count钉死等于给数据加了把尺子后面所有分析都站得住脚。几点实在的提醒抓取频率别太猛每页之间time.sleep一个一两秒的随机间隔配合代理基本不会被拦。词表建议按板块自己补比如新能源和白酒的「黑话」差很多通用词典打出来会偏。另外股吧数据只反映发言的那部分人沉默的大多数不在里面用它做参考可以拿来当买卖依据就过头了。这套流程跑通后你完全可以把多只股票并起来抓做成自己的散户情绪面板。代码量不大真正的工作量在后面维护词表和看数据但第一步把情绪变成数字事情就从玄学变成了可以聊的东西。