用Python爬虫+情感分析,从1.6万条评论还原U23决赛真实舆论
凌晨一点我关掉直播屏幕定格在0比4。U23亚洲杯决赛中国U23国家队输给了日本拿了亚军。按说亚军已经是这些年难得的好成绩可打开评论区什么声音都有有说虽败犹荣的有说技不如人的还有大量纯粹发泄情绪的话。我写了六七年Python那晚没去网上跟人抬杠而是打开电脑决定写个爬虫把网友的真实情绪量化出来。我给自己定了三个问题骂声真的占多数吗理性讨论是不是被淹没了比赛结束后的三个小时里情绪到底怎么变化的结果比我预想的有意思得多甚至有个结论直接推翻了我刷手机时的直觉。这篇就把整个项目复盘一下包括评论数据怎么抓、情绪怎么算、哪些地方容易翻车以及那个让我反复确认了好几次的意外发现。1. 比赛踢完的那晚我为什么非写个爬虫不可1.1 球迷身份下信息噪声已经大到我听不见真相赛后的第一件事我打开了常用的几个App。热搜上挂着好几个相关词短视频平台的封面清一色是日本球员庆祝的照片评论区前排要么是“丢人现眼”要么是“已经可以了这群小伙子还有戏”。两种声音都极其高亢但谁也没说服谁。我真正不舒服的地方在于这些内容背后的推荐算法天然会把情绪最极端的评论推到我眼前。因为它们互动率高平台就认为它们“优质”。于是我看到的所谓“舆论”其实只是几个吵架吵得最凶的人。而大量沉默的、只点了个赞的、认认真真分析比赛的普通人在信息流里几乎是隐形的。我意识到如果想知道“网友到底什么态度”不能靠刷手机刷出来得靠数据抽出来。1.2 情绪这东西其实是可以被量化的写代码的人有个毛病遇到一堆文本第一反应是能不能结构化。评论区看起来是几千行非结构化的中文句子但剥开看无非就是几个维度说好话还是坏话、情绪强烈到什么程度、什么时候说的、有没有人认同点赞数。这些维度都能用Python量出来。说好话还是坏话交给情感分析模型情绪强度可以用正负向词典打分什么时候说的看评论发布时间有没有人认同看点赞数。把这些字段拼起来就够了。所以那天晚上我给自己定的项目目标很朴素抓取若干平台与这场比赛相关的评论正文、发布时间、点赞数。对每条评论做情感倾向判定分成正向、中性、负向三类。按时间切片看情绪如何变化。最后结合点赞数看看“被认同的”到底是哪类声音。这个目标不复杂但做完之后我发现自己对这场比赛的看法都被改变了。1.3 项目边界先划清楚不然会掉进无底洞动手之前我得先承认一件事互联网上的评论是抓不完的。微博热搜几百个入口各大论坛帖子实时更新短视频评论区又是另一套体系。真要全量采集我得先上一套分布式爬虫框架再加代理池最后还得跟平台的风控斗智斗勇——这明显不是一晚能搞定的事。所以我把范围缩小到三个我自己平时也在用的平台微博、B站、再加少量懂球帝的赛后讨论帖。时间窗锁定在比赛结束后的四个小时。样本量控制在五位数以内。这个规模不需要上分布式爬虫一台电脑、一个requests库、几杯咖啡够了。个人项目最大的风险不是抓不到数据而是目标太大导致进度失控。先把边界划清楚后面每一步都会轻松很多。2. 评论去哪抓微博、B站、懂球帝的取舍2.1 三个平台的接口难度相差悬殊先说结论不是所有平台的评论都值得爬。有的平台接口干净得像教科书有的平台光参数签名就能劝退新手。平台数据入口难度主要难点微博移动端评论接口中等需要登录Cookie有频率限制分页逻辑特殊B站评论区/弹幕接口中高评论接口有签名参数弹幕是XML格式懂球帝App接口较高请求带签名与时间戳校验逆向成本高我最终的选择是主抓微博评论辅抓B站弹幕和评论懂球帝只作为人工抽检对照不写完整爬虫。原因很现实——我不打算在一篇复盘文里通宵逆向别人的App签名。懂球帝的讨论质量确实高但为了那几百条数据去对抗签名校验投入产出比太低。如果你只是想复现一个舆论分析项目我建议你也按这个思路来先挑一个数据量最大、接口最友好的平台跑通全流程其他平台作为补充。一口气贪多大概率会在某个平台的反爬机制上卡到天亮。2.2 微博评论先找到那条“主微博”再说微博的评论接口并不难找难的是知道该去评论哪条微博。我第一步是搜索“U23 日本 决赛”之类关键词找到比赛期间热度最高、由媒体账号发布的几条比赛报道微博复制它们的微博ID。有了微博ID之后评论接口就顺理成章了。我使用的是微博移动端的Web接口返回的是JSON格式里面直接带着评论正文、用户昵称、发布时间、点赞数还有一个关键字段——楼中楼回复数。结构大概是{ data: { comments: [ { text: 虽然输了但踢得有点东西, created_at: 04-12 23:05, like_count: 123, reply_count: 3 } ] } }这个接口需要带登录后的Cookie否则会被风控挡下来。好在我手机端常年挂着微博登录态一拷短时间抓取没问题。注意这个接口的分页不是简单的page1、2、3它是靠max_id来翻页的。第一次写的时候我用普通页码怼上去一直重复拿到同一批数据排查了二十分钟才发现问题。这类细节平台文档不会写真得踩一次才能记住。2.3 B站弹幕BV号转cid剩下的就是解XMLB站这边我选了一个比赛集锦视频先拿到BV号。然后调用视频信息接口拿到视频的cid弹幕接口就會返回一坨XMLd p30.2,1,25,12345,0,0,0差距还是有的/d d p31.8,1,25,12346,0,0,0未来可期/d每条弹幕的正文在标签体里前面那个p属性里则是时间戳、弹幕类型、用户ID等元数据。用Python的xml.etree.ElementTree解析循环取标签的text就行非常丝滑。B站评论区的接口反向需要处理一个叫WBI签名的东西涉及请求参数按字典排序再哈希。说实话第一次自己写很烦后来我直接用了现成的开源库bilibili-api-python自己只负责调用和存储。做项目要分清主次我的目标是拿数据做情绪分析不是发明B站签名算法。能省的时间绝不硬肝。2.4 样本量控制在什么范围数据才够看最终我跑了两个小时拿到有效评论和弹幕约1.6万条。其中微博评论约9000条B站弹幕约6000条B站评论约1000条懂球帝那边我只人工翻阅了热度最高的赛后帖记录了大概几十条典型言论用作对照。这个量级做粗粒度的情绪分析绰绰有余。每条评论统一清洗成四个字段来源平台、评论文本、发布时间、点赞数。存成CSV后面分析全靠它。3. 爬虫落地的关键代码和三个容易翻车的细节3.1 一套能用的通用爬虫骨架不管是微博还是B站我都基于requests.Session写了一套通用骨架。Session的好处是能自动维持Cookie配合自定义请求头看起来更像一个正常访客。我习惯加上随机延时和重试机制毕竟评论抓取不是性能测试没必要怼着服务器猛敲。import requests import time from random import random session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1, Referer: https://m.weibo.cn/, X-Requested-With: XMLHttpRequest }) def fetch_json(url, max_retry3): for i in range(max_retry): try: resp session.get(url, timeout10) if resp.status_code 200: return resp.json() except Exception: pass time.sleep(1 random() * 2) return None移动端的UA可以大大降低被风控标记的概率因为多数反爬策略对PC头更敏感。Referer字段也得认真写对防爬严格的接口少了它可能直接返回403。3.2 从浏览器复制Cookie别急着写自动登录评论区接口的登录态绕不开。我的做法很简单手机浏览器登录微博后在开发者工具里把Cookie整段复制到本地配置文件里。两张小时内抓紧把该抓的抓完过期了再去浏览器重新复制一次。不推荐在这个项目里做自动登录。微博的登录体系有滑块验证、设备指纹、短信验证码一环扣一环。为了一个一次性分析项目去弄自动登录至少要多写两百行代码还得处理各种奇怪的验证码弹出。与其这样不如手动复制Cookie把精力留给后面的情感分析和可视化。数据抓取的核心指标是“到手率”不是“自动化率”。3.3 XPath里text()的坑我替各位踩过了抓B站网页版评论或某些论坛帖子时我会用XPath提取正文。这里有个经典坑//div[classcontent]/text()只能取当前节点的直接文本如果评论主体的结构是“外层div里有子span”/text()取到的就几乎为空。正确写法是comment selector.xpath(//div[classcontent]//text()[normalize-space()]) comment .join(c.strip() for c in comment)//text()拿到的是所有后代文本节点构成的列表包括子标签里的文字再拼接成完整句子。normalize-space()的作用是剔除空白节点防止列表里混入换行和空格。这个细节不大但第一次遇到时我一度以为目标页改了结构绕了很大的弯。3.4 数据清洗评论里全是“”和“#话题#”不能直接用原始评论基本没法直接丢给情感分析模型。里面夹杂着某用户、#某话题#、链接、表情符号还有大量重复转发。我清洗时做了这样几件事去掉所有用户名片段只保留正文主体。去掉#话题#两个井号及其中内容。去掉URL和纯图片标记。文本长度小于2的删掉基本是“哈哈哈”“……”这类无法判断情绪的碎片。用集合去重微博评论区经常有复制粘贴刷屏的队形这类重复内容会严重拉偏情感统计。清洗后有效数据大概剩1.4万条。别嫌少清洗本身就是数据质量的一部分。把脏数据留在里面后面的情绪分析结果会被严重污染。4. 情绪评分怎么做SnowNLP、词典规则和人工抽检4.1 为什么选本地库而不调大模型API做情感分析的路子很多可以调大模型接口可以申请云厂商的自然语言处理API也可以用本地开源库。我最后选了本地方案SnowNLP加词典规则融合。原因有两个。第一评论量是一万多条如果全走大模型接口算下来是一笔小额但没必要的账单而且个人项目的数据本来就属于公共评论没必要为了分析而把数据发到第三方服务。第二本地方案可解释性更强。SnowNLP给出一个0到1的分数我可以手动抽查可以针对足球场景调整词典整个过程自己完全可控。大模型在细腻情感理解上更强但在这个场景里我需要的是粗粒度的正/中/负三分法本地库完全够用。4.2 SnowNLP的基础用法以及它的“偏科”问题SnowNLP的用法简单到只需要两行from snownlp import SnowNLP score SnowNLP(text).sentiments分数越接近1越正向越接近0越负向。但问题在于SnowNLP的训练语料偏电商评论“发货快”“质量好”这类句子它能判断得很准到了体育评论里就开始犯迷糊。我踩到的实际案例是“真服了”这种明显负向的句子SnowNLP给了0.68分居然是正向偏积极。“无语”也是被识别成中性偏正。原因是这类口语在训练语料里出现得太少模型根本没学会它们的真实情绪。这就是我不迷信单个模型的原因。任何通用模型落到垂直领域都需要做校准否则结果就是精致的废话。4.3 用足球场景词典做规则修正为了解决“偏科”我加了一层词典规则。大致逻辑是用jieba分词遍历文本里的词按出现的正负向词进行加减分。我手工整理了两个词表负向词表解散、滚、垃圾、丢人、下课、废、摆烂、丢球、不配、呵呵、服了。正向词表未来可期、虽败犹荣、拼了、尽力、有进步、希望、加油、稳住、敢打。规则模型很简单给每个词一个权重加权求和后映射到0到1区间neg_score sum(neg_word_weights.get(w, 0) for w in words) pos_score sum(pos_word_weights.get(w, 0) for w in words) lex_score (0.5 pos_score - neg_score) / (1 pos_score neg_score) lex_score max(0, min(1, lex_score))最终得分是SnowNLP和词典规则的融合我取了0.6的SnowNLP权重加0.4的词典权重final_score 0.6 * snownlp_score 0.4 * lex_score大于0.6算正向小于0.4算负向中间算中性。融合之后“真服了”这类句子总算被掰回了负向区间。整个修正过程花了大概一个小时收益率很高。4.4 人工抽检200条确定模型可信度模型调整完不能直接拿结果出去说事。我随机抽了200条评论自己一条一条看人工打标签再和模型结果做对比。最终准确率约78%正负向判得比较准主要集中在“中性”的界定上——很多评论本来就是吐槽夹杂着鼓励人工智能分不清楚人其实也拿不准。78%的准确率对粗粒度舆论分析够用了。我不会拿它去发论文但我敢拿它来回答“网友情绪到底偏正还是偏负”这个问题。5. 可视化结果里的三个发现以及那个“没想到”5.1 情绪总盘负面确实最多但不是全部把1.4万条有效数据的情绪分布画成饼图后第一版结果是这样的负向评论56%正向评论21%中性评论23%负面占多数和我的预期一致。0比4的比分摆在那里指望舆论一片叫好不现实。但让我注意的是负面并没有我刷手机时感觉的那么压倒性——信息流里的骂声显得很多是因为它们点赞高、回复多、被算法反复推送到眼前。真实评论海里超过四成的声音其实并非纯发泄。5.2 词云里的高频词骂得最响的是一小撮我对清洗后的文本做了词云。去掉了“日本”“中国”“比赛”这类无意义高频词之后冒出来的关键词很有层次。最显眼的是“防守”这个词出现在大量战术分析评论里比如“防守站位太松”“中场对后卫的保护不够”。第二个梯队是“未来可期”“年轻”“还有机会”典型的鼓励派用词。第三梯队才是“解散”“丢人”这类纯情绪词。词云不会骗人。骂声确实存在但不是评论广场的唯一主题。很多人还是愿意看比赛内容本身只是这类中性的、理性的表达没有骂声那种传播力。5.3 情绪时间线从崩溃到冷静只需要三个小时把时间切成半小时一个桶我画了一张折线图。比赛刚结束时负向情绪占比冲到70%以上刷屏的几乎全是发泄。但大约一个半小时后负向比例开始明显下降正向和中性讨论追了上来。到赛后第四个小时负向已经回落到50%上下关于“未来还有戏”的讨论占比明显增加。这个曲线很有意思。它说明大部分网友的情绪爆发是短期的时间一过人还是会回到相对理性的状态。所以我一直觉得比赛刚结束那半小时看到的“全网骂声”并不是全网只是全网最激动的那一批。5.4 让我反复确认的意外按点赞加权后理性声音反超了这是整晚最让我意外的结果。把每条评论的点赞数作为权重重新统计情绪占比事情彻底反转了按评论条数统计负向56%。按点赞数加权统计正向中性合计超过了55%。也就是说虽然发“发泄型评论”的人更多但被网友真正认可、真正点赞顶上去的其实还是理性分析和鼓励类的内容。骂声只是一阵风点赞才是人心的投票。这个结论让我头皮发麻。我刷手机时以为所有人都很愤怒结果数据的真实回答是大多数人还是保持着冷静并且他们更愿意认同那些认真讨论比赛的人。算法没有骗我但它只给我看了声量最大、最刺激的那部分。舆论场上的“多数”和“音量”从来不是一回事。5.5 平台差异微博最直接B站更爱玩梗分平台拆开看情绪结构差异非常大。微博评论负向占比最高评论区充斥着大量短句发泄这和微博广场式的传播机制有关人人可以冲上去吼一句。B站弹幕里的负向占比低很多但充斥着“就这”“寄”这类玩梗中性表达很难被情感模型精确识别很多被我归到了中性。懂球帝的赛后讨论则是另一番气象战术分析帖居多虽然也批评球员但几乎每一条都在说具体问题不是情绪攻击。这提醒我不同平台的人群气质完全不同跨平台汇总得出的“网友情绪”本质是一个混合口味如果只看单一平台你对整个舆论的判断就可能跑偏。6. 复盘限频、乱码、IP封禁以及数据背后的分寸6.1 这次踩过的四个典型坑爬虫项目总是看着简单跑起来全在踩坑。我这次遇到的几个问题列出来给各位做个参考微博翻页参数不是page而是max_id用错会死循环在同一批数据上。微博跑到大概两千条时接口开始返回风控错误码。解决方式是加随机睡眠把请求间隔从1秒拉到3秒再随机更换移动端UA。B站弹幕XML文件默认不是UTF-8读取时忘记指定编码解析出来一堆乱码。记住用resp.apparent_encoding或直接声明encodingutf-8。CSV文件用Excel打开乱码。写入CSV时参数必须带上encodingutf-8-sig只写utf-8的话Excel用自己的编码解读中文全乱。这些坑没有一个是原理层面的全是实践细节。但只要踩中一个就能让你在凌晨三点抓耳挠腮。6.2 结果的可信度边界先承认样本有偏差我不太喜欢把个人小项目的结果包装成“全网结论”。在这个项目里样本只有约1.4万条来源集中在微博和B站时间窗只有赛后四小时所有原声都限定在这场比赛的公共讨论里。这些限制决定了结论只能代表“这几个平台、这个时间段、这批活跃用户”的情绪状态。但它依然比刷手机可靠得多。因为刷手机看到的不是样本而是算法筛选后的“高互动切片”。爬虫抓下来的是原始样本即使有偏差偏差也来自平台用户结构而不是推荐机制。只要在写结论时把边界说清楚这个分析就是有信息量的。6.3 最后说一点和数据无关的分寸感项目跑完我把结果发给朋友看朋友第一反应是你写这篇东西会不会被当成支持哪一方我觉得不会。我只是想做一个不吵架的旁观者用数据把舆论场切开来看一眼。但我也不想把这次分析变成某种“打脸”工具。球员是二十岁出头的年轻人输了决赛比谁都难受。做数据分析的人更要有分寸感分析情绪分布不等于给情绪发泄网开一面指出理性声音占多数也不等于替失败开脱。那天晚上之后我重新把微博和B站的评论区翻了一遍心态完全变了。骂声还在但我开始明白它们只是舆论水面上的泡沫水面之下大多数人还是清醒的。如果你也想做类似的项目我的建议是大胆抓仔细洗模型别图贵结论别图大。先把一套流程跑通再慢慢扩展数据源和分析维度。数据不会替你做决定但它能让你看到一个更真实的世界——至少比手机屏幕里那个世界真实一点。