
1. 先说这场比赛再说这个项目1.1 0比4背后一个程序员看到了什么U23国足对阵日本这场球赛前谁都知道不好踢但真的踢出0比4这个比分时评论区还是炸了。有意思的是这支球队虽然大比分输给了日本却因为整个赛事的整体表现拿下了亚军。于是社交平台上的声音特别分裂——有人骂技战术崩盘有人说亚军已经是惊喜还有人盯着几个年轻球员的跑动数据吵个不停。我看了半小时评论区发现一个很典型的现象情绪比信息多立场比证据多。同样是0比4有人看到的是绝望有人看到的是希望还有人只看到了自己的情绪宣泄口。作为一个常年跟数据打交道的Python开发者我当时的第一个念头不是加入争吵而是想搞清楚一件事——这届网友的情绪分布到底跟我想的一样不一样。于是我花了大概一个晚上的时间用Python写了一套爬虫加情绪分析的小工具把某体育平台赛事评论区里的公开留言全部抓下来再通过分词、情感打分、关键词聚类做了一次量化分析。最后的结果确实出乎我的意料也让我对网络情绪这个模糊概念有了完全不一样的理解。1.2 为什么不用现成的舆情系统可能有人会问市面上的舆情分析工具一抓一大把有免费的也有付费的直接丢个链接进去不就完事了吗我确实考虑过这个方案但很快放弃了。第一个原因是成本稍微能用的舆情系统动辄按年收费个人项目没必要花这个钱。第二个原因是灵活性现成工具给的是正面、负面、中性这种粗粒度结论但我更想知道的是网友在具体骂什么、夸什么、担心什么这种细颗粒度的信息拆解只有自己写的代码能做到。第三点也很现实——现成舆情系统大部分是黑盒你根本不知道它的打分词表是怎么构建的也就没法判断结论靠不靠谱。自己做爬虫就不一样了。每一行代码都清楚在干什么每一个数据字段都可以追溯哪怕分析结果不完美至少我知道它为什么不完美。对于我个人来说这种可控性比开箱即用重要得多。2. 项目目标与技术选型2.1 情绪分析到底要分析什么很多人一听到情绪分析第一反应就是统计正面评论多还是负面评论多。但实际做过的人都知道真正的情绪分析难点不在正负分类而在情绪层次的拆解。0比4输球这场比赛的评论区骂声和鼓励声可能同时存在甚至出现在同一条评论里。比如有人写虽然输了但拼劲还在这句话能不能简单归为正面不能。它里面包含了失望和认可两种情绪只是比例不同。再比如有人写门将尽力了后防线是灾难这算正面还是负面前半句正面后半句负面。所以我在设计分析目标的时候给自己定了三个层次。第一层是整体正负情绪占比用来回答网友到底是骂得多还是夸得多第二层是高频关键词聚类用来回答大家讨论的焦点集中在哪里第三层是情绪与关键词的交叉分析用来回答消极情绪主要集中在哪些话题上。只有把这三个层次都跑通才算一个完整的情绪分析项目。2.2 技术栈选定requests加lxml的组合最稳技术选型这块我基本没有犹豫直接用了Python生态里最成熟的一套组合。网络请求用的是requests库这是Python爬虫的标配API设计简单处理Cookie、Headers、Session都够用。网页解析用的是lxml加XPath比正则表达式好维护得多也比BeautifulSoup在性能上快不少。文本处理用的是jieba做中文分词配合snownlp做情感打分。另外还用到了pandas做数据清洗matplotlib做可视化。这套组合选下来核心逻辑很简单requests拿到HTMLlxml把HTML转成结构化节点XPath定位评论内容jieba把中文句子切成词snownlp给句子算情感分数最后pandas汇总分析。可能有朋友会问既然snownlp的正负判断精度一般为什么不直接用大模型API做情感分析我在实际测试中对比过大模型API的精度确实高一些但有两个问题。第一是成本几千条评论跑下来API调用费用已经够吃几顿外卖了第二是延迟逐条调用API的耗时是本地模型的好几倍。对于个人项目来说snownlp的精度完全够用关键是它的词典可以本地扩展这也是我选择它的核心理由。2.3 数据源怎么选才靠谱爬虫项目的第一步不是写代码而是想清楚数据从哪里来。我这次选数据源的时候定了三个标准第一数据必须包含真实的用户评论而不是机器生成的内容第二页面结构要相对规范便于解析第三要有足够的评论数量保证样本量能支撑分析。对比了几个平台之后我选择了一个大型体育资讯平台的赛事评论区。理由很简单这个平台的评论是按时间倒序展示的分页结构固定每条评论的发布时间、用户ID、点赞数都在固定的DOM节点里非常适合XPath精准定位。而且体育平台不像短视频平台那样把评论数据藏在加密接口里数据结构相对透明对新手来说非常友好。我同时强调一点做爬虫一定要有边界意识。我抓取的全部是公开可见的数据而且控制抓取频率在每秒两到三条不给目标服务器造成任何压力。抓取的数据也仅限于个人学习分析使用。这一点后面专门讲。3. 爬虫设计与采数实现3.1 页面结构分析和XPath定位写爬虫代码之前我花了差不多四十分钟做页面分析。这个时间花得非常值因为页面结构分析做得越细后面写代码就越顺。打开赛事评论区的页面按F12进入开发者工具先看评论列表的外层结构。大多数情况下评论列表是一个div标签每条评论是这个div下的子div。关键技术点在于定位评论内容和发布时间这两个字段的XPath路径。我以某平台评论页为例核心的XPath路径大致是这样# 评论列表容器 comment_list html.xpath(//div[classcomment-list]/div[classcomment-item]) # 单条评论的正文 comment_content item.xpath(.//div[classcomment-content]//text()) # 发布时间 comment_time item.xpath(.//span[classcomment-time]/text()) # 点赞数 comment_like item.xpath(.//span[classcomment-like]/text())这里有一个很重要的细节评论内容有时候会被拆分成多个文本节点比如用户昵称、回复对象、表情符号都会混在里面。所以我在提取的时候用了//text()而不是/text()这样能拿到当前节点下的所有文本节点然后再在清洗阶段拼接到一起。还有一点容易被忽略就是编码问题。很多网站的页面声明是UTF-8但实际返回的内容可能会有编码偏移。我通常在请求时显式指定编码response requests.get(url, headersheaders, timeout10) response.encoding response.apparent_encoding这样能最大程度避免中文乱码问题。3.2 完整爬虫代码与运行逻辑下面是我这次项目里实际使用的核心爬虫代码我做了一些脱敏处理但整体逻辑和真实运行版本是一致的。先看完整的代码import requests import time import random from lxml import 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: https://sports.example.com/ } def get_comments(page_num): url fhttps://sports.example.com/match/12345/comments?page{page_num} try: response requests.get(url, headersheaders, timeout10) response.encoding response.apparent_encoding tree html.fromstring(response.text) comments [] items tree.xpath(//div[classcomment-list]/div[classcomment-item]) for item in items: content_parts item.xpath(.//div[classcomment-content]//text()) content .join(content_parts).strip() pub_time item.xpath(.//span[classcomment-time]/text()) like_num item.xpath(.//span[classcomment-like]/text()) if content: comments.append({ content: content, time: pub_time[0].strip() if pub_time else , like: int(like_num[0].strip()) if like_num else 0 }) return comments except Exception as e: print(f第{page_num}页抓取失败: {e}) return [] all_comments [] for page in range(1, 60): page_comments get_comments(page) print(f第{page}页获取{len(page_comments)}条评论) all_comments.extend(page_comments) # 随机延时避免对服务器造成压力 time.sleep(random.uniform(0.3, 0.5)) print(f共抓取 {len(all_comments)} 条评论)这段代码的运行逻辑分三步构造翻页URL、请求并解析页面、把评论数据追加到总列表里。循环60页是因为赛事热度高评论区有近3000条评论60页刚好覆盖完。代码里有两个关键设计。第一个是time.sleep(random.uniform(0.3, 0.5))这个随机延时代码看上去不起眼但它决定了爬虫是礼貌访问还是粗暴抓取。0.3到0.5秒的间隔意味着每秒只发两到三个请求对站点服务器来说完全是正常浏览流量。第二个是异常处理try...except网络请求不可能百分之百成功超时、断连、页面改版都可能发生有了异常捕获爬虫不会因为某一页失败就整体崩溃而是跳过错误继续跑。3.3 反爬机制的识别与应对做爬虫绕不开反爬这个问题。我在这个项目里遇到的第一个拦截是访问频率过高时弹验证码页。第一次跑的时候我设置的延时只有0.1秒结果到第20页左右就被拦截了。识别反爬的维度一般有三个请求频率、请求头特征、行为模式。请求频率这块解决方案就是前面提到的随机延时把它从固定延时改成随机延时被识别的概率会明显下降。请求头特征这块必须设置完整的User-Agent、Referer和Accept-Language让请求看起来像真实浏览器发出的。行为模式这块有些站点会检测访问路径的规律性如果每页间隔时间完全一致反而容易被判定为脚本随机延时正好解决了这个问题。还有一个容易被忽略的细节是Cookie处理。很多站点的评论接口需要先访问页面拿到会话Cookie然后再带着Cookie翻页。我这次的代码里没有显式处理Cookie是因为目标平台的评论区接口对未登录用户也开放了读取权限。但如果你爬的站点需要登录才能看评论那就要用requests.Session()来维持会话状态。session requests.Session() login_url https://sports.example.com/login data {username: xxx, password: xxx} session.post(login_url, datadata, headersheaders)记住一个原则爬虫的核心不是对抗而是模拟。尽量让自己看起来像一个正常用户而不是试图攻破对方的安全防线。4. 情绪分析的完整流程4.1 数据清洗比情感分析更重要爬下来的3000条原始评论不能直接用来分析。评论区是个鱼龙混杂的地方里面什么内容都有直接拿去跑模型的话结果会被噪声带偏。我遇到的脏数据主要有这么几类。第一类是纯表情评论比如哈哈哈、6666这种。这类评论虽然表达了情绪但对主题分析没有任何价值需要过滤掉。我用的方法是统计评论里的中文字符占比低于一定比例的直接剔除。第二类是广告垃圾评论比如加微信领红包之类的内容。这类评论通常包含明显的外链特征或者联系方式我在清洗时专门建了一个敏感词表把包含这些词的评论标记为垃圾数据。第三类是超短评论。只有一个字哎或者只有一个唉这种评论能表达情绪但信息量太低在关键词聚类环节会产生噪点。我设置了一个规则少于两个字的评论只保留在情感分析里不进入关键词统计。清洗代码大致长这样import re import pandas as pd def clean_comment(text): # 去除HTML标签和多余空白 text re.sub(r[^], , text) text re.sub(r\s, , text) # 去除纯标点、纯表情类评论 chinese_chars re.findall(r[\u4e00-\u9fa5], text) if len(chinese_chars) 4: return None return text df pd.DataFrame(all_comments) df[cleaned] df[content].apply(clean_comment) df df.dropna(subset[cleaned])清洗完之后原本3000条评论剩下大约2400条可用数据。这个淘汰比例非常正常我在其他项目的文本清洗中淘汰率通常在15%到25%之间这说明评论区本身就存在大量低信息量内容。4.2 中文分词与情感打分清洗完数据之后下一步是分词和情感打分。这一步是整个项目的核心环节。分词用的是jieba库它是目前中文文本处理最常用的分词工具。在分词之前我加了一个关键操作——加载自定义词典。这一步很容易被新手忽略但效果差异巨大。比如门将这个词如果不加进自定义词典jieba可能会切成门和将两个字语义完全变了。再比如后防线、乌龙球、换人调整这些足球领域的专有名词都需要提前加进词典。import jieba from snownlp import SnowNLP # 加载自定义词典 jieba.load_userdict(football_dict.txt) # 情感打分 def sentiment_score(text): s SnowNLP(text) return s.sentiments df[sentiment] df[cleaned].apply(sentiment_score)snownlp的情感打分逻辑是输出一个0到1之间的概率值得分越高表示情绪越正面。一般来说我把0.4以下定义为消极0.4到0.6定义为中性0.6以上定义为积极。这里有一个重要的认知点snownlp是基于朴素贝叶斯训练的通用情感模型它对打得好太强了这类直白表达判断得比较准但对虽然输了但拼劲还在这种转折句往往会判成中性偏负面。所以我在后面又加了一层基于规则的人工校准针对那些包含转折词虽然但是不过的评论做二次人工判断。坦白讲snownlp的情感判断精度不是最高的但对这个项目来说已经足够了。它最大的优势是纯本地运行不需要联网处理几千条评论只要几十秒而且完全免费。4.3 基于规则的关键词聚类情感打分只能告诉你正还是负不能告诉你在讨论什么。为了搞清楚0比4这场比赛网友关注的焦点话题我做了一步关键词提取和聚类。方法用的是jieba的TF-IDF关键词提取算法。TF-IDF的核心思想是一个词在一篇文本里出现次数多但在整个语料里出现次数少那这个词就有更高的区分度更能代表这篇文章的核心主题。from jieba.analyse import extract_tags keywords_count {} for text in df[cleaned]: keywords extract_tags(text, topK3) for keyword in keywords: keywords_count[keyword] keywords_count.get(keyword, 0) 1 sorted_keywords sorted(keywords_count.items(), keylambda x: x[1], reverseTrue) print(sorted_keywords[:20])跑完之后我拿到了这一场0比4比赛评论区的关键词排名。排在最前面的几个词是门将后防换人年轻拼劲虽败犹荣差劲战术青训未来。这个排序本身就说明了问题——虽然比分是0比4但网友讨论的核心话题并不只是比分本身。5. 结果输出数据不会骗人但解读会5.1 情感分布的统计结果先看我拿到的整体情感分布数据。在2400条有效评论中snownlp打出的积极情绪占比大约31%中性情绪占比约34%消极情绪占比约35%。这三项数据非常接近几乎是一个均分的状态。说实话这个结果在跑出来之前我是没想到的。按照我对网络评论环境的固有印象0比4惨败给日本评论区应该是压倒性的负面情绪消极评论没有70%也有60%。但实际数据告诉我情绪并没有一边倒。我又把数据按时间段拆开看发现了一个更有意思的规律比赛刚结束的前30分钟消极情绪占比高达52%确实是压倒性的但两小时之后积极情绪占比反超到40%以上到第二天早上评论区的主旋律已经变成了复盘和鼓励。这说明一个非常重要的问题情绪是时间的函数不是静态的标签。你抓取数据的时间点直接决定了你看到的情绪分布。如果我在比赛刚结束时抓取那结论会是网友对国足彻底失望如果我在第二天抓取结论又会变成网友对年轻球员充满信心。两个结论都对但都不全面。5.2 最意外的发现骂与爱的对象完全不同比情感分布更让我意外的是情绪与关键词的交叉分析结果。我把消极评论单独拿出来做关键词聚类发现被骂得最集中的不是比分不是战术甚至不是教练而是两个具体位置——门将和后防线。大量评论的原话都在说门将出击时机有问题后防线站位太散。但当我再看积极评论的时候发现被夸得最集中的是几个年轻球员的拼劲和跑动距离。有一个球员在评论区被反复提到大家夸的不是他的技术而是他全场比赛不放弃的态度。这个发现让我意识到网友的情绪远比我们想象中理性。骂的是表现夸的是态度——这两者并不矛盾甚至可以同时存在。0比4的结果让大家失望但球员拼命跑动的态度又让大家看到希望。所以情绪分析的结果不是简单的正方胜出或者反方胜出而是批评与期待并存。还有一个有趣的发现是高频词里出现了青训未来希望这类长期话题词。这说明0比4这场比赛本身在某种程度上成了一个引子把网友对足球长期发展的关注给激发出来了。当大家用长期视角看问题时当下的0比4就只是一个成长过程中的阶段性阵痛而不是世界末日。5.3 一个立体的情绪画像综合所有数据我最后得出了一张这样的情绪画像网友对这场比赛的情绪不是单一的情绪而是三层情绪叠加的结果。第一层是即时情绪也就是比赛刚结束时的失望和愤怒主要集中在门将失误和后防混乱上。第二层是理性情绪过了一段时间后大家开始复盘战术和人员配置讨论为什么会出现0比4。第三层是长期情绪涉及青训体系、年轻球员成长、未来赛事预期这部分情绪相对积极。如果把这三层情绪画在一张图上你会发现它不是一条简单的负到正的直线而是一个从情绪宣泄到理性复盘再到长期期待的渐变过程。这也解释了为什么我会在开头说没想到竟然这样——我原本以为评论区是一边倒的骂声但数据告诉我骂声只是一层浮沫下面藏着的是更复杂的情绪结构。如果只看表面你只会得到一个网友很生气的结论但深入数据之后你会看到网友在生气的同时也在期待在批评的同时也在认可。6. 踩坑记录与实操总结6.1 爬虫环节最常见的4个问题这个项目做完我整理了一下在爬虫环节遇到的最典型的几个问题都是常规文档里不会写的那种。第一个是翻页URL参数判断错误。有些网站的评论翻页是传统的?page2这种形式但有些网站用的是光标分页比如?cursorxxxxxxxx的形式。我一开始想当然地用了page参数结果翻了不到3页就全是一样的内容。解决办法是先手动翻两页对比URL差异再确定分页参数。第二个是睡眼不足导致IP被临时限制。这个前面提到过我把固定延时改成随机延时之后问题就解决了。但如果你发现改了延时还是被限制那可能是同一个IP短时间内请求量太大需要用IP代理池来轮换。不过对个人项目来说除非你爬海量数据否则随机延时基本够用。第三个是XPath表达式因为页面改版失效。我在项目进行到一半的时候发现某个字段突然抓不到数据了排查之后发现是网站前端做了一次A/B测试一部分用户看到的页面结构不一样。解决方案也比较粗暴写个检测逻辑如果单页抓到0条评论就自动暂停并打印当前页面的HTML片段方便快速定位问题。第四个是重复数据问题。用户反复刷新或者我们重复请求时同一评论可能被抓多次。我用的是评论内容和发布时间合并去重的方式因为在绝大多数平台两条评论的文本和时间完全相同是极小概率事件。6.2 情绪分析最大的坑指标陷阱情绪分析这个环节最大的坑不是技术问题而是指标定义问题。如果你把snownlp输出的0到1分直接映射成正面/负面那你会丢失大量信息。比如0.4到0.6这个区间snownlp把它定义为中性但它实际上包含了大量有情绪但表达克制的评论。我给自己的建议是不要迷信单一指标。正负情绪占比只是一个粗线条真正有价值的是情绪分布的形状和变化趋势。如果时间允许建议把情感分数分成10个区间分别统计占比这样能看到更细腻的分布结构。还有一个容易踩的坑是样本偏差。我这次只爬了某个单一平台的评论这个平台的用户画像只能代表一部分人群。如果我想得到一个更全面的情绪画像至少要覆盖两到三个不同属性的平台比如体育垂直平台、综合资讯平台、短视频平台的评论区。6.3 爬虫的边界意识最后想认真地说一说爬虫的边界问题。这个内容很重要但往往容易被技术文章忽略。我在之前的代码里特别强调了数据来源必须是公开访问的页面这是爬虫的第一条边界。第二条边界是抓取频率必须控制在对目标服务器无感的范围内这是技术上的礼貌问题。第三条边界是抓取数据的用途必须限定在个人学习和分析范围内不得用于商业用途这也是目前主流法律框架下对爬虫行为的基本约束。我自己在实践中奉行一个原则爬虫工具本身是中性的关键看用来做什么。用爬虫去做数据分析、了解舆论动态、辅助个人决策这些都没问题。但如果你用爬虫去批量抓取未公开数据、干扰网站正常运营、或者把抓取的数据用于牟利那就越过了红线。这个项目里的所有数据我在分析完之后没有保留任何可识别个人身份的字段只保留了评论文本的统计分析结果。这也算是我给自己定的一个数据使用底线吧。通过这次实践我最深的体会是爬虫和情绪分析本身并不是这个项目最大的价值真正的价值在于——当所有人都在凭直觉判断网友情绪的时候用数据给出一个可以被检验、被讨论、被推翻的答案。哪怕这个答案不是最终真理它也至少提供了一个比凭感觉更可靠的视角。下次再遇到热点事件的时候我建议大家也可以试试这个思路把情绪诉诸数据你会发现一个全新的世界。