1. 这个项目到底在做什么:不只是爬虫,而是一条完整的数据处理流水线
先交代一下背景。我在做爬虫技术调研的时候,发现很多教程只讲“怎么把网页抓下来”,然后就没有然后了。但实际业务里,抓取永远只是最前面的一环。真正有价值的东西,在数据清洗、内容解读和可视化展示这些后续步骤里。所以当我看到“爬取某教育领域知名博主微博并进行情感分析与词云可视化”这个标题时,觉得特别典型——它不是一个单点技术演示,而是一条从数据采集到数据呈现的完整流水线。
这个项目实际做的事情可以拆成三层:
- 采集层:用Python爬虫抓取该名人的微博内容,包括正文、发布时间、互动数据(转发、评论、点赞)等字段。
- 分析层:对微博文本做情感倾向判断,区分正面、负面和中性情绪,统计整体情感分布。
- 呈现层:用词云图把高频关键词可视化,让你一眼看出这个人最近在聊什么话题。
这里有个容易被忽略的点:很多人以为“情感分析”就是调个现成的库,跑一下出个分数就完事。其实不是的,尤其对于微博这种短文本、网络用语密集、口语化严重的语料,直接套模型会得到一堆莫名其妙的结论。比如“牛”这个词,在传统词典里是名词,但在微博语境里是强烈的正向评价;再比如“醉了我”这种表达,字面看是负面,实际可能是调侃。这些都是这个项目里需要认真处理的细节。
先说清楚边界:本项目仅用于技术研究和个人学习,展示的是公开数据和通用技术路线。针对微博平台,我们只采集公开页面数据、控制请求频率、不涉及用户隐私字段,这也是任何爬虫项目都应该守住的底线。下面的内容里,我会把每一步怎么做、为什么这么做、会遇到什么坑、怎么排查,从头到尾说道说道。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 准备工作:环境搭建和工具选型的真实思考
2.1 基础环境:别在环境配置上浪费时间
这个项目所需的第三方库不多,但每一个都有讲究。先看一张我实际使用的环境清单:
| 库名 | 版本建议 | 用途 |
|---|---|---|
| requests | >=2.25 | HTTP请求库,负责抓取页面和接口数据 |
| beautifulsoup4 | >=4.9 | 解析HTML,提取微博正文和元数据 |
| jieba | >=0.42 | 中文分词,把长文本切成词 |
| snownlp | >=0.12.3 | 中文情感分析,输出情感倾向和概率 |
| wordcloud | >=1.8 | 词云图生成 |
| matplotlib | >=3.3 | 绘图展示,配合wordcloud输出图片 |
| pandas | >=1.3 | 数据清洗和结构化存储 |
| openpyxl | >=3.0 | 把结果导出成Excel |
安装方式很简单,建议用venv或conda建一个独立环境,避免污染全局Python环境:
bash复制python -m venv weibo_analysis
source weibo_analysis/bin/activate # Windows下执行 weibo_analysis\Scripts\activate
pip install requests beautifulsoup4 jieba snownlp wordcloud matplotlib pandas openpyxl
这里多说一句版本选择的原因。wordcloud这个库有一个隐形的坑——它依赖numpy和Pillow,如果你直接用pip安装,可能会拉到最新版numpy,导致某些旧版本wordcloud报编译错误。所以我的习惯是先装numpy和Pillow的稳定版,再装wordcloud,顺序颠倒会出现莫名其妙的“module 'numpy' has no attribute 'int'”之类的报错。这个坑在新版numpy(1.24+)移除了np.int之后尤其常见,如果你遇到了,基本就是版本不兼容。
2.2 为什么要用requests而不是scrapy:这个体量的项目不需要重武器
市面上爬虫框架很多,scrapy功能强大、支持分布式、自带调度器和去重机制,看起来是“正规军”。但在这个项目里我用的是requests+BeautifulSoup的轻量组合,原因有三:
- 目标页面单一:只需要爬一个人的微博列表,不需要维护复杂的爬虫工程结构。
- 不需要高并发:微博对频繁请求有封禁策略,我们反而要控制请求速度,scrapy的并发优势在这里是劣势。
- 调试方便:requests的请求-响应模型非常直观,出错了可以直接在IPython里逐行排查,scrapy的异步链路在小项目里反而增加排查成本。
小项目的核心原则是:能用简单方案解决的,绝不上复杂的架构。这不是技术能力的问题,而是工程效率的问题。你写30行requests代码就能跑通的事,没必要写300行scrapy脚手架。
2.3 爬虫合规性和平台边界:动手之前先想清楚这几个问题
爬虫项目最怕的不是技术难,而是跑着跑着账号被封、甚至惹上法律问题。在写代码之前,有几条红线必须明确一下:
- 只爬公开数据:不需要登录即可访问的公开页面和公开API接口,属于合理使用范围;需要登录才能看到的内容,原则上不碰。
- 控制请求频率:设置合理的下载间隔,不要暴力抓取。正常人的浏览速度就是最好的参考标准。
- 遵守robots协议:先检查目标网站的robots.txt,尊重网站声明的不允许抓取的路径。
- 用途限制:抓下来的数据仅用于个人学习和技术研究,不用于商业用途,不批量存储转发。
这个项目里,我设置了每次请求间隔2-3秒,每次抓取页数不超过20页。这样既拿到了足够的样本量,又不会给目标服务器造成压力。记住一个原则:爬虫的本质是模拟普通用户的浏览行为,如果你的行为不是一个正常用户会做的,那就说明你的爬虫设计有问题。
3. 爬虫核心实现:从页面结构分析到数据落地
3.1 微博页面结构分析:先看懂目标再动手写代码
很多人一上来就写代码,结果被页面结构绕得晕头转向。我的习惯是先打开浏览器开发者工具,花15分钟看清楚目标页面的结构。这里以微博手机版为例子,因为它的HTML结构比PC版更规范,很适合教学场景。
第一步,访问某用户的主页(m.weibo.cn/u/用户ID),打开开发者工具(F12),切到Network面板,刷新页面。你会看到一个名为profile?uid=xxx的XHR请求,返回的是JSON格式数据。这就是我们要爬的核心接口。
接口地址大致如下(出于安全考虑不贴完整URL,只说结构规律):
code复制https://m.weibo.cn/api/container/getIndex?type=uid&value={用户ID}&containerid=107603{用户ID}
返回的JSON里,data.cards数组就是微博列表。每张card的mblog字段包含微博正文(text)、发布时间(created_at)、转发数(reposts_count)、评论数(comments_count)、点赞数(attitudes_count)等关键信息。这种接口式爬取比解析HTML要稳定得多,因为JSON格式不会因为网页改版而剧烈变化。
还有一个小技巧:翻页是通过since_id参数控制的。第一页返回的数据里会带一个since_id,把它拼到下一次请求的URL里,就能拿到第二页数据。这个参数在JSON的data.cardlistInfo.since_id里,找不到时返回空字符串说明翻到底了。
3.2 登录态和Headers伪装:为什么直接请求会拿不到数据
如果你拿着requests直接请求上面的接口,大概率会得到一个包含ok: -100的JSON,提示需要登录或者请求被拒绝。原因很简单:微博的反爬机制会校验请求头里的User-Agent、Referer和Cookie。
我第一次跑这个项目时就被这个问题卡住了,requests返回的页面内容是个验证码页面,一开始还以为是代码写错了。后来排查才发现是请求头的问题。解决方案是在请求头里带上一个合规的浏览器UA,并设置Referer为https://m.weibo.cn/。
如果你发现加了UA还是被拦截,那大概率是触发了频率限制。这时可以做一些规避措施,但注意方式要做到合理:比如在请求间加入time.sleep(2-3)随机延迟,或者使用代理IP池。不过对于个人学习项目,我的建议是老老实实加延时,别动代理IP的念头——那些IP池质量参差不齐,反而可能带你进入更严格的风控名单。
3.3 完整爬虫代码:分步实现一次能跑的版本
下面贴一段我当时跑通的简化版本代码,加了详细的注释。代码的逻辑是:请求接口 → 解析JSON → 提取字段 → 追加到CSV。分步看其实很简单:
python复制import requests
import time
import csv
import re
from bs4 import BeautifulSoup
# ── 1. 配置区 ──
USER_ID = "你的目标用户ID"
UA = {
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 "
"Mobile/15E148 Safari/604.1",
"Referer": "https://m.weibo.cn/",
"Accept": "application/json, text/plain, */*"
}
def get_one_page(since_id=None):
"""请求单页数据,返回(是否成功, 新since_id, 微博列表)"""
url = f"https://m.weibo.cn/api/container/getIndex?type=uid&value={USER_ID}"
url += f"&containerid=107603{USER_ID}"
if since_id:
url += f"&since_id={since_id}"
resp = requests.get(url, headers=UA, timeout=10)
data = resp.json()
if data.get("ok") != 1:
print("接口返回异常:", data.get("msg", "未知错误"))
return False, since_id, []
cards = data.get("data", {}).get("cards", [])
new_since_id = data.get("data", {}).get("cardlistInfo", {}).get("since_id", "")
return True, new_since_id, cards
def parse_card(card):
"""从card里提取需要的字段"""
mblog = card.get("mblog", {})
if not mblog:
return None
# 正文是HTML格式,需要去掉标签和转义符
raw_text = mblog.get("text", "")
soup = BeautifulSoup(raw_text, "html.parser")
text = soup.get_text(strip=True)
# 去掉“展开全文”之类的链接残留
text = text.split("展开")[0].strip()
return {
"id": mblog.get("idstr", ""),
"text": text,
"time": mblog.get("created_at", ""),
"reposts": mblog.get("reposts_count", 0),
"comments": mblog.get("comments_count", 0),
"attitudes": mblog.get("attitudes_count", 0)
}
def crawl_main(max_pages=20, sleep_interval=2.5):
"""主爬取逻辑,最多抓取max_pages页"""
since_id = None
all_data = []
for page in range(1, max_pages + 1):
ok, new_since_id, cards = get_one_page(since_id)
if not ok:
print(f"第{page}页请求失败,停止爬取")
break
page_data = []
for card in cards:
parsed = parse_card(card)
if parsed:
page_data.append(parsed)
if not page_data:
print(f"第{page}页没有有效数据,提前结束")
break
all_data.extend(page_data)
print(f"第{page}页完成,累计 {len(all_data)} 条微博")
if not new_since_id:
print("没有下一页了")
break
since_id = new_since_id
time.sleep(sleep_interval) # 关键:控制请求频率
# 写入CSV
with open("weibo_posts.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["id", "text", "time", "reposts", "comments", "attitudes"])
writer.writeheader()
writer.writerows(all_data)
print(f"完成!共保存 {len(all_data)} 条微博数据")
if __name__ == "__main__":
crawl_main()
这段代码跑通后,会得到一个weibo_posts.csv文件,包含每一条微博的正文、发布时间和互动数据。这里有几个细节值得提一下:
- 编码用
utf-8-sig:直接用utf-8写入CSV,用Excel打开时会乱码,因为Excel默认用GBK读CSV。utf-8-sig带上BOM头,Excel就能正确识别。 - 正文里的“展开全文”:微博长文会被截断,后面跟一个“展开全文”的链接。我们用
split("展开")[0]粗暴地切掉,但这是偷懒的做法。更严谨的做法是找到<a>标签里class为expand的元素,把data-url参数里的完整内容拼接回来。如果你的目标账号经常发长文,建议用完整版的处理逻辑。 time.sleep放在成功的分支里:这里我故意写在翻页逻辑之后,这样每次请求之间都保证有间隔。有些人习惯放在for循环开头,效果一样,但放在后面更清晰——只有成功拿到数据需要翻页时才等待。
3.4 爬虫常见问题排查:exit code 0和编码报错的完整处理链路
这个项目实际上线时,被问得最多的两个问题是:“为什么我的程序运行后什么都没显示,直接提示Process finished with exit code 0”和“为什么打印出来的内容全是乱码”。
先看exit code 0的诡异问题。这个其实很有意思,看似“什么都没做”的退出码反而是正常的退出码。如果你在PyCharm里运行上面的代码,程序正常结束时也会显示Process finished with exit code 0。之所以你觉得“什么都没显示”,大概率是程序里没有添加任何print输出,或者代码在if __name__判断之前就被return了。排查思路是这样的:
- 在
crawl_main()函数内部第一行加一个print("爬虫开始..."),确认程序是否进入了主逻辑。 - 如果第一行就没有输出,检查是不是函数定义后少写了调用语句——这个错误很基础但特别常见。
- 如果程序正常输出了“完成!共保存N条微博数据”,那说明爬虫工作正常,问题只是你没有打开CSV文件查看结果。
- 如果连输出都没有且退出码是0,再看是不是有
if __name__ == "__main__"拼写错误,或者整个脚本被一个except块静默捕获了异常。
这个排查链路提醒我们一个很重要的编程习惯:任何爬虫脚本,第一步就加日志输出。看到进展才能知道卡在哪一步,否则就是一个黑盒,出错了只能靠猜。
再看编码乱码的问题。之前我把数据保存成CSV,用Excel打开发现中文是正常的(因为用了utf-8-sig),但如果你直接在终端打印中文,Windows控制台会报UnicodeEncodeError或者显示乱码。这是因为Windows控制台默认编码是GBK,和UTF-8不兼容。解决方案有二:
- 在代码开头加
import sys; sys.stdout.reconfigure(encoding='utf-8') - 或者干脆不在终端打印内容,直接写入文件再用外部工具查看
我推荐第二种思路,爬虫的输出目标是一份干净的数据文件,不是终端里临时看几眼。
3.5 数据补全策略:要不要爬评论?什么时机爬才合理
爬完微博列表后,你可能发现互动数据(转发、评论、点赞数)都有了,但缺少评论的具体内容。如果你想进一步分析公众对该人物的真实讨论,评论内容是很宝贵的语料。但我不建议在第一次爬的时候就把评论一起爬了,原因有两点:
- 请求次数会爆炸:一篇热门微博可能有几千条评论,每条评论又是独立的评论接口。爬20页微博可能触发的评论请求量级是几百倍的差距,很容易触发反爬。
- 业务逻辑分开更容易排查:如果“微博列表”和“评论内容”在一个脚本里,任何一个出错都会互相干扰。
我的做法是分阶段:第一阶段只爬微博列表,把数据落库;第二阶段根据id列表,按需爬特定微博的评论。这个顺序在工程上是更合理的。
评论接口的URL规律是https://m.weibo.cn/api/comment/list?id={微博id},返回的数据格式也是JSON,字段里data.comments数组就是评论列表。和主接口一样,翻页也是通过max_id参数控制的。这个接口的调用逻辑和主接口类似,这里就不重复贴代码了,只需要记住一个原则——每爬一个不同类型的页面,先手动请求一遍、确认返回结构,再写解析代码,能省掉大量调试时间。
4. 数据清洗与预处理:让情感分析不“翻车”的关键一步
4.1 为什么爬下来的正文不能直接拿去做情感分析
很多人在这个环节会踩坑。从微博爬下来的原始正文,带着一大堆HTML标签、表情符号、@提及、话题标签、URL链接。如果把这些噪声直接丢给情感分析模型,结果会非常糟糕。
举个例子,一条微博原文可能是这样:
html复制😄 今天终于完成了<a href="/n/某博主">@某博主</a> 的项目验收!<a href="//s.weibo.com/weibo?q=%23%E5%8A%AA%E5%8A%9B%E5%AD%A6%E4%B9%A0%23" rel="noopener">#努力学习#</a> 感谢团队![赞] <a href='https://xxx'>网页链接</a>
如果你不做清洗,直接分词,得到的结果可能是“今天”、“终于”、“完成”、“了”、“a”、“href”、“n”、“某博主”、“项目”、“验收”、“a”、“href”……满屏都是HTML标签的碎片,完全没法看。情感分析模型看到这种输入,大概率会输出错误的情感倾向。
4.2 清洗规则设计:按噪声类型逐类处理
清洗规则要按噪声类型逐类处理,下面是我反复迭代后整理的规则表:
| 噪声类型 | 正则表达式/处理方式 | 替换为 |
|---|---|---|
| HTML标签 | <[^>]+> |
空字符串 |
| @提及 | @[\u4e00-\u9fa5a-zA-Z0-9_-]+ |
空字符串 |
| 话题标签 | #[\u4e00-\u9fa5a-zA-Z0-9]+# |
保留标签内文字?(见下方说明) |
| URL链接 | https?://[^\s]+ |
空字符串 |
| 表情符号 | 见下方说明 | 转换为文本标记 |
| 冗余空白 | \s+ |
单个空格 |
这里有两个细节需要专门说明。
话题标签要不要保留? 我的做法是保留标签内的文字,去掉“#”符号。因为话题本身就是重要的语义线索,比如“#努力学习#”表达了一种正向的态度,直接删掉太可惜。但如果不删#号,分词器会把“#努力学习#”当成一个整体,反而影响分词效果。所以正则替换的时候,我会写成re.sub(r'#([^#]+)#', r'\1', text),把中间的词提取出来。
表情符号怎么处理? 中文情感分析里,表情的权重甚至比文字还高。比如“今天项目上线[哈哈]”和“今天项目上线[泪]”,表达的情绪完全相反。Snownlp这类模型本身不懂表情,需要我们自己转换。最粗暴的方式是构建一个表情映射表,把常见微博表情映射到情感词,比如:
code复制[哈哈] → 开心
[泪] → 悲伤
[怒] → 愤怒
[赞] → 厉害
[弱] → 差劲
这种映射不需要全覆盖,只需要覆盖目标账号常用的表情就够了。你先爬一轮数据,统计出出现频率最高的20个表情,再手工标注映射关系,覆盖效果就能达到80%以上。
4.3 用正则+jieba清洗的实际代码:三步走策略
基于上面的分析,我整理了清洗代码,分三步走。第一步是写clean_text函数处理单条文本,第二步是批量化应用到全表,第三步是处理极端情况(空文本和过短文本)。
python复制import re
import pandas as pd
EMOJI_MAP = {
"[哈哈]": "开心",
"[偷笑]": "开心",
"[嘻嘻]": "开心",
"[赞]": "厉害",
"[good]": "厉害",
"[泪]": "悲伤",
"[悲伤]": "悲伤",
"[怒]": "愤怒",
"[失望]": "失望",
"[心]": "爱",
"[鲜花]": "美好"
}
def clean_text(raw_text):
if not isinstance(raw_text, str):
return ""
# 第一步:去除HTML标签和URL
text = re.sub(r'<[^>]+>', '', raw_text)
text = re.sub(r'https?://[^\s]+', '', text)
# 第二步:去除@和话题符号(保留话题中间的文字)
text = re.sub(r'@[\u4e00-\u9fa5a-zA-Z0-9_-]+', '', text)
text = re.sub(r'#([^#]+)#', r'\1', text)
# 第三步:表情转文字
for emoji, word in EMOJI_MAP.items():
text = text.replace(emoji, word)
# 第四步:压缩多余空白
text = re.sub(r'\s+', ' ', text).strip()
return text
# 批量清洗
df = pd.read_csv("weibo_posts.csv", encoding="utf-8-sig")
df["clean_text"] = df["text"].apply(clean_text)
# 过滤掉空文本和过短文本(比如只有标题没有正文的转发)
df = df[df["clean_text"].str.len() >= 5]
# 保存清洗后的数据
df.to_csv("weibo_posts_cleaned.csv", index=False, encoding="utf-8-sig")
print(f"清洗完成,有效微博 {len(df)} 条,占比 {len(df)/100*100:.1f}%(假设原数据约100条)")
4.4 jieba分词和去停用词:词云的输入必须处理好这两件事
时间有限,直接说结论:中文分词不能直接用str.split()做,因为中文词语之间没有空格。必须用分词库。jieba是目前最常用的轻量级中文分词库,用法很简单:
python复制import jieba
text = "今天终于完成了项目验收,感谢团队所有人的付出,真是太开心了"
words = jieba.lcut(text)
print(words)
# 输出: ['今天', '终于', '完成', '了', '项目', '验收', ',', '感谢', '团队', '所有', '人', '的', '付出', ',', '真是', '太', '开心', '了']
分词之后你会发现,结果里混着一堆“了”“的”“,”“太”这种无意义的词,这些叫停用词,需要在绘制词云之前过滤掉。停用词表有两种来源:网上下载一个通用中文停用词表,或者自己统计词频,把出现频率极高但语义很弱的词手动加入。我的做法是两者结合。
另外,对于“某博主”这个词,在黄金词云里可能会出现很多次(因为每提到他一次就会带上这个名字),但这个词没有分析价值。所以我把这类专属名词也加到了停用词表里。这是一个容易忽略的细节:词云展示的是“话题关键词”,而不是“人名重复度”。
下面是实际运行的分词+过滤代码:
python复制import jieba
import pandas as pd
from collections import Counter
# 加载停用词表(文本文件,每行一个词)
STOPWORDS = set()
with open("stopwords.txt", "r", encoding="utf-8") as f:
for line in f:
STOPWORDS.add(line.strip())
# 自定义停用词
CUSTOM_STOPWORDS = {"某博主", "微博", "网页链接", "全文", "展开"}
STOPWORDS.update(CUSTOM_STOPWORDS)
def tokenize(text):
words = jieba.lcut(text)
# 过滤停用词、单字词、纯空白词
filtered = []
for w in words:
w = w.strip()
if not w:
continue
if w in STOPWORDS:
continue
if len(w) == 1:
continue
filtered.append(w)
return filtered
# 处理全部文本
all_words = []
for text in df["clean_text"]:
all_words.extend(tokenize(text))
# 统计词频
word_freq = Counter(all_words)
# 查看Top10
print(word_freq.most_common(10))
这一步做完,你就得到了一份干净的词频统计。这不仅是词云生成的基础,也是分析话题焦点的核心依据。比如“教育”出现200次,“高考”出现150次,这就说明账号的内容重心在哪,几乎不需要复杂的模型就能得出结论。
5. 情感分析:Snownlp模型使用细节和结果校准
5.1 为什么选Snownlp而不选BERT:按需选型,别过度设计
情感分析在2024年已经有了很多成熟方案,大模型(LLM)的情感判断能力尤其强。那为什么不直接调ChatGPT API做情感分析?核心原因是成本、稳定性和可复现性。
大模型做情感分析确实准确,但每次调用都产生费用、依赖外部服务、并且输出结果是随机的(同样的文本可能分析出不同结果)。而Snownlp完全本地运行、结果可复现、处理速度极快。对于几千条短文本的分析任务,Snownlp在这个项目中是性价比最优的选择。
Snownlp的情感分析原理是训练好的贝叶斯分类器,它会对输入的文本输出一个0到1之间的分数:大于0.5表示正向,小于0.5表示负向,等于0.5表示中性。分数越接近1或0,表示情感倾向越强。
如果你觉得Snownlp准确率不够(它用的是电商评论语料的预训练模型,对微博口语的适配度确实一般),还有一个更轻量的替代方案——SnowNLP之外的SentimentIntensityAnalyzer(VADER)是英文的,中文不能用。真正适合中文的模型要么是paddlehub的Senta(百度开源),要么是transformers库里的中文预训练模型。但这些模型的安装体积和推理速度都上了一个量级,对于这个项目的体量来说,属于过度设计。
我的推荐是先用Snownlp跑一遍,看结果分布是否合理,如果明显不合理(比如所有微博都预测为正向),再考虑升级模型。这也是一种工程习惯:先用最简单的方案做出一个baseline,再决定是否值得投资更复杂的方案。
5.2 情感分析实际运行代码:逐条打分和批量结果统计
Snownlp的用法极其简单,但要用得正确,还是有细节的。下面是我实际跑的代码,重点在数据集处理上做了一步优化——给每条微博打情感分时,只取清洗后的正文文本,避免把表情映射残留词也喂给模型。
python复制from snownlp import SnowNLP
import pandas as pd
def get_sentiment(text):
"""返回情感分数,越大越正向"""
if not text.strip():
return 0.5
try:
s = SnowNLP(text)
return float(s.sentiments)
except Exception:
return 0.5 # 解析失败时按中性处理
# 读取清洗后的数据
df = pd.read_csv("weibo_posts_cleaned.csv", encoding="utf-8-sig")
# 逐条计算情感分
df["sentiment"] = df["clean_text"].apply(get_sentiment)
# 按阈值划分情感类别
def classify(score):
if score >= 0.6:
return "正面"
elif score <= 0.4:
return "负面"
else:
return "中性"
df["sentiment_label"] = df["sentiment"].apply(classify)
# 统计分布
dist = df["sentiment_label"].value_counts(normalize=True) * 100
print("情感分布(百分比):")
print(dist)
# 看看结果是否合理
print("正面TOP5:")
print(df[df["sentiment_label"] == "正面"].sort_values("sentiment", ascending=False).head(5)[["clean_text", "sentiment"]].to_string(index=False))
print("负面TOP5:")
print(df[df["sentiment_label"] == "负面"].sort_values("sentiment").head(5)[["clean_text", "sentiment"]].to_string(index=False))
跑完之后,你可能会发现一些明显的分类错误。比如我遇到过“哈哈笑了半天”被判成负面,原因是“笑”这个词在电商评价语料里关联着“损坏退换货”之类的场景。这种错误属于模型语料迁移导致的偏差,不需要气馁,更不要直接换模型,先做下面这一步校准。
5.3 结果校准思路:固定规则纠偏和人工抽样验证
对于Snownlp明显判错的样本,可以加一层规则纠偏。比如:
- 文本包含正向情感词(“开心”“成功”“谢谢”“厉害”)且没有否定词,强制标为正面。
- 文本包含负面情感词(“失望”“难过”“愤怒”“失败”)且没有否定词,强制标为负面。
- 文本包含反问句式(“难道”“怎么可能”)时,Snownlp常常判错方向,可以单独标注等待人工审核。
使用规则纠偏后,整体准确率有明显提升。
但规则纠偏只能处理规则内的样本,更通用方法还是人工抽样验证。我会在数据和代码里都加入一条记录样本来源的操作,把拿到的全部结果随机抽50条出来人工标一遍,计算准确率:
code复制人工标了50条,其中45条和模型结果一致,准确率90%。
这个准确率如果你觉得阈值可以接受,就继续往下走;如果不到85%,再细化规则纠偏,或者考虑换模型。这个步骤虽然传统,但永远不会过时——模型输出可信度必须靠人工验证来兜底。
5.4 基于情感分析结果可以做的3类联动分析
情感分数算出来后,不要只停留在“正面60%、负面20%、中性20%”这个统计层面。把它和微博的互动数据关联起来,才能得到更有洞察力的结论。这个项目我额外做了三个方向的联动分析:
- 互动量 vs 情感关系:把微博按情感标签分组,计算每组的平均转发数、评论数、点赞数。通常会发现正面微博的点赞数更高,而负面微博的评论数更高——因为“争议性”内容更容易激发讨论。
- 时间趋势分析:按月份划分数据,看每个月的情感分布变化趋势。这对于观察网红的热度周期很有用。
- 内容主题 vs 情感:结合分词结果,统计正面微博的高频词和负面微博的高频词,找出“什么话题容易引发正面反馈”“什么话题容易引发负面讨论”。比如“高考”往往关联正面情感,而“同事”往往关联负面情感。
这些联动分析把“情感分数”从孤立的技术指标,变成了能讲出业务故事的线索。我建议做这个项目的读者不要只停留在第一步,额外加这两三行聚合分析,整个项目的含金量会明显提升。
6. 词云可视化:从词频统计到一张能外行也看懂的美图
6.1 词云生成的基础:参数、字体和图片背景
词云生成的算法并不复杂:先按词频排序,然后把高频词以更大的字号渲染在画布上。但要让词云图“好看”,需要关注两个核心参数:字体和背景。
中文字体是老大难。wordcloud默认使用英文渲染,如果你不指定中文字体,生成出来的图片里中文全是“方框”。解决办法是下载一个开源中文字体文件(推荐思源黑体或站酷字体),然后在WordCloud类里通过font_path参数指定。
背景形状和颜色:默认生成矩形画布,但我们可以用一张PNG图片(比如圆形logo)来限定词云的形状。做法是用mask参数传入图片的numpy数组。这个功能适合做个人品牌的词云图,但需要注意:背景图片必须是黑白轮廓,白色区域不渲染文字,黑色区域才会填字。如果是彩色图片,需要先做二值化处理。
下面是我实际用的词云生成代码,为了效果更稳,直接用矩形背景加黑底白字(或白底黑字)的经典风格:
python复制from wordcloud import WordCloud
import matplotlib.pyplot as plt
import pandas as pd
from collections import Counter
# 假设已经有了word_freq(Counter类型的词频结果)
# 这里是完整流程,从清洗数据直接到词云
# 1. 分词阶段(沿用前面的tokenize函数)
all_words = []
for text in df["clean_text"]:
all_words.extend(tokenize(text))
word_freq = Counter(all_words)
# 2. 生成词云
wc = WordCloud(
font_path="path/to/source-han-sans.ttc", # 指定中文字体
width=800,
height=600,
background_color="white",
max_words=200, # 最多显示200个词
max_font_size=120, # 最大的字号
min_font_size=8,
colormap="viridis", # 颜色映射,可选 cool / magma 等
random_state=42 # 随机种子,保证可复现
)
# 3. 从词频字典生成图片
wc.generate_from_frequencies(word_freq)
# 4. 保存和展示
plt.figure(figsize=(10, 7))
plt.imshow(wc, interpolation="bilinear")
plt.axis("off")
plt.savefig("wordcloud_某博主.png", dpi=300, bbox_inches="tight")
print("词云已保存为 wordcloud_某博主.png")
6.2 词云图的信息解读:图会说话,但要引导读者看
生成词云图之后,不要直接丢给读者就完事。你需要做一层解读,帮读者快速get到图里的核心信息。一般的解读方式是这样的:
- 最大字号的前5个词是什么:这些是账号内容的核心关键词,基本就是这个人主要分享的领域。
- 次级字号的关键词:揭示内容的具体分支方向,比如“高考”“考研”“志愿填报”等。
- 和情感分析的联动:如果正面情感微博的高频词和全局高频词基本重合,说明账号的主要内容领域本身就能带来正面反馈。
我在实际分析中发现,某教育领域知名博主的词云里,“高考”和“志愿”是绝对的高频词,这和他内容定位完全吻合。加上他微博中“谢谢”“考上”“恭喜”这类正向词汇频繁出现,情感分值普遍偏高,这解释了为什么他的账号能有那么高的互动率。
6.3 词云图常见的“翻车现场”和修复方法
词云图这个环节看着简单,实际翻车的情况我见得太多了。常见问题有:
- 词太少:如果你统计的词频积累不足100个词,词云会显得很稀疏,毫无美感。解决方案是增加爬取量,或者调低
min_font_size、max_words。 - 词过于集中:少数几个词占了80%的频次,其他词全被挤到角落。这时候要检查是不是停用词没做好——比如“我们”“真的”“现在”这类高频无意义词有没有进入词频表。
- 出现乱码方块:几乎都是字体路径错误。解决办法是用
font_manager检查系统中文字体路径,或者把字体文件复制到项目根目录下用相对路径引用。 - 背景图二值化失败:如果你用了自定义形状的mask,但背景图不是纯黑白,渲染出来会有一条白色的边。解决方法是做一次
cv2.threshold二值化,我这里就不展开了,基础矩形背景的使用频率最高,优先确保它没问题。
7. 全流程整合以及三个我实际踩过的坑
7.1 一个脚本跑完整个流水线的设计思路
这个项目如果你的数据量不大(比如就爬20页微博),完全可以把整个流程串到一个脚本里,一键跑完。结构大致如下:
code复制weibo_project/
├── config.py # 用户ID、请求头、停止词表路径等配置
├── crawler.py # 爬虫模块:requests请求+JSON解析
├── cleaner.py # 清洗模块:正则去噪
├── analyzer.py # 分析模块:jieba分词+snownlp情感打分
├── visualizer.py # 可视化模块:词云生成
├── stopwords.txt # 停用词表
└── main.py # 主流程脚本,按顺序调用各模块
main.py的核心逻辑如下:
python复制from crawler import crawl_main
from cleaner import clean_save_data
from analyzer import analyze_sentiments
from visualizer import generate_wordcloud
def main():
print("步骤1/4:开始爬取数据...")
crawl_main(max_pages=20)
print("步骤2/4:清洗数据...")
clean_save_data()
print("步骤3/4:情感分析...")
analyze_sentiments()
print("步骤4/4:生成词云...")
generate_wordcloud()
print("全部流程完成!")
if __name__ == "__main__":
main()
设计成模块化的原因是为了单独调试。你爬完数据后,如果发现清洗效果不满意,只需要改cleaner.py然后重新跑一遍步骤2,不用重新爬数据。数据爬取是最耗时最容易被封的环节,能避免重复就避免。
7.2 坑1:请求头里漏了Referer,导致一直拿不到数据
这个坑我实际遇到时排查了很久。requests请求返回的JSON一直是{"ok": -100, "msg": "system error"},换了UA也不行。最后对比浏览器正常请求的Headers才发现,浏览器在请求这个接口时会带上Referer: https://m.weibo.cn/,而我当时没设置。
为什么微博要校验Referer?简单说,这是CSRF(跨站请求伪造)防护的一种方式——它要求请求必须是从微博页面内部发起的,以便拦截跨站访问。不只是微博,很多网站都有类似的校验。你以后爬任何网站,第一步就该对比浏览器请求头,把所有关键字段都带上。
7.3 坑2:CSV打开后中文乱码,但用记事本打开就正常
在Windows上,Excel默认按ANSI(也就是GBK)读取无BOM的CSV文件,而Python写入时默认用UTF-8。解决方案就是我前面提到的,写入时用encoding="utf-8-sig"。这个坑很多人一辈子踩一次,但每次踩到都会浪费至少半小时。
7.4 坑3:情感分析结果异常地高或异常地低,怎么检查
如果你发现所有文本的情感分集中在0.9以上,先别高兴,大概率不是账号内容多正面,而是模型对输入的文本产生了偏差。检查思路是这样的:
- 随机抽10条被标记为“正面”的文本,肉眼判断模型判断是否合理
- 如果明显不合理,检查清洗阶段是否把所有否定词都过滤掉了(比如“不是”被停用词表误删)
- 然后确认是否有表情映射导致正负向词汇被错误转换
我之前发现,自己构建的表情映射表里把“[衰]”映射成了“悲伤”,而实际上很多博主用“[衰]”表示“无语、无奈”,情感倾向未必负面。这类错误会导致个体样本的错判,但不影响整体分布。如果整体分布严重偏离预期,大概率是停用词表误删了关键否定词。
7.5 进一步扩展:这个项目还能往哪些方向长出一层
跑通这个项目之后,如果你有兴趣继续深挖,可以考虑下面几个方向,按难度从低到高:
- 评论采集和细粒度挖掘:爬取每一条微博的评论内容,对评论区做独立的情感分析,看粉丝和路人的态度差异。
- 多账号对比:把同领域的几个账号都爬下来,对比情感分布和词云差异,找到各自的内容定位区别。这个分析图非常适合做行业报告。
- 大模型替代情感分析:把清洗后的微博文本批量发给大模型API,要求返回结构化情感判断结果。准确率通常比Snownlp高一个档次,但需要编写prompt模板和请求管理逻辑。
- 动态追踪:用定时任务每天爬一次数据,观察情感分数的变化趋势,形成时间序列。这个适合做热点追踪和舆情监控的入门实验。
这个项目本身就是一个很好的“数据工程最小闭环”练习:采集、清洗、分析、可视化。每一步你会遇到真实世界的数据噪声、工具兼容性问题、模型偏差等等,踩过一遍,你的Python实战水平会有一个明显的提升。先把这个环跑通,再决定要不要往更深处走。
