如果你关注过东方财富网的股吧评论,大概率会发现一种奇特现象:某只股票明明业绩平平,评论区里却一片“起飞”的欢呼;另一只基本面扎实的股票,股吧里反倒骂声不断。这种散户情绪和基本面数据之间的背离,恰恰是很多人做舆情监控、短线情绪判断甚至量化策略时不想错过的信号。用python爬取东方财富网股吧评论,再用情感分析把这些“看好”“稀烂”“冲就完事”还原成可量化的情绪分数,是一条非常顺手的技术路径。这篇文章是这个系列的第一篇,先把最底层的数据源打通:怎样把股吧评论稳定地抓下来、存成结构化数据,为后续的情感建模做原料准备。
我一直觉得,爬虫最难的部分不是写代码,而是搞清楚数据到底藏在哪、长什么样。股吧这类动态页面尤其典型:你直接去网页源码里搜评论关键词,大概率搜不到;真正的内容是通过接口动态加载回来的。所以这一篇会从页面分析开始,带你把接口找出来,再封装成可复用的爬虫函数,中间穿插一些我实际踩过的坑。如果你按照这篇走完,至少能拿到一份干净、可入库的东方财富股吧评论数据。
1. 为什么把第一站选在东方财富股吧
1.1 股吧评论是难得的“散户情绪样本”
做股票相关内容的人,多多少少都想过一个问题:普通投资者的情绪能不能被量化?答案是可以,但前提是要有足够大、足够真实的文本来源。东方财富股吧恰好满足这个条件:它是国内股民讨论热度最高的社区之一,每只股票都有自己的独立讨论区,评论更新频繁,而且大多数发言都是用户即兴写出来的,口语化严重,情绪表达非常直白。
相比上市公司公告、研报这类正式文本,股吧评论的语言粗糙、观点极端,但正因如此,它反而更适合做情感分析训练样本。你可以从评论里提取出“看涨”“看跌”“中性”三类信号,再结合发布时间、热度指标做趋势分析。很多量化团队会把股吧情绪指数作为辅助因子,不是没有道理的。
当然,我在这里先强调一句:爬取数据请严格遵守平台规则和相关法律法规,只做个人学习研究使用,不要抓取非公开数据,更不要去撞接口频率、抓用户隐私。后面我会在代码里刻意加请求延时,也是这个原因。
1.2 这个系列我只打算做两件事:抓下来,读懂它
既然是系列文章的第一篇,我不想一开始就铺得太大。整个项目可以拆成两条主线:第一条是把数据稳定地抓下来,包括评论内容、作者、发布时间、阅读量、点赞数等;第二条是对文本做情感分析,比如先用现成的SnowNLP跑一版,再用LSTM或BERT做更精细的分类。
这一篇先完成第一条线的前半段:爬取帖子列表。为什么不是直接爬每一条楼中楼回复?因为第一步最关键的是打通数据链路、把数据形态搞清楚。帖子列表里的标题和摘要本身就已经包含大量观点,足够后续情感分析用了。真到需要分析“评论里的评论”时,在现有代码基础上再往详情页钻一层即可,设计思路是一样的。
2. 动手前先把这三件事想清楚
2.1 环境:Python版本、IDE与依赖库
如果你还没装Python,建议直接装3.8及以上版本,我这里用的是Python 3.10。太老的版本对类型注解、第三方库的兼容性会差一些,没必要给自己添堵。IDE方面,PyCharm和VS Code都行,个人更推荐VS Code,轻量、插件丰富,配好Python扩展后写爬虫足够顺手。
依赖库没有太多花哨的东西,核心就这几个:
requests:发HTTP请求,拉取接口数据beautifulsoup4:解析HTML和清洗文本lxml:作为BeautifulSoup的解析器,速度比纯Python的html.parser更快pandas:最后统一处理表格数据、导出CSVopenpyxl:如果你想把数据存成Excel,需要装这个
在终端里执行:
pip install requests beautifulsoup4 lxml pandas openpyxl如果你用Anaconda,直接用conda安装也可以。这里不展开讲Python安装的每一步,网上资料很多;但有一点值得提醒:安装后务必确认pip指向的是你正在用的Python版本,可以在命令行执行python -m pip --version检查,避免装了一堆库结果脚本里导入失败。
2.2 合规:个人研究如何爬才不越界
每次写爬虫教程,我都要专门说合规,因为这事太重要了。爬虫本身不违法,但如果用来抓取非公开数据、绕过访问控制、大规模影响网站正常运行,那就越界了。
具体到东方财富股吧,我的实践原则只有四条:
- 只抓公开可见的数据,不碰需要登录后才能看到的用户隐私字段。
- 请求频率控制在人类手动浏览的节奏之下,例如每页之间至少间隔1秒。
- 不把抓下来的数据用于商业用途或二次传播。
- 不通过构造恶意参数去试探接口漏洞,也不尝试绕验证码或封禁机制。
这些原则写起来简单,但真正执行起来容易被“再抓一页就够”的侥幸心理打破。我的建议是直接在代码里写死time.sleep(),把延时变成硬约束。
2.3 技术选型:为什么是requests而不是selenium
刚开始学爬虫的人容易陷入一个误区:看到网页内容加载不出来,第一反应是上Selenium模拟浏览器。Selenium确实万能,但代价是启动浏览器、渲染页面、等待网络,每一步都很慢,而且内存占用高。对于股吧这种数据其实是通过接口返回的页面,用Selenium属于大炮打蚊子。
我选择requests直接请求接口,原因很直接:
- 接口返回的是结构化JSON,解析起来比HTML容易得多。
- 请求次数少、速度快,对服务器更友好。
- 代码简单,后续如果要爬多只股票,只要改参数就能复用。
如果你要爬的网站没有接口,内容全部写在HTML源码里,那用requests加BeautifulSoup也够了。只有遇到极端的动态渲染页面(比如内容依赖复杂JavaScript计算生成)才需要上Selenium或Playwright。股吧不属于这种情况,所以这篇文章里完全不用模拟浏览器。
3. 拆开股吧页面:评论其实不在网页源码里
3.1 直接在HTML里搜关键词,为什么搜不到
我第一次爬股吧时,习惯性地用requests.get把帖子列表页拉下来,然后想着用BeautifulSoup去解析帖子标题。结果发现HTML源码里只有页面框架、导航栏和一堆JavaScript变量,帖子内容一个都没出现。当时我还以为是反爬,后来才知道是太天真:股吧的评论数据是页面加载完成后,通过异步请求从后端接口拿到的。
这个现象在今天的网页里非常普遍,术语叫前后端分离。页面只负责搭骨架,数据由JavaScript代码从另一个URL请求回来再填充到页面上。所以,我们爬虫要做的不是跟HTML死磕,而是找到那个真正返回评论数据的XHR请求。
3.2 用开发者工具揪出真实的数据接口
这一步是整个爬虫的核心,也最容易被教程一带而过。我建议你亲手走一遍流程,以后换成任何网站都会了。
用Chrome或Edge打开东方财富网任意一只股票的股吧页面,比如贵州茅台吧。按F12打开开发者工具,切换到Network面板,再按Ctrl+R刷新页面。此时Network面板里会出现几十条请求,不要慌,我们只关心XHR类型的请求。
在筛选栏里输入XHR,然后再看请求名称。你要找的是那种返回内容里有帖子标题、作者、时间字段的接口。通常它的名字会带list、data、post之类的关键词。点开一条请求,切到Preview或Response页签,如果能看到大段JSON格式的数据,基本就是它了。
我当时抓到的接口地址形如https://guba.eastmoney.com/interface/GetData.aspx,参数里包含股票代码、页码、每页条数等信息。但你看到的时候接口参数可能已经变了,这很正常。关键是学会怎么去看请求的URL和参数,而不是死记硬背一个地址。
从请求里你能看到这样几条关键信息:
- 请求方式:
GET还是POST - 请求URL:带
?后面的query参数 - 请求头:至少需要
User-Agent和Referer - 返回格式:JSON还是JSONP
3.3 小测试:先手动请求一次接口
找到接口后,先在命令行里做一次最小请求验证,确认能通,再写完整爬虫。我习惯用Python交互式环境或写一个临时脚本:
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, params=params, headers=HEADERS, timeout=10) print(resp.status_code) print(resp.text[:500])如果返回的是一段以var开头或包裹在括号里的文本,不要慌,那多半是JSONP格式,需要做一次清理。如果返回状态码403,第一件事先检查User-Agent和Referer,绝大多数403都是请求头缺失导致的。
这一步非常值得花时间做扎实,因为接口能不能通,决定了后面所有代码的走向。
4. 写爬虫:封装请求、解析字段、翻页循环
4.1 请求头伪装与公共参数封装
接口确认没问题后,就可以把爬虫写成函数了。我习惯先把请求头、公共参数、超时时间统一管理起来,避免在翻页循环里反复复制粘贴。
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_size=20): return { "path": f"list,{stock_code}", "p": page, "ps": page_size, }这里有一个非常实用的经验:page_size不要设太大。有人觉得一页拉100条省事,但很多接口对单次返回条数有隐藏限制,你设大了它反而只返回默认值,甚至直接报错。先设20跑一次看返回条数,再根据情况调整。
4.2 解析接口返回中的关键字段
接口返回的文本可能不是纯JSON,而是带了一个回调外壳。我写了一个通用函数,把这种情况处理掉:
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返回的通常是字典,里面包含帖子列表。每家网站的字段名不一样,我在实际工作中遇到比较多的是post_id、post_title、post_abstract、user_nickname、post_publish_time、read_count、comment_count、like_count这些。具体字段名以你抓到的返回为准,但解析逻辑是一致的:
def parse_post(post): """把单条帖子转成标准化字典""" 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再清洗一遍字段值,是因为某些帖子标题里会残留\n、\u3000之类的东西,甚至可能有HTML标签。清洗文本这个步骤虽然不起眼,但如果你直接拿去训练模型,会发现数据噪声大到结果根本没法看。
4.3 翻页、去重与断点保存
拿到单页数据以后,翻页就简单了,但有几个细节必须处理:
第一,接口返回空列表时应该立刻停止,而不是继续空跑到最大页数。第二,遇到请求异常不能直接整个程序崩溃,要捕获后重试。第三,爬到一半可能被中断,所以要有断点续爬的思路。
下面这段是我常用的翻页函数:
def fetch_page(stock_code, page, retry=3): params = build_params(stock_code, page) for attempt in range(retry): try: resp = requests.get(BASE_URL, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() data = clean_response(resp.text) # 根据实际返回结构取列表 posts = data.get("re", []) if isinstance(posts, dict): posts = posts.get("list", []) return posts except Exception as e: print(f"第{page}页第{attempt+1}次请求失败: {e}") time.sleep(2) return []翻页主逻辑:
def crawl_stock(stock_code, max_pages=10, delay=1.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}页无数据,停止翻页") 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}条,累计{len(all_posts)}条") time.sleep(delay) return all_postsseen_ids是我从实际项目中总结出来的一个强制要求。接口偶发情况下会在不同页码返回同一批帖子,如果你不去重,最终数据里会出现大量完全一样的评论,清洗阶段还得再花力气处理。
断点续爬这块,最简单的方式是把已经抓到的post_id集合写入一个文件,下次启动时先加载它。这样万一你抓了2000条后断网,不需要从头再来:
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(subset="post_id") df.to_csv(filepath, index=False, encoding="utf-8-sig")这样,crawl_stock主函数里先用load_seen_ids初始化集合,每抓完一页或累计到一定数量就调一次save_posts,即使中断也不会损失太多进度。
5. 数据清洗与存储:给情感分析准备干净的原料
5.1 清洗规则:去标签、去重、时间格式统一
数据爬下来只是第一步,能不能给情感分析用,取决于清洗质量。我见过很多人在这一步偷懒,结果模型训练出来效果很差,还以为是算法不行,其实是数据里全是空值和乱码。
我的清洗流程一般分四步:
第一步,去HTML标签。上面解析时已经用BeautifulSoup处理过,但有些摘要字段可能还会残留,所以我会再统一过一遍。
第二步,去空白字符和特殊符号。\u3000、\xa0、连续空格全部替换掉。中英文标点混用的问题先不处理,等做分词时再说。
第三步,去重复数据。上面已经用post_id去重,但有时同一条内容会被不同用户转发,所以还需要再按“标题+内容”做一次去重。
第四步,统一时间格式。股吧返回的时间可能是2024-01-15 10:30:00,也可能是Unix时间戳,甚至还可能是毫秒级时间戳。我有一个转换函数:
def normalize_time(ts): if ts is None: return None if isinstance(ts, str): return ts.strip() try: ts = int(ts) # 毫秒级时间戳转成秒级 if ts > 10**12: ts = ts / 1000 return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") except Exception: return str(ts)这个函数在后续做时间序列分析、按小时统计情绪变化时非常有用。
5.2 存储:CSV和SQLite两种方案
清洗后的数据可以存成CSV,方便直接查看和用pandas做分析。如果数据量大、需要频繁查询,我更推荐存SQLite,轻量、单文件、跨平台,后续接情感分析脚本也方便。
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", index=False, encoding="utf-8-sig")SQLite的话,用Python自带的sqlite3就能搞定:
import sqlite3 conn = sqlite3.connect("guba.db") df.to_sql("guba_comments", conn, if_exists="replace", index=False) conn.close()to_sql是pandas自带的方法,底层会帮你建表,字段名直接沿用DataFrame的列名。对于这个项目来说完全够用,不用手写建表语句。
从进化的角度看,我建议你先把CSV方案跑通,确认数据形态没问题之后,再考虑迁移到SQLite。不要一上来就搭一套复杂的数据库,数据和模型都没跑通,先保持简单。
6. 我踩过的坑:反爬、IP频率与半路断掉
6.1 被限制时最常见的三种表现
东方财富股吧的整体反爬强度不算变态,但也不是毫无防御。我在实际抓取中遇到过三种情况,一旦出现就要立刻停下来检查:
第一种,请求返回403。这种情况九成是请求头问题,尤其是User-Agent。有些爬虫框架默认的UA是Python-requests/x.x,服务端一眼就能识别。所以一定要显式设置成浏览器UA,并且带上Referer。
第二种,返回200但数据为空。接口没有报错,但返回的列表是空。这可能是你请求参数不对,也可能是被服务端静默过滤了高频IP。遇到这种情况,先降低请求频率,再检查参数。
第三种,请求偶尔成功偶尔失败,间隔几次就超时。这是频率限制的典型信号。不要硬扛,直接把延时从1秒调到3秒,再观察一段时间。
我把这些问题整理成了一张表,方便你排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 403 | 请求头缺失或UA被识别 | 补全User-Agent、Referer,模拟浏览器 |
| 200但列表为空 | 参数错误或接口字段变化 | 回到开发者工具重新核对参数 |
| 偶发超时 | 请求频率过高 | 增加sleep间隔,降低翻页速度 |
| 数据大量重复 | 接口翻页不稳定 | 用post_id和内容做双重去重 |
6.2 延迟、重试和断点续爬的兜底方案
关于爬虫的稳定性,我想分享一个观念:稳定比快更重要。很多人喜欢用协程、多线程把爬虫写成飙车模式,一秒钟发几十个请求,抓完十几万条数据。这种玩法在小型项目里可能没问题,但如果被对端限制,整个任务前功尽弃的概率也很大。
我的做法一向是:单线程、带延时、带重试、带断点。单线程的好处是逻辑简单,不会出现请求顺序错乱的问题;延时控制在1到2秒,既不招摇也足够快。一个股票吧最多抓几十页,总共用不了一分钟,真的没必要再优化速度。
重试机制也很重要。网络请求是不可靠的,偶尔一次超时很常见。fetch_page里我已经写了最多重试3次的逻辑,每次重试间隔2秒。如果3次都失败,就返回空列表,让主循环自然停止,保住已经抓到的数据。
断点续爬我再次强调,非常关键。我的一个朋友抓其他网站的数据,没有断点逻辑,抓到一半服务器超时,几个小时的进度全丢了。从那以后,我再也不写没有状态的爬虫。每次新增数据后立刻落盘,哪怕程序中途崩了,最多损失最后一页的数据。
6.3 用爬到的数据做什么:先跑一遍简单统计
数据存储完之后,可以先做一点最基础的统计分析,验证数据质量,也为后续的情感分析做一个铺垫。比如统计一下共爬了多少条评论、哪些帖子阅读量和评论数最高、评论时间分布是什么样的。
import pandas as pd df = pd.read_csv("guba_600519.csv") df["publish_time"] = pd.to_datetime(df["publish_time"], errors="coerce") print(df.shape) print(df.describe()) print(df["publish_time"].dt.hour.value_counts().sort_index())这些数字能让你直观感受到数据的分布,如果发现发布时间集中在一个异常的时间段,或者某天的数据量猛增,可能意味着抓取过程中有些页面被漏掉了,也可能是当天该股票确实出了大新闻。这种对数据的敏感度,是后续做情感分析时判断结果是否合理的基础。
到这里,爬取股吧评论这条路已经通了。你手里的数据包含标题、摘要、作者、时间、阅读量、评论量和点赞量,已经是一份能支撑后续分析的干净数据。下一篇我会把这批数据接进情感分析流程,先用SnowNLP这种轻量方案快速跑一版情绪得分,再对比一下LSTM的效果。到时候你会发现,前面花在页面分析和数据清洗上的时间,每一分钟都不会白费。