决赛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, params=params, headers=HEADERS, timeout=10) 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[@class="comment"]/text()只能取到当前节点的直接文本,如果评论内容被包在子标签里,比如<div class="comment-content"><a>回复</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(r'http\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"]), topK=20, withWeight=True) 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, labels=labels, autopct="%.1f%%", startangle=90, 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, marker="o", 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更新后的反馈,都可以用同样的爬虫加情感分析流程跑一遍。代码都在上面了,你换一个比赛、换一个平台就能复用。唯一要记住的是控制采集频率,别给服务器添麻烦。如果你也跑出了什么反直觉的结论,欢迎来交流。