前两天帮朋友处理了一个小需求:把一部电影在豆瓣上的最新短评爬下来,生成一张词云图看看观众都在聊什么。当时顺手写了个Python脚本,从requests爬评论,到jieba分词,再到wordcloud生成词云,前后加起来不到两百行。朋友看完直呼“就这么简单?”,所以今天把这套流程整理成一份完整教程放出来。标题里三个关键词——Python、网络爬虫、词云wordcloud——正好覆盖了从数据采集到可视化输出的整个链路,这也是很多入门者最想一次性打通的一条线。
这篇文章会带着你从零开始,先搭好Python环境和爬虫依赖,再实际爬取豆瓣最新评论,然后把评论内容清洗、分词,最后生成矩形、圆形、心形、白底黑字等各式词云。无论你是刚装好Python还没摸清语法的纯新手,还是写了几个小爬虫想再学点可视化技巧的老手,这篇文章都能直接照着抄作业。
1. 整体设计与思路拆解
1.1 项目目标与完整数据链路
这个项目说白了就是一条数据流水线:从豆瓣短评页面把观众写的最新评论抓下来,切成一个个有意义的词,再按照词频高低把高频词放大、低频词缩小,拼出一张有观赏性的图形。
整条链路分四步。第一步是数据采集,用requests向豆瓣电影短评页面发送HTTP请求,拿回HTML响应;第二步是解析提取,用BeautifulSoup定位页面里的评论节点,把评论文本全部抠出来;第三步是文本加工,调用jieba做中文分词,顺便把“我们”“这个”“真的”这类对主题表达毫无帮助的停用词过滤掉;第四步是可视化,把处理后的词列表交给wordcloud,设置字体、背景、mask蒙版,输出成PNG图片。
之所以要把这四个环节拆开讲,是因为后面排查问题基本都是按环节定位的。比如生成的词云带乱码,那大概率是第四步字体问题;词云里冒出一堆“我们”“你们”,那问题出在第三步停用词没过滤干净;压根没爬到数据,那要回到第一步看请求头或Cookie。脑子里有了这条链路,再往下走就清楚自己在干嘛了。
1.2 为什么选豆瓣评论当练手案例
想练爬虫,第一反应都是去爬豆瓣。不光是它的页面结构规整,更因为短评内容是现成的优质中文文本。
具体来说,豆瓣短评有几点很适合拿来入门。第一,页面是静态的。豆瓣短评的HTML由服务端直接渲染,评论内容就在标签结构里,不需要处理动态加载和XHR接口,对新手友好得多。第二,数据结构规整。每一条短评都被包在class="comment-item"的div里,正文放在span.short,用户和评分有各自的标签,选对CSS选择器之后一抓一个准。第三,评论语言质量高。豆瓣用户写短评往往有个人判断,高频词能直观反映大众对电影的讨论焦点,比如“特效”“剧情”“演技”“泪点”,生成的词云一眼就能看出门道。
当然也要说清楚,豆瓣近两年对未登录用户看短评做了限制,这个后面会给出解决方案。只需要把浏览器里的Cookie带进请求头,就能正常抓取,不影响学习流程。
1.3 核心工具选型解析
项目里要装的Python库有requests、beautifulsoup4、lxml、jieba、wordcloud、matplotlib、numpy、Pillow。下面这张表是我选定它们的原因,也列了备选方案供参考。
| 环节 | 推荐库 | 备选方案 | 选择理由 |
|---|---|---|---|
| 网络请求 | requests | urllib | 语法简洁、自动处理编码、会话复用方便 |
| HTML解析 | beautifulsoup4 + lxml | 正则表达式 | 选择器直观,页面改版后调整成本低 |
| 中文分词 | jieba | snownlp、thulac | 使用简单、支持自定义词典、生态成熟 |
| 词云生成 | wordcloud | stylecloud、pyecharts | 原生支持mask蒙版和自定义字体,可控性强 |
| 图片处理 | Pillow + numpy | OpenCV | 生成和读取mask图片够用,没有额外安装负担 |
很多老教程喜欢用正则从HTML里抠数据,不是说不行,而是页面结构一改你就得跟着改一大堆匹配规则。换成BeautifulSoup用CSS选择器之后,维护成本低得多。词云库这里,wordcloud是社区里积累最久、文档最全的选择,而且支持mask蒙版,这才是后面能生成心形、圆形造型的关键。
2. 环境准备与核心细节解析
2.1 Python环境安装与虚拟环境配置
先把环境捣鼓好。很多入门者不是卡在代码上,而是卡在Python装不上、库装不对。第一步是下载Python安装包。Windows用户去官网下载3.9到3.12之间任意稳定版本,安装时一定记得勾选Add Python to PATH,不然命令行里敲python会没反应。macOS用户用brew install python@3.11就行。
装完Python后我强烈建议建一个虚拟环境,这样这个项目单独装一套库,不会和系统其它项目互相污染。在项目根目录执行:
python -m venv wordcloud_env进入虚拟环境,Windows是wordcloud_env\Scripts\activate,macOS/Linux是source wordcloud_env/bin/activate。接着一次性安装依赖:
pip install requests beautifulsoup4 lxml jieba wordcloud matplotlib numpy pillow如果你用的VS Code,装完后按Ctrl+Shift+P打开命令面板,搜索“Python: Select Interpreter”,把解释器指到刚才建的wordcloud_env路径下,运行代码时才会走到虚拟环境里。这一步我见过太多新手漏掉,结果终端里明明是venv,编辑器却还在用全局Python,导致库永远找不到,代码始终报ModuleNotFoundError。
2.2 发送HTTP请求的关键细节
环境准备好之后,真正写爬虫前,得先把HTTP请求的基础打好。所谓爬虫,本质上就是用程序代替人重复发送HTTP请求并解析响应。豆瓣这类网站做基础反爬,第一个检测项就是User-Agent。如果requests请求头里没有UA,服务器很可能直接拒绝访问或者返回异常页面。所以我习惯在headers里放一个完整的浏览器UA,让请求看起来像Chrome发出的。
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", "Accept-Language": "zh-CN,zh;q=0.9", # "Cookie": "登录豆瓣后复制浏览器里的Cookie到这里" }Accept-Language告诉服务器希望返回中文页面,可以避免拿到默认语言不对的页面。第二是超时与重试。requests不设timeout的话,一旦服务器不响应,脚本会一直挂在那里。给请求加上timeout=10,超时后再捕获异常重试,整个脚本才不会在某个页面卡死。
第三是分页参数。豆瓣短评列表的URL长这样:https://movie.douban.com/subject/{电影ID}/comments,其中srot=new_score表示按最新排序,start=0表示从第0条开始,每页最多返回20条。爬下一页只需要把start变成20、40,这个逻辑不复杂,但新手经常忘记把start写进params里,结果翻来覆去只爬到第一页。
2.3 HTML解析与评论提取
拿到HTML响应之后不要急着用正则匹配,先用BeautifulSoup解析成DOM结构再操作。
豆瓣短评页面的评论文本在class="comment-item"的div下面,定位选择器是.comment-item .short。用select('.comment-item .short')能一次性拿到当前页全部评论节点,然后逐个取.text并strip掉首尾空白。这样做的好处是页面改版时,往往只需要微调选择器,而不需要重写整个解析逻辑。
清洗这一步别偷懒。我从页面抠出来的文本可能带有换行、多个空格,甚至混进一些无关字符。最简单的方式是:
content = node.text.strip().replace("\n", " ")处理完的评论先写进一个列表,等所有页抓完再统一落盘。如果某个页面没有正常返回评论,BeautifulSoup会解析出一个空列表,代码里要判断一下:列表为空就停掉翻页,避免继续空转。
3. 实操过程与核心环节实现
3.1 爬取豆瓣短评数据
先贴完整代码,后面一行行解释关键位置。这个脚本用《流浪地球2》演示,电影ID是35456179。
import time import random import requests from bs4 import BeautifulSoup MOVIE_ID = "35456179" # 流浪地球2,可换成其它电影ID COMMENT_URL = f"https://movie.douban.com/subject/{MOVIE_ID}/comments" 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", "Accept-Language": "zh-CN,zh;q=0.9", # "Cookie": "登录豆瓣后复制浏览器里的Cookie到这里", } def fetch_comments(url, params): try: resp = requests.get(url, headers=headers, params=params, timeout=10) if resp.status_code != 200: print(f"请求失败:{resp.status_code}") return [] soup = BeautifulSoup(resp.text, "lxml") nodes = soup.select(".comment-item .short") return [node.text.strip().replace("\n", " ") for node in nodes] except Exception as e: print(f"请求异常:{e}") return [] all_comments = [] for page in range(5): params = { "sort": "new_score", "status": "P", "start": page * 20, "limit": 20, } comments = fetch_comments(COMMENT_URL, params) if not comments: print("没有拿到评论,停止翻页") break all_comments.extend(comments) print(f"第{page + 1}页抓到{len(comments)}条,累计{len(all_comments)}条") time.sleep(random.uniform(1, 3)) with open("comments.txt", "w", encoding="utf-8") as f: f.write("\n".join(all_comments)) print("爬取结束,评论已保存到 comments.txt")这里有几个关键点。一是params不要手拼到URL里,用params参数传给requests,它会自己处理编码。二是每次翻页start递增20,循环5页能攒下100条评论,对词云来说够了,想多爬就多循环几页。三是最后用sleep做随机延时,不要每秒发一次请求,既是为了自己的IP不被封,也是给豆瓣服务器少添点麻烦。
如果运行后发现页面提示需要登录,或者只返回一页,就在headers里把Cookie那一行补上。打开浏览器登录豆瓣,按F12切到Network面板,随便找一条请求,从请求头里复制Cookie值,粘贴到代码里即可。注意复制的时候别把换行带进去,Cookie就是一行字符串。
提示:Cookie相当于你的登录凭证,千万别随手传到GitHub或者公共代码仓库里,泄露后别人可以直接拿你的账号身份操作。
3.2 分词与停用词过滤
评论爬下来之后是一个个完整的句子,直接扔给wordcloud效果会很差,因为wordcloud默认按空格切分词,中文句子没有空格,它会把一整句当成一个词块。所以要先分词。
import jieba import string with open("comments.txt", "r", encoding="utf-8") as f: text = f.read() stopwords = set("的 了 是 我 你 他 她 它 我们 你们 他们 这个 那个 一个 真的 就是 觉得 但是 电影 很 太 有 在".split()) punctuation = set(string.punctuation + ",。!?、;:“”‘’()《》【】…—") words = jieba.lcut(text) filtered = [ w.strip() for w in words if w.strip() and len(w.strip()) > 1 and w.strip() not in stopwords and w.strip() not in punctuation ]这里我把常见的停用词直接写死在代码里,你也可以从网上下载一份中文停用词表存成stopwords.txt,然后逐行读取。实际效果更推荐把“电影”“觉得”这类评论里的高频废话也放进去,因为它们对分析观众讨论焦点没有贡献。还有一句比较重要的话:过滤条件里len(w.strip()) > 1的意思是去掉单个字,很多时候单个字的信息量不足,但如果你想保留“燃”“爽”这类关键词,可以把这个条件去掉。
分词完成后,可以用Counter看一眼词频分布,验证数据质量:
from collections import Counter counter = Counter(filtered) for word, count in counter.most_common(10): print(word, count)如果排前10的还是“我们”“真的”这类词,说明停用词表不够,把没用的词加进去重新跑一遍就行。
3.3 生成基础词云
词云的核心是wordcloud库,但第一次用中文最容易踩坑的是字体。wordcloud默认字体不支持中文,不指定字体就会导出一片方框或乱码。所以要给font_path传入一个中文字体路径。Windows可以直接用微软雅黑msyh.ttc,macOS用系统里的PingFang.ttc,Linux可以用思源黑体。更省事的做法是到网上下载思源黑体放到项目目录,路径写成相对路径,换机器不踩坑。
from wordcloud import WordCloud import matplotlib.pyplot as plt text_for_cloud = " ".join(filtered) wc = WordCloud( font_path="msyh.ttc", width=800, height=600, background_color="white", max_words=200, max_font_size=150, random_state=42, collocations=False, ).generate(text_for_cloud) wc.to_file("basic_wordcloud.png") plt.figure(figsize=(10, 8)) plt.imshow(wc, interpolation="bilinear") plt.axis("off") plt.show()说几个参数的含义。width和height是画布尺寸,越大图片越清晰但生成越慢。background_color控制底色,white就是白底,改成black就是黑底。max_words限制最多展示200个词,词太多了画面会挤。max_font_size是最大字号,防止特别高频的词撑爆画布。random_state是随机数种子,wordcloud布局本身有随机性,固定之后每次生成结果一致,方便反复调参对比。collocations=False是我强烈建议加的,wordcloud默认会去识别两个词的组合,中文语境下容易把“流浪地球”这种词组拼成一个整体,你想按单个词展示就把它关掉。
3.4 生成各式词云:圆形、心形与白底效果
基础版出来之后,就能玩点花样了。wordcloud的mask参数允许传一张模板图,它会只在模板的非白色区域绘制词语,其它区域留白,这样就实现了圆形、心形等各式形状。
先说圆形词云。找一张白底带黑色或彩色圆形的图片,或者自己用Pillow画一张:
from PIL import Image, ImageDraw import numpy as np size = (600, 600) img = Image.new("RGB", size, "white") draw = ImageDraw.Draw(img) draw.ellipse((60, 60, 540, 540), fill="black") # 圆形区域填黑 img.save("circle_mask.png") mask = np.array(img.convert("L"))然后生成词云时,把mask传进去:
wc = WordCloud( font_path="msyh.ttc", mask=mask, background_color="white", max_words=200, max_font_size=120, random_state=42, colormap="Blues", ).generate(text_for_cloud) wc.to_file("circle_wordcloud.png")注意,mask一旦设定,width和height会失效,实际尺寸由图片大小决定。
想做心形词云,同理,关键在于准备一张“白底+心形填充”的mask图。我习惯用参数方程现场画一个,不发散找素材:
import numpy as np from PIL import Image, ImageDraw size = (600, 600) img = Image.new("RGB", size, "white") draw = ImageDraw.Draw(img) points = [] scale = 12 for t in range(0, 360): rad = np.radians(t) x = 300 + scale * 16 * np.sin(rad) ** 3 y = 300 - scale * ( 13 * np.cos(rad) - 5 * np.cos(2 * rad) - 2 * np.cos(3 * rad) - np.cos(4 * rad) ) points.append((x, y)) draw.polygon(points, fill="black") img.save("heart_mask.png")如果你不想自己画,也能直接下载一张白底红色心形PNG,只要背景是纯白、心形区域不是白色就行。这里有个容易踩的坑:很多素材背景不是纯白,带渐变或半透明,直接转数组后背景区域也会被填词。判断标准很简单,用PIL打开后缩略图看看四个角是否都是纯白像素。
生成经典“白底心形红字”词云的完整代码:
from wordcloud import WordCloud import matplotlib.pyplot as plt import numpy as np from PIL import Image mask = np.array(Image.open("heart_mask.png").convert("L")) wc = WordCloud( font_path="msyh.ttc", mask=mask, background_color="white", max_words=200, max_font_size=120, random_state=42, collocations=False, contour_width=2, contour_color="crimson", colormap="Reds", ).generate(text_for_cloud) wc.to_file("heart_wordcloud.png") plt.figure(figsize=(8, 8)) plt.imshow(wc, interpolation="bilinear") plt.axis("off") plt.show()这里再讲两个参数。contour_width和contour_color是给形状描边,数值越大边越粗,颜色用crimson这种和Reds配色接近的红色会显得协调。colormap控制文字配色,想做出白底红字的心形词云效果就用Reds。想换风格的话,背景改成black、colormap改成plasma,又是另一番感觉。
其他任意形状也完全是同一套思路。比如想生成猫形、五角星、地图形状,只需要找到对应的白底剪影图,传给mask就行,代码都不用变。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
把这段时间实操时遇到的问题整理成了一张速查表,方便你将来对号入座:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 词云图全是方框/乱码 | 没设置font_path | 给WordCloud传入中文字体路径 |
| 词云只有一两个超大词,其它糊成一片 | max_words太小,或者停用词没过滤干净 | 加大max_words,完善停用词表 |
| 输出图片边缘有白色块 | mask图片背景不纯白 | 换纯白底素材,或用PIL重新生成 |
| 爬到几页后返回403/429 | 请求频率太高或UA被识别 | 加随机延时、换UA,必要时补Cookie |
| 评论总是抓不到、解析结果为空 | Cookie过期,或页面结构改版 | 登录后重新复制Cookie,检查选择器 |
| 命令行打印中文报UnicodeEncodeError | Windows控制台默认编码问题 | 先把数据写入文件,不要直接print大量中文 |
| 词云显示“流浪地球”而不是“流浪”“地球” | wordcloud默认合并了bigram | 加上collocations=False |
| mask无效或者报图片读取错误 | 图片路径不对,或mask带透明通道 | 检查路径,确保图片是RGB/灰度模式 |
4.2 反爬与稳定性实战心得
爬虫这一块,最容易被新手忽略的是稳定性。我第一次爬豆瓣时,傻乎乎地每秒发一次请求,结果爬到第三页就被拦了,后面半小时都在等解封。后来学乖了,有几个习惯真的建议大家养成。
第一,请求间隔做随机化。time.sleep(random.uniform(1, 3))和固定sleep差别很大,随机间隔能让请求节奏更接近真实用户操作。第二,失败重试。requests请求偶尔会因为网络波动抛异常,我的fetch_comments函数里用try/except兜底了,但这只是最低保障。更稳妥的做法是重试三次,每次间隔递增:
for attempt in range(3): try: resp = requests.get(url, headers=headers, params=params, timeout=10) break except Exception as e: print(f"第{attempt + 1}次请求失败:{e}") time.sleep(attempt * 3)第三,把中间数据落盘。每次跑完脚本,评论、清洗后的词都要保存成文件。我之前就是没存清洗后的词列表,调整参数重跑时发现原始评论又变了,前后对比搞得一团乱。第四,别硬扛登录限制。豆瓣短评没登录能看的页数很有限,与其频繁换代理硬扛,不如登录后带Cookie跑,稳定得多。
4.3 词云美化的进阶建议
基础能跑通,接下来就能琢磨怎么让词云更好看了。分享几个实际经验。
字体是出效果的第一要素。同一个心形图,用默认黑体做的效果偏硬,换成年轮体、圆体,气质立刻不一样。推荐思源黑体、站酷小薇LOGO体这类开源字体,注意商用前确认授权。配色别用单一纯色,colormap换个参数效果差异巨大,viridis、magma、plasma、Reds、Oranges这几个我轮流试过,都挺稳定。想自定义配色,还可以传一个函数,根据词频返回不同颜色,做出深浅变化。
布局参数也值得调。prefer_horizontal默认是0.9,表示90%的词横向排布,改成0.5就会横竖各半,适合窄长图。relative_scaling决定词频对字号的影响程度,默认0.5,想突出高频词就调到0.8。最后是小图和大图的区别,想发公众号或者做PPT,建议把width、height设到1600x1200,配合scale=2再放大一圈,导出图片细节更清楚,放大也不会糊。生成速度会慢一些,但效果完全值得。
最后说一个我个人的体会:爬虫和词云单独拿出来都不难,难的是把它们串成一条完整链路,并且在每个环节都留好缓存、兜底和错误提示。只要一次跑通了,剩下的玩法就多了。比如把评论按时间分成前后两组,分别生成两张词云对比,就能直观看到观众关注点的变化;或者把词频数据导出成Excel,结合评分做进一步分析。写这份教程的时候,我顺手把最后生成的心形词云打印出来贴在了工位旁边,别的不说,视觉上确实挺有成就感。你完全可以照这个流程动手跑一次,改一两个参数,就会发现自己也能做出挺专业的词云图。