ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python爬取股吧评论:从零构建股票舆情数据采集与清洗实战

Python爬取股吧评论:从零构建股票舆情数据采集与清洗实战 做股票投资的朋友多少都有过这种经历盯着一只股票拿不定主意跑到股吧里翻评论想看看别人都在聊什么。但翻着翻着就会发现帖子太多、观点太杂靠肉眼根本看不过来。这时候用Python把股吧评论批量抓下来再做一轮情感倾向分析就能比较客观地看看市场情绪到底是偏乐观还是偏悲观。这篇文章我把整个项目的落地过程拆开来讲从爬虫部分开始也就是系列的第一篇。这个项目适合想练手爬虫和文本分析的Python爱好者也适合量化交易或舆情监控方向的朋友做数据储备。我尽量把每一步都讲透包括为什么这么写、踩过哪些坑照着操作就能跑起来。1. 项目整体设计与思路拆解1.1 为什么选择东方财富股吧作为数据源市面上股票社区不少雪球、同花顺、淘股吧都有大量用户讨论我最终选择东方财富股吧原因其实很实际。第一是数据量大且覆盖全。东方财富的用户基数摆在那里几乎每只A股股票都有对应的股吧板块热门股的帖子更新速度非常快一天几百条新帖很正常。这意味着我们能拿到足够样本量去做情感分析样本太小的话统计结果没什么参考价值。第二是页面结构相对规整。股吧的帖子列表有固定的URL范式评论区的数据也藏在几个稳定的接口里不涉及复杂的加密参数用requests加BeautifulSoup就能搞定不需要上Selenium这类重型工具。对于一个想快速验证舆情分析思路的项目来说这就是最舒服的数据源。第三是评论内容有明确的情感倾向。散户在股吧里发言通常比较直接看多看空都写在脸上比如明天涨停庄家跑路了拿着不动坐等翻倍这种文本非常适合做情感分析的训练和验证。1.2 技术选型这套组合最顺手我用的是比较经典的Python爬虫数据分析组合不整花活够用就行。requests发HTTP请求抓取网页源码和JSON数据比urllib好用太多自带会话管理能保持Cookie。BeautifulSoup4解析HTML文档定位帖子列表里的标题、链接、作者、回复数等字段写起来直观。lxml作为BeautifulSoup的解析引擎速度比纯Python的html.parser快不少。pandas做数据清洗和结构化存储最后统一导出CSV方便后面做情感分析时直接读入。re正则表达式处理一些页面里的JS变量或者特殊字符提取。至于情感分析部分后面第二篇我会用SnowNLP和jieba配合来做SnowNLP对中文短文本的情感倾向判断效果还不错而且上手成本低不需要自己训练模型。如果你对准确率有更高要求也可以换成BERT或者百度的Senta但那是后话先爬数据要紧。1.3 整体流程与模块划分整个项目我拆成了几个模块每个模块独立运行、互不干扰数据采集层 - 数据清洗层 - 存储层 - 分析层本篇文章聚焦前三层也就是把数据从股吧里扒下来、洗干净、存起来。具体流程是这样的构造股票代码对应的股吧列表页URL循环翻页爬取帖子标题、帖子链接、作者、发布时间、阅读数和评论数。进入每个帖子的详情页提取楼层评论内容。对评论数据进行去重、空值处理、噪声过滤等清洗操作。把结果存入CSV文件每行一条评论字段包含股票代码、帖子标题、评论内容、评论时间等。这么设计的好处是每一层都能单独调试。你可以先把列表页跑通确认字段都提取对了再去做详情页的爬取不用一上来就追求一条龙。2. 目标网站分析与请求策略2.1 股吧页面结构与数据定位动手写代码之前一定要先打开浏览器手动访问目标页面把网页结构摸清楚。这一步省不了很多新手上来就写requests请求结果发现解析出来的内容对不上归根结底就是没先看页面。东方财富股吧的帖子列表页URL格式是这样的https://guba.eastmoney.com/list,600519,f.html其中600519是股票代码f表示全部帖子如果换成1就是精华帖。翻页的话URL会变成https://guba.eastmoney.com/list,600519,f_2.html https://guba.eastmoney.com/list,600519,f_3.html注意第一页不带_1直接从第二页开始才有页码后缀这个细节容易被忽略。用浏览器打开页面后按F12打开开发者工具选中某个帖子的标题元素你会看到每条帖子都被包裹在一个div标签里标题在a标签内链接是href属性。作者信息、阅读量、评论数分别在对应的span或div中。我需要提醒一句页面结构随时可能改版。2023年到2024年之间我就遇到过两次class名变化的情况。所以不要硬编码太死解析时尽量用标签层级关系而不是某个单一的class名或者做好异常兜底。2.2 请求头与访问参数的设置爬虫发请求时如果什么请求头都不带服务器一眼就能看出是脚本访问大概率直接拒绝。我习惯在请求里带上比较完整的Headers模拟真实浏览器的行为。headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Connection: keep-alive, Referer: https://guba.eastmoney.com/ }关于User-Agent多说两句。你可以在网上搜最新UA找一段和自己浏览器版本匹配的字符串。用同一个UA反复请求也没关系但别用那种明显不合理的UA比如被大量爬虫用滥的Python-requests默认UA很容易触发风控。另外建议用requests的Session对象来维持会话而不是每次请求都新建一个requests.get。原因很简单Session会保存Cookie对同一个站点连续请求时服务端看到的是同一个浏览器在干活被拦截的概率会小很多。2.3 反爬机制的应对思路东方财富股吧的反爬强度属于中等偏下但它不是完全不设防。我实际操作中遇到的主要限制有两个第一个是访问频率。短时间密集请求比如1秒发5个以上的请求IP很容易被临时封禁表现就是返回的页面变成验证码或者直接超时。解决办法很朴素控制请求间隔每抓完一页睡2到3秒。第二个是部分数据走接口动态加载。帖子的楼层评论不是直接在HTML源码里的而是通过XHR请求从后端接口拉取。这种情况下直接解析HTML是拿不到评论内容的得先找到那个接口。找接口的方法还是在开发者工具的Network面板里点击翻页或者展开评论观察新增的XHR请求。股吧评论的接口通常返回JSON格式数据里面有replies或者类似字段。我们只需要把参数拼好用requests直接请求这个JSON接口数据比解析HTML更规整效率也更高。3. 数据爬取的完整实现3.1 帖子列表页爬取代码第一步先把帖子列表抓下来。这个模块的目的不是拿评论而是拿到所有帖子的URL以及一些基本的统计字段。我用一个函数来搞定单页的抓取和解析import requests from bs4 import BeautifulSoup import time import pandas as pd def get_stock_code(): 这里让用户输入股票代码 比如贵州茅台就是600519 code input(请输入股票代码).strip() return code def fetch_list_page(code, page): 抓取股吧帖子列表的某一页 page1时URL不带页码后缀其余页加 _页码 if page 1: url fhttps://guba.eastmoney.com/list,{code},f.html else: url fhttps://guba.eastmoney.com/list,{code},f_{page}.html headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: fhttps://guba.eastmoney.com/list,{code},f.html } resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 if resp.status_code ! 200: print(f第{page}页请求失败状态码{resp.status_code}) return None soup BeautifulSoup(resp.text, lxml) items [] # 帖子列表项的选择器注意股吧改版后class可能会变需要现场调整 article_list soup.select(div.articleh) for article in article_list: try: title_tag article.select_one(a.title) if not title_tag: continue title title_tag.get_text(stripTrue) link title_tag.get(href) if not link.startswith(http): link https://guba.eastmoney.com link # 作者、阅读数、评论数在不同class中按需提取 author article.select_one(a[rel*author]) author_name author.get_text(stripTrue) if author else read_tag article.select_one(span.read) read_count read_tag.get_text(stripTrue) if read_tag else comment_tag article.select_one(span.comment) comment_count comment_tag.get_text(stripTrue) if comment_tag else items.append({ 股票代码: code, 帖子标题: title, 帖子链接: link, 作者: author_name, 阅读数: read_count, 评论数: comment_count, 页码: page }) except Exception as e: print(f解析单条帖子出错{e}) continue return items这里有个容易踩坑的地方articleh这个class是股吧帖子列表项的历史类名我在2023年上半年实测时还是有效的。但网站改版频率不低如果你的选择器解析不到内容建议在开发者工具里看一下当前页面每一条帖子所在的标签结构。3.2 帖子详情页与评论数据抓取拿到帖子URL之后下一步就是进入详情页抓取评论。股吧的评论数据我前面说了往往是接口动态返回的直接解析帖子详情页HTML拿不到完整的楼层评论。我在实际项目里是直接找接口。经过抓包分析股吧帖子详情的接口形态大致长这样具体参数会随版本变化https://gbapi.eastmoney.com/comment/list?code帖子IDpage1size30有的接口需要带callback参数做JSONP返回体里包含评论正文、用户名、评论时间、点赞数这些字段。我们就不用纠结把整个页面爬下来再正则抠数据了直接请求接口拿到标准JSON用json模块解析省事得多。import requests import json def fetch_comments(post_id, max_pages5): 根据帖子ID抓取评论数据 这里max_pages限制最大抓取页数防止单个帖子评论太多导致耗时过长 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: fhttps://guba.eastmoney.com/news,{post_id}.html } all_comments [] for page in range(1, max_pages 1): # 注意接口地址我用占位符展示实际使用时需要替换成抓包得到的真实接口 api_url fhttps://gbapi.eastmoney.com/comment/list?code{post_id}page{page}size30 try: resp requests.get(api_url, headersheaders, timeout10) data resp.json() # 假设JSON结构中有data.comments这样的字段 comments data.get(data, {}).get(comments, []) if not comments: break for item in comments: all_comments.append({ 帖子ID: post_id, 评论内容: item.get(content, ), 评论用户: item.get(user_name, ), 评论时间: item.get(post_time, ), 点赞数: item.get(like_count, 0) }) except Exception as e: print(f抓取帖子{post_id}第{page}页评论出错{e}) break time.sleep(1) return all_comments这里我要特别说明股吧的接口地址和返回字段可能会调整我不能保证上面的占位符URL一定还活着。真正跑项目时第一件事是用开发者工具抓包找到当前最新的评论接口然后把URL替换进去。代码思路是不变的变的是接口地址。还有一种兜底方案如果实在找不到接口就用Selenium模拟浏览器滚动页面等评论加载完再抓取渲染后的DOM。但Selenium太吃资源启动一个浏览器实例做并发抓取既慢又容易崩溃我只有在实在走不通接口的情况下才会退而求其次。3.3 数据清洗与存储爬下来的评论数据不能直接拿去用。股吧评论里充斥着表情符号、连续重复字符、广告信息以及各种无意义的沙发顶一个这些都会影响后面情感分析的准确率。我写了一个清洗函数核心逻辑包括以下几项去除HTML标签有些评论内容里会残留标签。去除URL链接评论里的链接跟情感无关直接删掉。去除emoji和特殊符号正则匹配掉非中英文数字的符号但保留基本标点因为感叹号和问号对情感判断有影响。去除纯灌水内容比如666哈哈哈这类重复短文本。空值处理评论内容为空的行直接丢弃。import re import pandas as pd def clean_comment(text): 评论清洗函数 if not isinstance(text, str): return text re.sub(r.*?, , text) text re.sub(rhttp\S|www\.\S, , text) # 保留中文、英文、数字和常用标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、\s], , text) # 去除纯重复字词比如哈哈哈哈哈 if len(text) 1: return # 去除太短的纯语气词 short_noise {沙发, 顶, 路过, 666, 111, 哈哈哈哈, 嘿嘿, 不错, 支持} if text.strip() in short_noise: return return text.strip() def save_to_csv(all_data, filename): 保存到CSV文件 df pd.DataFrame(all_data) # 去重同一帖子ID和同一内容的评论视为重复 df df.drop_duplicates(subset[帖子ID, 评论内容]) # 清洗评论内容 df[评论内容] df[评论内容].apply(clean_comment) # 清洗后为空的删除 df df[df[评论内容] ! ] df.to_csv(filename, indexFalse, encodingutf-8-sig) print(f数据已保存共{len(df)}条有效评论) return df存储时我用utf-8-sig而不是utf-8这个细节很多新手容易忽略。Excel打开UTF-8的CSV会乱码但用utf-8-sig可以让Excel正确识别。如果你后续是用pandas直接读两种编码都没问题但考虑到很多人会用Excel先看一眼数据直接存成utf-8-sig省得麻烦。3.4 主流程串联把上面的模块串起来主函数长这样def main(): code get_stock_code() all_posts [] # 先抓5页帖子列表你也可以改成抓取直到没有数据为止 for page in range(1, 6): print(f正在抓取第{page}页帖子列表...) items fetch_list_page(code, page) if not items: break all_posts.extend(items) time.sleep(2) print(f共获取到{len(all_posts)}条帖子) all_comments [] # 遍历每篇帖子的前2页评论 for post in all_posts[:50]: # 先跑50篇帖子试试效果 post_id extract_post_id(post[帖子链接]) comments fetch_comments(post_id, max_pages2) all_comments.extend(comments) time.sleep(1) save_to_csv(all_comments, guba_comments.csv)extract_post_id这个函数就是从帖子URL里把ID抠出来一般是用正则匹配数字部分def extract_post_id(link): match re.search(r/(\d)(?:\.html)?, link) return match.group(1) if match else 主流程里有几个需要斟酌的点。第一是遍历帖子数量我默认取了50篇因为评论接口的请求压力比列表页大得多一口气抓所有帖子容易触发风控而且耗时很长。第二是每个帖子的评论只抓了前2页如果你的目标是分析该股票的近期情绪这个量级已经够用。4. 常见问题与排查技巧实录4.1 访问频率过高导致IP受限我最初在调试时循环里忘了加time.sleep结果大概发了30多个请求之后股吧页面开始返回一段JS校验脚本正常的帖子列表不见了。这其实就是IP被临时限制了。遇到这种情况第一反应不是去换IP或者买代理而是先退出来等5到10分钟再试。临时限制一般过了账期自动解除。如果在同一个局域网内有多台机器跑爬虫要错开时间否则出口IP同一个照样会被一起限制。诚心建议把time.sleep(2)这类代码当成业务逻辑的一部分不要想着去掉。爬得快不等于爬得稳数据完整比速度重要得多。你省下的那几分钟可能换来的是封IP几小时的麻烦。4.2 页面编码与乱码问题requests抓回来的文本解析时中文乱码是常见问题。股吧的页面编码是UTF-8一般用resp.encoding utf-8就能解决。但如果你用的是接口JSON里的字符串是\u开头的Unicode转义被JSON解析后会自动转回中文不需要额外处理。有一个比较稳妥的做法不手动指定编码而是从响应头中读取charsetresp.encoding resp.apparent_encodingapparent_encoding是requests根据网页内容自动检测的编码准确率很高。不过这个方法偶尔会把UTF-8识别成GB2312所以我还是倾向于手动指定毕竟股吧就是UTF-8明确指定反而少出错。4.3 页面结构变更的应对方案我必须再次强调爬虫写完之后不是一劳永逸的。股吧在2023年到2024年之间改版过articleh这个class目前还在用但谁能保证明天它不变应对思路有两点。第一CSS选择器不要写得太复杂太精确。比如不要用div.articleh div div a.title这种层级依赖很强的选择器直接a.title更健壮。第二在代码里加解析异常监控。一旦某个页面解析出的条目数为0就先不要把程序跑死而是报警提示让你知道可能需要更新选择器了。不要等到跑了一整天才发现所有数据都是空的。4.4 断点续爬与去重当抓取量大了以后程序可能跑一半崩掉或者你主动中断了进程。这时候如果从头再来浪费不少时间。我后来加了简单的断点续爬机制每爬完一个帖子就把帖子ID追加到一个文本文件里。def record_post_id(post_id): with open(crawled_posts.txt, a, encodingutf-8) as f: f.write(post_id \n) def load_recorded_ids(): try: with open(crawled_posts.txt, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) except FileNotFoundError: return set()主循环里每次拿到帖子先判断ID是否已经存在于已记录集合中存在就跳过。这样即便中途断掉重启后也能从断点附近继续。去重方面列表页翻页时可能会遇到同一篇帖子重复出现的情况因为帖子被管理员置顶或者有新评论顶贴页码排序会变。所以不止要记录抓过哪些帖子还要在评论数据层面做去重我用的方法是按帖子ID评论内容联合去重这个方案在同一条评论重复抓取时很有效。5. 实操心得与下一步预告5.1 几个可以少走弯路的经验第一爬虫项目别贪多。一开始就想把所有股票的所有评论全抓下来这是给自己挖坑。先拿一只股票、5页列表、每帖2页评论跑通全流程确认数据和存储都没问题再考虑扩大规模。我当初就是贪多一次性跑几十只股票结果半小时后被限流代码倒是写了一大堆数据全没拿下来。第二请求头里的Referer很重要。有些接口会校验收到的请求是从哪个页面跳过来的如果你不带上Referer接口返回的可能是错误页。这个字段抓包的时候顺便看一下拼到headers里问题自然消失。第三数据处理和爬虫尽量分开跑。我都是先把原始数据存到CSV再写一个独立的脚本做清洗和分析。这样爬虫挂了不影响数据处理数据处理出问题也不用重新爬。两个环节偶合在一起调试会非常痛苦。第四如果只是做情感分析验证不需要执着于拿到全量评论。抽取样本就够用了。比如每只股票抓最近一周的几百条评论足以看出情绪倾向。全量抓取既没有效率也没有必要还会增加被反爬盯上的概率。5.2 情感分析部分预告数据有了、清洗也做了下一篇就可以把重点转向情感分析了。我计划用SnowNLP给每条评论打一个情感倾向分分数越接近1表示越乐观越接近0表示越悲观。然后按天聚合画出这只股票的网络情绪曲线对比一下情绪和股价走势之间有没有相关性。这个分析过程涉及不少好玩的问题比如跌了但股吧情绪反而高涨是怎么回事利好消息出来但评论却一片悲观又该怎么解读。这些留到第二篇详细展开。如果你想提前动手可以先跑一下pip install snownlp然后试试这样一句代码from snownlp import SnowNLP text 这个股票明天肯定大涨 s SnowNLP(text) print(s.sentiments) # 越接近1越正面如果你是第一次接触情感分析看到0.98这样的分数应该会很有感觉。不过我要提醒一句SnowNLP默认模型是基于电商评论语料训练的用在股吧场景会有偏差到时候我们需要做一些优化。具体怎么优化下一篇细聊。数据已经躺在CSV里了下一步就是让它开口说话。
返回列表