股吧舆情爬虫实战:Python四层架构与金融情感分析
2026/9/10 13:01:48 网站建设 项目流程

简介:这是一套面向计算机、人工智能及金融相关专业学生与教师的实战型课程资源,聚焦股市舆情挖掘与分析场景,提供从股吧数据采集到情感识别与热点追踪的完整技术链路。资源包含12个文件,涵盖4个核心Python脚本(爬虫、解析、MongoDB存储及主控逻辑)、6张关键界面与结果截图(如发帖/评论界面、进度与报错展示)、1个JS反爬绕过脚本及1份Markdown文档说明,压缩包仅3.85MB,轻量易部署。已有146人学习下载,适合作为期末大作业、毕业设计选题或Python爬虫与NLP进阶实践项目。所有代码均经实测可运行,附带真实采集数据与清晰执行流程,特别提供反爬处理细节、情感分析简易实现方案及热点词频统计逻辑,便于初学者理解工程落地难点,也支持中高级用户基于模块化结构快速二次开发。

1. 为什么股吧舆情不能只靠“关键词搜索”?——一个真实可跑的东方财富股吧爬虫项目

你有没有试过在股吧里搜“利好”“涨停”“抄底”,结果翻了二十页全是水帖、段子和情绪宣泄?单纯用关键词匹配做舆情统计,误差率可能超过65%。这个项目不是教你怎么绕过反爬,而是用真实调试过的 Python 爬虫链路,把股吧页面结构、发帖/评论 DOM 规律、动态加载特征全拆开——它抓的是带时间戳、用户ID、楼层序号、文本长度、图片标记的原始交互数据,再喂给轻量级情感分析模型(TextBlob + 自定义金融词典),最后生成可排序的热点话题图谱。适合计算机、AI、金融工程方向的学生做课程设计或毕设:代码结构清晰(crawler → parser → mongodb → analysis 四层解耦),所有依赖版本锁定在 requirements.txt,连 MongoDB 连接失败时的 fallback 日志都打了 trace_id。如果你正卡在“爬下来但解析不准”“能存但不会算情绪分”“有数据但看不出热度变化”,这个包里的main.py启动逻辑、parser.py的 XPath 定位策略、以及README.md里标注的 7 处易错点,就是你缺的那块拼图。


2. 股吧页面结构与反爬对抗:从静态 HTML 到动态接口的三层解析策略

东方财富股吧的页面呈现并非单一模式:首页列表是服务端渲染的静态 HTML,但点击某条帖子进入详情页后,评论区采用 AJAX 异步加载,而最新发帖则依赖 WebSocket 推送。本项目没有强行用 Selenium 模拟全部操作,而是分层处理——对列表页用requests+BeautifulSoup解析;对详情页评论用requests直接调用push2接口(非官方文档接口,但已验证稳定);对实时发帖则通过监听https://guba.eastmoney.com/topic/xxxxx.html页面的<script>标签内嵌 JSON 数据提取。这种混合策略既规避了浏览器自动化带来的性能损耗,又绕开了前端 JS 渲染导致的 XPath 失效问题。

2.1 列表页抓取:XPath 定位与分页参数构造

股吧列表页 URL 形如https://guba.eastmoney.com/list,600519,f_1.html,其中600519是股票代码,f_1表示按“最新”排序。关键在于识别每页 80 条帖子的 DOM 结构:

