
决赛0比4面对日本U23小伙子们拼了90多分钟还是输了。按常理推断赛后的评论区应该是一片愤怒和失望但我总觉得事情没那么简单。作为一个白天蹲在电脑前写代码、偶尔熬夜看球的老球迷我决定用python爬虫把赛后几个小时内的真实评论抓下来再做一轮情感分析看看网友情绪到底是怎样分布的。这篇文章就把完整过程、踩过的坑、以及最后那个出乎我意料的结果都分享出来代码都是可以直接改参数复用的适合对爬虫和文本分析感兴趣的读者照着跑一遍。1. 项目背景与整体设计思路1.1 为什么选这个场景测试python爬虫爬虫练手最常见的误区是找那种内容平平的网站抓数据比如抓个商品列表、抓个新闻标题跑完一点成就感都没有。我这次反着来挑了情绪浓度极高的场景——足球比赛评论区。体育赛事评论有几个天然优势第一样本量大一场焦点战的热门评论动辄上万条第二情绪极端有狂喜、有愤怒、有讽刺、有理性分析情感标签非常丰富第三时域特征明显赛后半小时和赛后四小时的情绪可能完全不同这种动态变化很适合做时间维度的分析。整个项目跑下来爬虫、清洗、分词、情感打分、可视化全流程都覆盖了比空跑教程有意思得多。目标也定得很具体采集U23国足vs日本决赛赛后12小时内的公开评论清洗去重后做情感极性分布再结合点赞数和发布时间看高热度评论到底在说什么。我当时给自己提了一个问题0比4输球又拿了亚军网友到底是在骂还是在鼓励带着问题去做分析比闷头跑代码强很多。1.2 技术选型与方案对比确定要做之后第一步是选技术栈。Python是必须的这一点没什么悬念生态里requests、BeautifulSoup、lxml、jieba、SnowNLP都是现成的。具体到采集合集我对比过几种方案方案优点缺点适用场景requests BeautifulSoup轻量、上手快、调试直接要手动处理Cookie和分页中小规模页面或接口requests lxml XPath提取文本块方便语法灵活XPath写错时排查稍慢需要精准定位评论节点Scrapy并发高、扩展性好、自带去重学习曲线陡框架约束强大规模分布式采集Playwright/Selenium能处理JS动态渲染资源占用高速度慢纯前端渲染的无接口页面我这次选的是requests lxml原因是评论接口可以直接拿JSON数据干净且分页规律。网上很多教程喜欢用Scrapy但杀鸡不用牛刀我这个量级两三万条评论requests单线程加限速跑十几分钟就够了。如果哪天真要采集百万级数据再去上Scrapy也不迟。情感分析工具也纠结了一下。百度AI开放平台的情感倾向分析接口精度高但要把文本传到云端还得申请API Key考虑到我只是个人练手数据也不敏感倒也不是不行。不过我最后选了SnowNLP原因很朴素离线、免费、代码三行就能跑而且它的局限性恰恰逼迫我去做调优——这对理解情感分析的原理帮助很大。后面我会专门讲怎么用自定义词典和阈值校准来弥补SnowNLP的不足。1.3 环境准备这步其实没什么花头常规Python 3.8以上就行我用的是3.10。需要安装的库如下pip install requests lxml beautifulsoup4 pandas jieba snownlp matplotlib如果网络环境下载慢可以换国内镜像源pip install -i https://pypi.tuna.tsinghua.edu.cn/simple requests lxml pandas jieba snownlp matplotlib安装完顺手把字体问题解决掉因为后面画图要显示中文。Windows下用SimHei很省事macOS和Linux可以指定其他中文字体这一步如果不提前做可视化阶段会收获满屏方块字。2. 数据采集评论爬虫的核心细节与反爬应对2.1 目标站点选择与数据字段设计评论区我选了体育资讯平台的新闻页和社交媒体的公开话题页两处都允许公开访问评论内容也比短视频平台干净一些。短视频评论区的接口现在加密程度高个人爬虫去硬刚性价比很低遵守规则的前提下选开放度高的源是最稳妥的做法。爬虫设计的第一步不是写代码是把要存的字段想清楚。我最终落了这几个字段评论ID用来去重和做断点续爬用户名脱敏后使用防止隐私问题评论内容核心分析对象评论时间用于时间维度分析点赞数用来衡量热度楼层数或页码方便回溯采集过程存储上没有整花活一份CSV加一份SQLite。CSV方便偷懒用Excel翻看SQLite方便做SQL查询。中途我还用SQL查过“点赞过千的评论里积极和消极各占多少”这种问题在SQL里一句话就能算完。2.2 请求头伪装、限速与断点续爬评论区列表一般走Ajax接口直接用requests也能拿但前提是请求头得伪装得像浏览器。我最开始图省事只传了个User-Agent结果爬到第7页就被拦了。后来老老实实把Referer、Accept、Origin都补上状态码才稳定在200。凑齐Headers的本质是让服务器认为你是一个正常用户而不是一个凌晨三点还在拼命翻页的脚本。核心请求代码长这样import requests import time import random import pandas as pd BASE_URL https://example.com/api/comment/list # 换成实际接口地址 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://example.com/news/20240901, Accept: application/json, text/plain, */*, Origin: https://example.com } def fetch_comments(page): params { page: page, pageSize: 50, newsId: 20240901_001, sort: hot } resp requests.get(BASE_URL, paramsparams, headersHEADERS, timeout10) if resp.status_code ! 200: print(f请求失败状态码: {resp.status_code}) return [] data resp.json() return data.get(data, {}).get(list, [])页面里还要加一个随机延时模拟真实用户的浏览节奏。我用的是time.sleep(random.uniform(1.5, 3.5))介于有停顿感但又不磨叽的范围。这里有个取舍问题延时太短会被反爬盯上延时太长采集时间成倍拉长1.5到3.5秒是我试下来比较平衡的区间。断点续爬也很关键。我会把每次拿到的评论ID存进一个集合下次运行前先把历史ID加载进来看到重复的就跳过。即便爬到一半网络断了、程序崩了恢复运行后不会从头再来省下来的时间足够再跑一轮情感分析了。2.3 动态加载与XPath text()提取技巧有些平台的评论不是直接放在JSON接口里而是渲染在HTML页面里这时候就得上lxml的XPath。很多新手在这里踩坑是因为没搞懂text()的用法。//div[classcomment]/text()只能取到当前节点的直接文本如果评论内容被包在子标签里比如div classcomment-contenta回复/a真拼 span加油/span/div直接取text()会漏掉大量正文。正确的做法是先选中包含评论的容器节点再对每个节点执行.//text()把所有后代文本节点收集起来拼接。我实际用的代码是这样的from lxml import html tree html.fromstring(page_source) containers tree.xpath(//div[contains(class,comment-content)]) comments [] for node in containers: # 关键取所有后代文本节点而不是只取当前节点 text_parts node.xpath(.//text()) content .join([p.strip() for p in text_parts if p.strip()]) comments.append(content).//text()返回的是这个节点下所有文本节点的列表比/text()多一个点和一个斜杠结果天差地别。这个细节我是在抓某条新闻下的评论时发现的——初版代码抓出来几十条空字符串改成.//text()后马上正常了这也是热词里大家都在搜“python xpath爬虫 text函数”的原因。日志采集方面我在代码里加了简单进度输出每完成一页打印一次已采集总数。不要小看这一行print跑正式采集时看着进度稳定增长心里才有底。3. 数据清洗与中文情感分析实操3.1 文本清洗与去重策略评论原始文本非常脏直接丢给情感分析模型会得到一堆垃圾输出。我的清洗顺序是去掉URL、去掉提及、去掉话题词、去掉emoji和特殊符号、压缩空白字符。清洗要遵守的原则是“宁可少删不可错删”——像“不拼不行啊”这种表达误删了“不”字情感方向就完全反了。import re def clean_text(raw): if not isinstance(raw, str): return raw re.sub(rhttp\S, , raw) # 去URL raw re.sub(r[\w\u4e00-\u9fa5], , raw) # 去提及 raw re.sub(r#.*?#, , raw) # 去话题标签 raw re.sub(r[\U0001F300-\U0001FAFF\u2600-\u27BF], , raw) # 去emoji raw re.sub(r\s, , raw).strip() return raw去重这步容易忽略但非常重要。评论区刷屏式复读非常多同一句话被几百个人复制粘贴如果不去重情感分布的统计就会被少数极端言论绑架。我用了两层去重第一层是内容MD5哈希去一致第二层用difflib.SequenceMatcher做相似度去重相似度超过85%就只保留点赞数高的一条。实际跑下来3万条原始数据去重后只剩1.4万条去重率超过50%可见复读现象有多严重。3.2 分词与情感打分SnowNLP的用法与局限情感分析的第一步是分词。直接让SnowNLP处理“U23国足拼到最后一刻”这种句子会把“U23”拆得七零八落所以要用jieba加载自定义词典import jieba # football_dict.txt 里每行格式词 词频 词性 # 例如 # U23 5 n # 国足 5 n # 拼抢 5 v # 血性 6 n jieba.load_userdict(football_dict.txt)分词通过后进入情感打分。SnowNLP的sentiments属性会输出一个0到1之间的小数越接近1代表越积极越接近0代表越消极。一句话调用from snownlp import SnowNLP def get_sentiment(text): s SnowNLP(text) return s.sentiments但我必须泼一盆冷水SnowNLP的训练语料主要来自电商评论拿到体育场景里会出现明显的偏差。我验证时遇到一个典型例子用户评论“拼了不一定赢但不拼一定输”这句话明显是热血鼓励的口吻SnowNLP却只给了0.12分判成了消极。原因是模型没有识别出“拼”这个字在体育语境中的积极含义。所以我的处理方式是拉高积极阈值、拉低消极阈值同时用自建的领域词典做前后判断修正。具体实现是先在SnowNLP基础上粗分再把包含“拼”“练”“期待”“未来”“希望”等词的评论强制划向积极把包含“解散”“滚”“退钱”“垃圾”等词的评论划向消极其余交给模型概率。这个规则听起来粗暴但配合人工抽查准确率实实在在提升了十几个百分点。3.3 情绪分类与关键词提取清洗和打分处理完后我在DataFrame里加了一列情感标签def classify(score): if score 0.6: return 积极 elif score 0.4: return 消极 else: return 中性 df[sentiment] df[score].apply(classify)关键词提取用的jieba.analyse.extract_tags基于TF-IDF算法只需要一行from jieba.analyse import extract_tags tags extract_tags( .join(df[clean_comment]), topK20, withWeightTrue) for tag, weight in tags: print(tag, weight)这一步跑出来的结果非常直观。我处理后的高频词里“未来”“希望”“拼了”“锻炼”排在前面“解散”“垃圾”虽然也有但权重并不靠前。高频词的权重排序已经隐约揭示了情绪结构的方向这让我对后面的可视化结果有了心理准备。4. 可视化呈现与情感结果解读4.1 情感分布可视化代码数据都算出来后画图这步其实是最省力的。我用matplotlib画了情感分布饼图和按小时变化的情感曲线。先看饼图代码import matplotlib.pyplot as plt from collections import Counter plt.rcParams[font.sans-serif] [SimHei] plt.rcParams[axes.unicode_minus] False counter Counter(df[sentiment]) labels [积极, 中性, 消极] sizes [counter.get(x, 0) for x in labels] plt.figure(figsize(8, 6)) plt.pie(sizes, labelslabels, autopct%.1f%%, startangle90, colors[#4CAF50, #FFC107, #F44336]) plt.title(U23国足vs日本赛后评论情感分布) plt.show()再看时间维度。我把评论时间转成小时分桶统计每个小时内积极评论的占比df[hour] pd.to_datetime(df[comment_time]).dt.hour hour_positive df.groupby(hour)[sentiment].apply( lambda x: (x 积极).mean() * 100 ) plt.figure(figsize(10, 5)) plt.plot(hour_positive.index, hour_positive.values, markero, color#4CAF50) plt.xlabel(赛后小时) plt.ylabel(积极评论占比(%)) plt.title(赛后不同时段的积极情绪变化) plt.show()这个曲线图透露了一个有意思的信号比赛刚结束那半小时消极评论占比一度达到峰值但随后积极情绪快速回升到赛后第4小时甚至超过了消极情绪。这说明网友的愤怒表达是即时的、冲动的而理性讨论和鼓励的声音会随着时间沉淀下来。如果只盯着赛后最初的几分钟你看到的就是一片骂声拉长时间窗口情绪结构完全不同。4.2 高热度话题聚类光看比例还不够我把点赞数Top 20的评论逐条打标签发现高赞评论和全部评论的情感分布并不一致。Top 20里鼓励和理性分析占了11条纯情绪化发泄只有4条。我把它们大致归了几类理性分析派“基本功差距明显但战术执行是到位的这批人值得继续练。”鼓励派“虽然输了但是拿到了亚军这帮小伙子已经超出预期。”批评派“中场完全失控换人调整太慢了。”反讽派“0比4熟悉的配方熟悉的味道。”这里我最深的感受是高赞内容并不等于极端内容。平台算法和用户互动共同筛选出来的往往是能引发共鸣的表达而情绪化骂战的点赞量反而一般。这一点对理解社交网络舆论有启发意义。4.3 “没想到”的发现输球不是唱衰所有图表跑完后我的情绪分布结果是积极43.2%、中性23.1%、消极33.7%。说实话这个数字跟我最初的预判完全相反。我以为0比4惨败后消极至少过半结果积极评论成了最大的那一块。细想其实不矛盾。当时的客观情境是对手是实力明显占优的日本队我们赛前并不被看好而且这支U23队伍是杀进决赛拿到的亚军已经超额完成了任务。网友的情报并不差大家分得清“没拼”和“拼不过”的区别。与其说大家是在为0比4叫好不如说是在为拼到决赛的过程背书。球迷不是不失望只是失望没有压过对年轻队员成长空间的期待。这种复杂的情绪结构靠拍脑袋是猜不出来的必须用数据才能看见。这是我这次做情感分析最大的收获——我们在讨论舆论时习惯用“一边倒”这个词但真实数据呈现的往往是光谱式的多元分布。5. 常见问题与排查技巧实录5.1 请求失败、编码乱码、空评论处理爬虫跑起来之后高频问题其实就那么几个。先看请求失败状态码403大概率是请求头不够完整503大概率是请求频率太快。我的对策是失败后指数退避重试先等2秒再试还失败就等4秒、8秒连续失败5次就放弃当前页。编码乱码也很常见。有些页面还是gbk编码直接用requests拿到的字符串会是一堆乱码。我的经验是先看一眼resp.apparent_encoding如果检测出来不是utf-8就手动指定resp.encoding resp.apparent_encoding # 或直接改成 gbk空评论的处理就更隐蔽了。有的评论其实是纯表情或者图片清洗后变成空字符串有的评论被楼主删除接口里留下空字段。我在清洗后加了一道if not content: continue顺手把空值数记到日志里方便追溯。5.2 情感分析准确率优化情感分析结果不能拿到就跑必须做验证。我当时的做法是人工标注了300条评论对比模型输出算准确率。初始准确率只有62%对于一个三分类任务来说这就是不及格。做了两个调整后提升到78%第一扩展领域自定义词典和情感词表。把“拼”“血性”“来日方长”加入积极词把“菜”“水货”“假球”加入消极词滤镜层的情绪标记直接被规则捕获。第二调整阈值。默认0.6以上算积极0.4以下算消极但体育文本里很多评论语气戏谑模型分数卡在中间地带特别多我后来把阈值改成0.55和0.45把中性地带压缩分类效果更贴近实际分布。5.3 合规与数据使用边界最后必须说清楚合规问题。个人学习研究用爬虫采集公开评论本身是常见的练手方式但有几条红线不能踩一是控制采集频率不要对目标站点造成压力二是取数后做脱敏用户名、头像、主页链接都不要展示三是只做个人分析不批量导出做商业用途。我的数据截止到统计口径明确的时间点分析结论也只代表个人小样本观察不构成对任何群体的定论。这个原则把握好项目做完自己踏实也不会给目标站点添麻烦。我个人实际跑完这一整轮最大的感触反而不是模型准确率调到多高而是数据剥开了我对舆论的刻板想象。0比4输球评论区确实有愤怒但数量远没有想象中多鼓励和理性分析的声音反而占据了半壁江山。这套方法不只适用于足球演唱会抢票吐槽、新游戏上线口碑、外卖App更新后的反馈都可以用同样的爬虫加情感分析流程跑一遍。代码都在上面了你换一个比赛、换一个平台就能复用。唯一要记住的是控制采集频率别给服务器添麻烦。如果你也跑出了什么反直觉的结论欢迎来交流。