Python爬取东方财富股吧评论:从接口分析到数据存储

发布时间:2026/10/4 23:26:09
Python爬取东方财富股吧评论:从接口分析到数据存储
如果你关注过东方财富网的股吧评论大概率会发现一种奇特现象某只股票明明业绩平平评论区里却一片“起飞”的欢呼另一只基本面扎实的股票股吧里反倒骂声不断。这种散户情绪和基本面数据之间的背离恰恰是很多人做舆情监控、短线情绪判断甚至量化策略时不想错过的信号。用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面板再按CtrlR刷新页面。此时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还是JSONP3.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, paramsparams, headersHEADERS, timeout10) 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_size20): return { path: flist,{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, retry3): params build_params(stock_code, page) for attempt in range(retry): try: resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) 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}页第{attempt1}次请求失败: {e}) time.sleep(2) return []翻页主逻辑def crawl_stock(stock_code, max_pages10, delay1.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(subsetpost_id) df.to_csv(filepath, indexFalse, encodingutf-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, indexFalse, encodingutf-8-sig)SQLite的话用Python自带的sqlite3就能搞定import sqlite3 conn sqlite3.connect(guba.db) df.to_sql(guba_comments, conn, if_existsreplace, indexFalse) 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], errorscoerce) print(df.shape) print(df.describe()) print(df[publish_time].dt.hour.value_counts().sort_index())这些数字能让你直观感受到数据的分布如果发现发布时间集中在一个异常的时间段或者某天的数据量猛增可能意味着抓取过程中有些页面被漏掉了也可能是当天该股票确实出了大新闻。这种对数据的敏感度是后续做情感分析时判断结果是否合理的基础。到这里爬取股吧评论这条路已经通了。你手里的数据包含标题、摘要、作者、时间、阅读量、评论量和点赞量已经是一份能支撑后续分析的干净数据。下一篇我会把这批数据接进情感分析流程先用SnowNLP这种轻量方案快速跑一版情绪得分再对比一下LSTM的效果。到时候你会发现前面花在页面分析和数据清洗上的时间每一分钟都不会白费。