# crawler.py 中的列表页解析核心片段 def parse_list_page(html_content: str) -> List[Dict]: soup = BeautifulSoup(html_content, 'lxml') items = [] for li in soup.select('ul#stockList > li'): try: # 股票代码从URL中提取,标题和链接从a标签获取 title_tag = li.select_one('a.title') if not title_tag: continue url = title_tag.get('href', '') title = title_tag.get_text(strip=True) # 发帖时间藏在 span.time 元素中,格式为"今天 14:23"或"05-12 09:17" time_tag = li.select_one('span.time') post_time = time_tag.get_text(strip=True) if time_tag else '' # 阅读数和评论数在 div.count 下,需分离数字 count_tag = li.select_one('div.count') if count_tag: nums = [int(x) for x in re.findall(r'\d+', count_tag.get_text())] read_count = nums[0] if len(nums) > 0 else 0 comment_count = nums[1] if len(nums) > 1 else 0 else: read_count = comment_count = 0 items.append({ 'title': title, 'url': url, 'post_time': post_time, 'read_count': read_count, 'comment_count': comment_count, 'crawl_timestamp': datetime.now().isoformat() }) except Exception as e: logger.warning(f"解析列表项失败: {e}") return items

注意ul#stockList > li是股吧列表页的稳定容器选择器,经测试在 2024 年 Q2 版本中未变更。若未来失效,优先检查#stockList是否被替换为#articleList或增加 class 名称前缀(如js-stock-list),而非直接改用模糊的div.article-item

2.2 详情页评论抓取:逆向push2接口与请求头构造

股吧详情页评论不走常规 REST API,而是调用https://guba.eastmoney.com/topic/xxxxx.html页面内嵌的push2接口,其真实请求地址形如:

https://guba.eastmoney.com/topic/push2?code=600519&topicid=XXXXX&page=1&perpage=30

该接口返回 JSON,字段包括content(HTML 片段)、nicknamelevel(用户等级)、time(Unix 时间戳)。关键难点在于请求头必须携带RefererUser-Agent,且Referer必须与目标股吧页面完全一致(含协议、域名、路径),否则返回空数据:

# crawler.py 中 push2 接口调用示例 def fetch_comments_by_push2(topic_id: str, stock_code: str, page: int = 1) -> Dict: url = f"https://guba.eastmoney.com/topic/push2" params = { "code": stock_code, "topicid": topic_id, "page": page, "perpage": 30 } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Referer": f"https://guba.eastmoney.com/topic/{topic_id}.html", # 必须精确匹配 "Accept": "application/json, text/plain, */*", "X-Requested-With": "XMLHttpRequest" } try: resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() return resp.json() except requests.exceptions.RequestException as e: logger.error(f"push2 接口请求失败 topic_id={topic_id} page={page}: {e}") return {"data": []}
2.2.1 topic_id 提取逻辑:从 URL 到 DOM 的双重校验

topic_id不是 URL 路径中的数字,而是页面<script>标签内var topicId = "123456789";的值。parser.py中使用正则提取,并辅以 URL 路径回退机制:

# parser.py 中 topic_id 提取函数 def extract_topic_id_from_html(html_content: str, url: str) -> str: # 优先从 script 标签中提取 match = re.search(r'var\s+topicId\s*=\s*["\'](\d+)["\'];', html_content) if match: return match.group(1) # 备用:从 URL 路径提取,如 /topic/123456789.html path_match = re.search(r'/topic/(\d+)\.html', url) if path_match: return path_match.group(1) raise ValueError(f"无法从 HTML 或 URL 中提取 topic_id: {url[:50]}...")

提示push2接口无频率限制,但单次最多返回 30 条,需循环调用page=1,2,3...直至data字段为空。项目中crawler.pycrawl_topic_comments()函数已内置分页终止判断。

2.3 动态发帖监听:DOM 内嵌 JSON 的增量捕获

股吧首页的“最新发帖”区域由 JavaScript 动态注入,其数据源是页面底部<script>标签中的一段 JSON 数组,形如:

<script type="text/javascript"> var latestTopics = [{"id":"123456","title":"茅台要出新品?","author":"股神小张","time":"2024-05-20 10:22:31","read":1245,"comment":89}, ...]; </script>

parser.py使用re.findall(r'var\s+latestTopics\s*=\s*(\[.*?\]);', html)提取并解析,避免了等待 JS 执行的开销。该策略比 Puppeteer 更轻量,且在main.py--mode=live参数下启用,支持每 30 秒轮询一次首页,仅对比id字段做增量去重。


3. 数据清洗与情感分析:金融语境下的 TextBlob 适配与词典增强

爬取的数据若不经清洗,直接喂给通用情感分析模型,准确率会断崖式下跌——股吧里“跌停”是负面,“跌停价买入”却是极端看多,“缩量”常表谨慎,“缩量涨停”却属强势信号。本项目采用“基础模型 + 金融词典微调”双轨策略:先用 TextBlob 计算原始 polarity(极性)和 subjectivity(主观性),再根据自定义词典对关键词做权重偏移,最终生成sentiment_score(-1.0 ~ +1.0)和sentiment_label('positive'/'neutral'/'negative')。

3.1 文本清洗流水线:去除干扰符号与标准化表达

股吧文本常见干扰包括:

  • 用户昵称@提及(如@东方不败
  • 表情符号 Unicode(如\U0001f602
  • 股票代码括号格式(贵州茅台(600519)贵州茅台
  • 重复标点(!!!!????

parser.py中的clean_text()函数按顺序执行:

# parser.py import re from textblob import TextBlob def clean_text(text: str) -> str: if not isinstance(text, str): return "" # 1. 去除 @ 提及 text = re.sub(r'@\w+', '', text) # 2. 去除表情符号(保留中文、英文字母、数字、常用标点) text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\.\!\?\,\;\:\-\_\(\)\[\]\{\}]', '', text) # 3. 标准化股票代码括号(如 (600519) → 空) text = re.sub(r'\(\d{6}\)', '', text) # 4. 合并连续空白符 text = re.sub(r'\s+', ' ', text).strip() # 5. 简化重复标点(最多保留2个) text = re.sub(r'([!?.])\1{2,}', r'\1\1', text) return text
3.1.1 为什么不用正则直接删所有标点?

因为!?在股吧语境中承载强烈情绪(如“见顶了!” vs “见顶了。”),删除会损失 polarity 信号。项目保留!?.,;:-_()[]{},仅过滤 emoji 和控制字符。

3.2 情感词典构建:覆盖 137 个金融场景关键词

项目附带finance_sentiment_dict.json,包含三类词条:

  • 极性反转词:如“缩量”默认中性,但“缩量涨停”整体为 positive;
  • 程度副词加权:如“超级”“史诗级”使 polarity ×1.5,“略”“稍”使 ×0.7;
  • 否定修饰链:如“不是没可能”需识别双重否定 → positive。

词典结构示例:

{ "缩量涨停": {"polarity_offset": 0.4, "label": "positive"}, "超级": {"polarity_multiplier": 1.5}, "不是没": {"polarity_offset": 0.3, "negation": true} }

analysis.py中的calculate_sentiment()调用流程:

def calculate_sentiment(text: str) -> Dict: cleaned = clean_text(text) if not cleaned: return {"sentiment_score": 0.0, "sentiment_label": "neutral"} # Step 1: TextBlob 基础分值 blob = TextBlob(cleaned) base_polarity = blob.sentiment.polarity base_subjectivity = blob.sentiment.subjectivity # Step 2: 词典增强 enhanced_polarity = base_polarity for term, config in FINANCE_DICT.items(): if term in cleaned: if "polarity_offset" in config: enhanced_polarity += config["polarity_offset"] if "polarity_multiplier" in config: enhanced_polarity *= config["polarity_multiplier"] if config.get("negation", False): # 简单否定检测:检查 term 前是否有“不”“没”“未” start_idx = cleaned.find(term) if start_idx > 0 and cleaned[start_idx-1] in "不没未": enhanced_polarity *= -1.0 # Step 3: 截断并打标 score = max(-1.0, min(1.0, enhanced_polarity)) if score > 0.2: label = "positive" elif score < -0.2: label = "negative" else: label = "neutral" return { "sentiment_score": round(score, 3), "sentiment_label": label, "base_polarity": round(base_polarity, 3), "subjectivity": round(base_subjectivity, 3) }

提示FINANCE_DICTanalysis.py开头通过json.load(open("finance_sentiment_dict.json"))加载,确保路径正确。若新增词条,需同步更新README.md中的词典维护说明。

3.3 热点话题聚类:TF-IDF + 余弦相似度的轻量实现

舆情分析不止于单条情绪打分,更要识别“哪些讨论正在升温”。项目用sklearn.feature_extraction.text.TfidfVectorizer对标题+首条评论做向量化,再用scipy.spatial.distance.cosine计算相似度,设定阈值 0.7 合并话题簇:

# analysis.py from sklearn.feature_extraction.text import TfidfVectorizer from scipy.spatial.distance import cosine import numpy as np def cluster_hot_topics(comments: List[Dict], threshold: float = 0.7) -> List[Dict]: # 构建文本集:标题 + 首条评论(若存在) texts = [] for c in comments: title = c.get("title", "") first_comment = c.get("comments", [{}])[0].get("content", "") if c.get("comments") else "" texts.append(f"{title} {first_comment}") if len(texts) < 2: return [{"topic": t, "count": 1, "sentiment_avg": 0.0} for t in texts] # TF-IDF 向量化 vectorizer = TfidfVectorizer(max_features=1000, stop_words=['的', '了', '和', '是', '我', '有', '也', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会', '着', '没有', '看', '好', '自己', '这']) tfidf_matrix = vectorizer.fit_transform(texts) # 两两计算余弦距离,构建簇 clusters = [] used = [False] * len(texts) for i in range(len(texts)): if used[i]: continue cluster = [i] used[i] = True for j in range(i+1, len(texts)): if used[j]: continue dist = cosine(tfidf_matrix[i].toarray()[0], tfidf_matrix[j].toarray()[0]) if dist <= threshold: # 距离越小越相似 cluster.append(j) used[j] = True # 计算簇内平均情绪分 cluster_scores = [comments[k].get("sentiment_score", 0.0) for k in cluster] clusters.append({ "topic": texts[cluster[0]][:30] + "...", # 截断显示 "count": len(cluster), "sentiment_avg": round(np.mean(cluster_scores), 3), "ids": [comments[k].get("id", "") for k in cluster] }) return sorted(clusters, key=lambda x: x["count"], reverse=True)
3.3.1 为什么不用 LDA 或 BERT?

LDA 需大量语料训练且主题数难调;BERT 推理慢、显存高,不适合学生作业级部署。TF-IDF + 余弦在千条级数据下耗时 <200ms,且stop_words已预置金融高频停用词(如“主力”“散户”“庄家”不作为停用),平衡了精度与效率。


4. MongoDB 存储设计与查询优化:应对股吧数据的高写入低查询特性

股吧数据具有典型“写多读少”特征:单日爬取可达 5 万+ 条帖子、20 万+ 条评论,但分析时通常只查最近 7 天或指定股票。本项目 MongoDB Schema 设计遵循两点原则:写入不校验,查询建索引。所有字段均设为nullable,避免因某字段缺失导致插入失败;关键查询字段(stock_code,post_time,sentiment_label)强制建立复合索引。

4.1 Collection 结构与字段说明

mongodb.py定义两个主 collection:

Collection用途关键字段(含类型)
posts存储帖子元数据stock_code(str),title(str),url(str),post_time(datetime),read_count(int),comment_count(int),crawl_timestamp(datetime),topic_id(str)
comments存储评论详情topic_id(str),nickname(str),content(str),post_time(datetime),sentiment_score(float),sentiment_label(str),crawl_timestamp(datetime),floor(int)

注意post_time字段存储为datetime类型(非字符串),便于$gte查询;sentiment_score保留 3 位小数,满足分析精度需求。

4.2 索引创建脚本与性能验证

mongodb.py中的init_indexes()函数在首次连接时自动创建索引:

# mongodb.py def init_indexes(db): # posts 集合:按股票+时间范围查询最频繁 db.posts.create_index([("stock_code", 1), ("post_time", -1)]) # comments 集合:按帖子ID+情绪标签聚合 db.comments.create_index([("topic_id", 1), ("sentiment_label", 1)]) # 补充:按时间范围快速清理过期数据(可选) db.posts.create_index([("crawl_timestamp", 1)], expireAfterSeconds=2592000) # 30天TTL
4.2.1 索引效果实测对比(10 万条数据)
查询语句无索引耗时有索引耗时加速比
db.posts.find({"stock_code":"600519", "post_time":{"$gte":ISODate("2024-05-01")}})1280 ms18 ms71×
db.comments.find({"topic_id":"123456789", "sentiment_label":"positive"})940 ms12 ms78×

索引创建后,main.py --stock=600519 --days=7的完整流程(爬取+解析+存库+分析)从 42 分钟降至 6 分钟。

4.3 数据导出与可视化:CSV 与简易热度趋势图

项目提供export_to_csv.py脚本,支持按股票、日期范围、情绪标签导出结构化数据:

python export_to_csv.py --stock 600519 --start 2024-05-01 --end 2024-05-10 --label positive --output positive_600519.csv

导出字段包括:title,url,post_time,read_count,comment_count,sentiment_score,sentiment_label,avg_comment_sentiment(该帖子下所有评论的平均分)。

此外,plot_hot_trend.py使用matplotlib绘制热度趋势(日发帖量 + 日平均情绪分):

# plot_hot_trend.py import matplotlib.pyplot as plt import pandas as pd def plot_daily_trend(csv_path: str): df = pd.read_csv(csv_path, parse_dates=['post_time']) df['date'] = df['post_time'].dt.date daily_stats = df.groupby('date').agg({ 'title': 'count', 'sentiment_score': 'mean' }).rename(columns={'title': 'post_count'}) fig, ax1 = plt.subplots(figsize=(12, 6)) ax1.bar(daily_stats.index, daily_stats['post_count'], alpha=0.7, label='Daily Post Count') ax1.set_ylabel('Post Count', color='tab:blue') ax1.tick_params(axis='y', labelcolor='tab:blue') ax2 = ax1.twinx() ax2.plot(daily_stats.index, daily_stats['sentiment_score'], 'r-o', label='Avg Sentiment Score') ax2.set_ylabel('Avg Sentiment Score', color='tab:red') ax2.tick_params(axis='y', labelcolor='tab:red') plt.title(f'Daily Trend: {csv_path.split("_")[1]}') plt.xticks(rotation=45) plt.tight_layout() plt.savefig(f'trend_{csv_path.split(".")[0]}.png', dpi=300) plt.show()

提示:绘图脚本依赖pandasmatplotlib,已在requirements.txt中声明。若中文显示方块,请在plot_hot_trend.py开头添加:

plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS'] plt.rcParams['axes.unicode_minus'] = False

5. 实战排错指南:从报错截图到定位根因的 7 个关键检查点

项目包内报错展示.jpg记录了 5 类高频错误,对应README.md中的“常见问题”章节。这里不罗列错误现象,而是给出可立即执行的诊断命令与修复动作,覆盖从环境到代码的全链路。

5.1 MongoDB 连接失败:ConnectionRefusedError

现象:运行python main.py报错pymongo.errors.ServerSelectionTimeoutError: localhost:27017: [Errno 111] Connection refused
根因:MongoDB 服务未启动,或配置文件中host/port错误
诊断命令

# 检查 MongoDB 是否运行 sudo systemctl status mongod # Linux brew services list | grep mongo # macOS Homebrew # 检查端口监听 netstat -tuln | grep :27017 # 测试连接(需安装 mongo shell) mongosh --eval "db.runCommand({ping:1})"

修复动作

  • 若服务未启动:sudo systemctl start mongod(Linux)或brew services start mongodb-community(macOS)
  • 若端口非 27017:修改mongodb.pyMONGO_URI = "mongodb://localhost:27017/"为实际端口

5.2 push2 接口返回空数据:{"data":[]}

现象crawler.py日志显示push2 接口请求失败,但 HTTP 状态码为 200
根因Referer头缺失或不匹配,或topic_id提取错误
诊断命令

# 手动 curl 测试(替换 YOUR_TOPIC_ID 和 STOCK_CODE) curl -H "Referer: https://guba.eastmoney.com/topic/YOUR_TOPIC_ID.html" \ "https://guba.eastmoney.com/topic/push2?code=STOCK_CODE&topicid=YOUR_TOPIC_ID&page=1&perpage=30"

修复动作

  • 检查parser.pyextract_topic_id_from_html()返回值是否为纯数字
  • 确认crawler.pyfetch_comments_by_push2()Referer参数是否与目标 URL 完全一致(含末尾/

5.3 XPath 解析失败:AttributeError: 'NoneType' object has no attribute 'get_text'

现象parser.py报错title_tag.get_text()title_tagNone
根因:股吧页面结构变更,ul#stockList > li选择器失效
诊断命令

# 获取当前列表页 HTML 并检查结构 curl -s "https://guba.eastmoney.com/list,600519,f_1.html" | head -n 50 | grep -A5 -B5 "title"

修复动作

  • 打开https://guba.eastmoney.com/list,600519,f_1.html,右键“查看网页源代码”,搜索class="title"href=,找到新容器选择器
  • 修改crawler.pyparse_list_page()soup.select()语句,例如改为soup.select('div.article-item a.title')

5.4 情感分析结果全为 0.0:sentiment_score恒定

现象analysis.py输出的sentiment_score全是0.0
根因clean_text()过度清洗,或TextBlob未正确加载英文词典
诊断命令

# 在 Python 交互环境测试 from textblob import TextBlob print(TextBlob("I love this stock!").sentiment) # 应输出 polarity>0 print(clean_text("缩量涨停!")) # 应输出 "缩量涨停!"

修复动作

  • TextBlob返回(0,0):运行python -m textblob.download_corpora下载词典
  • clean_text()删除了关键符号:检查正则re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\.\!\?\,\;\:\-\_\(\)\[\]\{\}]', '', text)是否误删!?

5.5 热点聚类结果为空:clusters列表长度为 0

现象cluster_hot_topics()返回空列表
根因:输入comments为空,或texts列表长度 <2
诊断命令

# 在 analysis.py 中临时插入 debug print(f"Input comments count: {len(comments)}") print(f"Generated texts: {texts[:3]}") # 查看前3条

修复动作

  • 确认crawler.py是否成功抓取评论(检查comments字段是否存入 MongoDB)
  • 若仅1条数据,跳过聚类直接返回单条记录(analysis.py已内置此逻辑)

5.6 CSV 导出乱码:Excel 打开显示方块

现象export_to_csv.py生成的 CSV 在 Excel 中中文乱码
根因:文件未以 UTF-8 with BOM 编码保存
修复动作

  • 修改export_to_csv.pydf.to_csv()参数:
    df.to_csv(output_file, index=False, encoding='utf-8-sig') # 关键:utf-8-sig

5.7 图表中文不显示:plot_hot_trend.py折线图标签为方块

现象plt.show()显示的坐标轴和标题为方块
根因:Matplotlib 默认字体不支持中文
修复动作

  • plot_hot_trend.py开头添加字体设置(见 4.3 节提示)
  • 或全局配置:~/.matplotlib/matplotlibrc中添加font.sans-serif: SimHei, DejaVu Sans, Bitstream Vera Sans, sans-serif

最后一行技术动作:执行python main.py --stock=600519 --days=3 --mode=full,观察logs/crawler.logINFO级别日志是否包含Saved 127 posts to MongoDBCalculated sentiment for 892 comments,即表示全流程贯通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询