我最早写爬虫练手,就是拿豆瓣影评做词云,当时没想太多,纯粹想看看大家的评论里到底在聊什么。结果一上手才发现,从“能拿到数据”到“生成一张像样的词云图”,中间隔着一大堆细节:反爬怎么处理、网页结构怎么看、中文为什么要先分词、字体和蒙版又怎么配。这些坑我自己一个个踩过来,今天干脆用Python爬虫加wordcloud,把“爬取豆瓣最新短评并生成各式词云”这件事完整拆开讲一遍。
这个项目不挑基础,适合刚开始学爬虫、想找一个落地场景的新手,也适合已经写过一点代码、但还没系统做过“采集-清洗-可视化”全流程的朋友。它解决的问题很具体:怎么构造请求拿最新评论,怎么从HTML里准确抽出评论文本,怎么让中文词云不糊不乱不全是方块。整条链路跑通之后,换任何新闻、影评、商品评价的数据源,你都能照葫芦画瓢。
1. 项目概览与技术选型
1.1 为什么选豆瓣短评做数据源
选豆瓣不是因为它好爬,而是在中文互联网里,它的文本质量确实有优势。不像电商评论充斥着“质量不错”“发货很快”这种高度重复的套话,豆瓣短评通常是有观点、有情绪、有信息量的,用户会聊剧情、聊演技、聊导演风格、聊价值观冲突。这些词汇足够丰富,喂给词云算法之后,出来的图才好看,才值得分析。
另一个原因是数据获取的公开程度相对明确。豆瓣电影每部作品的短评页是公开页面,不需要登录也能看到一部分内容,配合浏览器里复制的Cookie能拿到更多数据。我在实际抓取时观察到,不登录的状态下翻到后面几页会触发验证,所以教程里会把“带Cookie请求”作为标准做法来讲,而不是教大家注册海量小号去绕过限制。
这里先立个规矩:爬虫数据只用来学习和个人分析,不做商业用途,不批量撞接口,控制请求频率。后面所有代码都按这个前提来写,这也是一个从业者应该有的习惯。
1.2 技术栈选型:requests + BeautifulSoup + wordcloud
整个项目的技术组合我选的是请求、解析、分词、可视化四个层面的经典方案,每一层都有明确的替代品,但对新手来说这套组合踩坑最少。
- 请求层用 requests,它够简单、够直白,几十行代码就能把页面拉下来,后续讲解反爬配置也方便。
- 解析层用 BeautifulSoup,它对HTML结构的容错性极好,豆瓣页面即便有些标签嵌套不规范,它也能靠CSS选择器准确定位到评论节点。
- 分词层用 jieba,这是中文文本处理绕不开的库。英文天然以空格分词,中文必须按语义拆词,不用jieba直接扔给WordCloud,出来的只会是一堆单字,毫无可读性。
- 可视化层用 wordcloud,它默认支持从文本直接生成词云,并且提供了字体、遮罩、配色等一系列控制参数,灵活性足够覆盖绝大多数自定义需求。
有人会问,解析层为什么不用 XPath 或者正则?我的观点是:正则适合模式统一且简单的场景,豆瓣评论HTML里夹杂大量属性变化,正则会写得很痛苦;XPath确实高效,但学习曲线略陡,对新手不够友好。BeautifulSoup 配合 select 方法,在“我想拿所有class为short的span文本”这种需求上,是最自然的选择。
1.3 合规底线与抓取纪律
每次写爬虫教程,我都会把这块单独拎出来讲,因为太重要了。爬虫本身是中性工具,但使用方式决定了它合不合规。实际操作前,先检查目标站点的robots协议和页面底部的使用条款,尊重平台规则;抓取过程控制在合理频率内,我习惯每次请求之间随机休眠3到5秒,既保护对方服务器,也降低自己IP被限制的概率;数据只做本地分析和学习展示,不公开发布、不售卖、不用于任何商业产品。
这些纪律不是套话,而是真实经验。豆瓣的反爬策略会动态变化,如果一上来就高频请求,新手往往会在第一页就收到验证码页面,然后就开始怀疑自己代码写错了。把频率控制好,后面所有步骤才能顺利推进。
2. 抓取豆瓣最新短评:关键环节拆解
2.1 读懂短评页的URL与参数
豆瓣电影短评页的URL结构很规律,一部电影的详情页地址通常是这种格式:
https://movie.douban.com/subject/目标电影的数字ID/把末尾补上/comments就是短评区入口,但直接这样访问,默认展示的是“热门短评”。我们要拿的是“最新评论”,所以还需要加上查询参数。我实际使用的完整地址长这样:
https://movie.douban.com/subject/目标电影的数字ID/comments?status=P&sort=time这里的sort=time表示按时间排序,拿到的就是最新发布的短评;status=P表示只看已公开的内容。如果不加这两个参数,默认的排序方式会把热度高的评论顶到前面,这就不是标题里说的“最新评论”了。
分页的逻辑也在这个URL里:往下翻页时,观察浏览器地址栏,会发现新增了start和limit参数。start是跳过多少条,limit是每页数量,豆瓣这里固定是20条。所以第1页是start=0,第2页是start=20,第3页是start=40,以此类推。
2.2 伪装请求头与Cookie,别一上来就被拒
直接拿 requests 去请求这个URL,大概率会得到一个403或者一个验证页面,原因是默认的Python UA太容易识别了。我在实际调试中的做法是先打开浏览器,按F12进入开发者工具,切到Network面板,刷新短评页,找到名为comments的请求,然后把浏览器偷偷带上的请求头完整复制出来。
核心是三个部分:
User-Agent:必须换成浏览器版本,例如Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36开头的整串内容。Referer:指向电影详情页,也就是https://movie.douban.com/或具体subject地址,缺了它容易被识别为跨域请求。Cookie:这个最关键。豆瓣很多内容需要对登录态做校验,带上Cookie后,请求的成功率和能翻到的页数都会明显提升。Cookie从浏览器Network面板里那个请求的Request Headers里复制,不需要自己去拼。
我在代码里习惯用一个字典管理这些信息:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Referer": "https://movie.douban.com/subject/目标电影的数字ID/", "Cookie": "你的浏览器Cookie" }有一点要注意,Cookie有有效期,如果隔几天重新跑项目,发现突然又403了,先回浏览器重新复制一次Cookie,多半就好了。
2.3 解析HTML的两种主流姿势
拿到页面响应后,第一步不是急着提取文本,而是先保存一份HTML到本地,用浏览器打开,肉眼确认评论结构。我见过太多人省掉这个步骤,结果选择器写错,程序跑完生成一个空词云,还不知道哪里出了问题。
豆瓣短评页里,每条评论最外层是一个带comment-itemclass的div,评论正文通常包裹在class为short的span里。我的主要解析代码只干两件事:先定位所有comment-item,再在每个节点里找span.short提取文本。
from bs4 import BeautifulSoup def parse_comments(html): soup = BeautifulSoup(html, "html.parser") items = soup.select(".comment-item") comments = [] for item in items: short_elem = item.select_one(".short") if short_elem: text = short_elem.get_text().strip() if text: comments.append(text) return comments除了BeautifulSoup,还可以用正则做辅助校验,比如去掉评论里的HTML实体和空白字符。但主解析逻辑不要用正则去匹配整段评论,因为HTML结构稍有变动,正则就全盘崩溃。BeautifulSoup的容错优势在真实页面上非常明显。
2.4 完整短评采集代码与运行说明
把参数构造、请求发送、HTML解析、休眠限速这几步串起来,就是一个可以直接跑的最小采集脚本,我平时习惯写成函数式结构,方便复用:
import time import random import requests from bs4 import BeautifulSoup SUBJECT_ID = "在这里替换成目标电影的纯数字ID" def fetch_comments(subject_id, page_start, session, headers): url = f"https://movie.douban.com/subject/{subject_id}/comments" params = { "start": page_start, "limit": 20, "status": "P", "sort": "time" } resp = session.get(url, headers=headers, params=params, timeout=10) resp.encoding = "utf-8" if resp.status_code != 200: print(f"请求失败,状态码: {resp.status_code}") return [] return parse_comments(resp.text) session = requests.Session() all_comments = [] for start in range(0, 200, 20): part = fetch_comments(SUBJECT_ID, start, session, headers) if not part: break all_comments.extend(part) print(f"已抓取 {start + len(part)} 条评论") time.sleep(random.uniform(3, 5)) with open("douban_comments.txt", "w", encoding="utf-8") as f: f.write("\n".join(all_comments))这里有几个实操细节值得说。第一,用requests.Session()而不是每次都直接requests.get,Session会复用底层的连接,对Cookie和Headers的管理也更友好。第二,resp.encoding = "utf-8"一定要在打印或写入之前设置,否则遇到部分中文编码判断异常会出现乱码。第三,循环里一旦某页返回空列表,直接break跳出,避免对超出范围的页数做无谓请求。
3. 词云生产链路:从文本到图片
3.1 为什么必须用jieba分词
词云生成的本质是统计“词语”的频率,但中文不像英文那样天然用空格分隔词。直接拿一整段中文丢给WordCloud,它只会按照单个汉字去统计,结果就是“电、影、很、好、看”这种没有任何分析价值的字频图。这也是中文词云新手最常犯的错误。
jieba库解决的就是中文分词问题,它内置了比较完整的词库,能正确切分出“电影”“导演”“演技”“剧情”这样的词汇。对于网络用语和电影专有名词,还能用jieba.add_word()手动补充,比如某些影片名、角色名、粉丝昵称。
实际使用只需要两行核心调用:
import jieba text = open("douban_comments.txt", encoding="utf-8").read() words = jieba.lcut(text)jieba.lcut会返回一个分词后的列表,这个列表随后进入清洗环节。
清洗这一步直接决定词云质量。我会做四件事:去掉标点符号和空白字符,过滤长度小于2的单个字,过滤掉“我们”“他们”“这个”“那个”等无信息量的停用词,再过滤掉评论里高频出现的套话词。停用词表需要根据数据动态补充,我第一次跑的时候词云里全是“电影”“不是”“觉得”这类词,后来手动维护了一份停用词表,图才清爽起来。
import re stopwords = {"电影", "一部", "不是", "什么", "这个", "那个", "我们", "他们", "觉得", "真的", "还是"} def clean_words(words): result = [] for w in words: w = w.strip() if not w: continue if len(w) < 2: continue if not re.search("[\u4e00-\u9fa5]", w): continue if w in stopwords: continue result.append(w) return result3.2 wordcloud关键参数逐个看懂
WordCloud的构造参数很多,我挑几个真正影响成品效果的来重点讲。
font_path是中文词云的生命线。不指定中文字体,生成的图里全是方块乱码,后面我单独开一节讲字体配置,这里先记住它必须放在第一位考虑。
width和height控制画布大小,默认只有400x200像素,直接生成的话文字密密麻麻。我会设成1920x1080或更高,保证后续导出图片清晰。注意,这两个参数在指定了mask(遮罩图)时会被覆盖,画布尺寸以遮罩图为准。
background_color决定背景色,最常用的是"white",和默认蒙版图配合效果稳定。如果喜欢深色底,可以设成"black",但文字颜色也要相应选亮色系。
max_words限制显示词语数量,默认是200。评论数据量很大时,不限制的话词云会非常拥挤,我会根据文本规模调成100到300之间。
max_font_size控制最大字号。很多人认为字号越大越好,其实不设置反而更自然,让词频自然决定大小。如果一定要设,建议设成画布宽度的1/8左右。
colormap是配色方案,它有几十种预设,比如"viridis"、"plasma"、"Set2"。用同一个词频数据,换一个colormap就能做出完全不同风格的图,这是我比较推荐的“低成本换风格”手段。
contour_width和contour_color是配合遮罩图用的,给词云边缘描边,会让轮廓清晰很多,我后面做人物剪影词云时必开。
3.3 蒙版、字体与配色的组合玩法
想让词云不再是千篇一律的矩形,最关键的是mask遮罩图。原理很简单:WordCloud把一张图片读入为像素矩阵,只会在非白色的区域绘制文字,所以只要提供一张目标形状的图,文字就会自动填成那个形状。
蒙版图的准备有三个要点。第一,形状要足够粗大,细长的线条在词云里会糊成一片。第二,颜色最好接近纯黑白,前景是黑色,背景是白色,这样程序判断最准确。第三,图片尺寸不要太大,1000x1000像素左右足够,太大会严重拖慢生成速度。
处理遮罩图时务必转成灰度模式再转numpy数组,否则带alpha通道的PNG容易出错:
import numpy as np from PIL import Image mask = np.array(Image.open("douban_mask.png").convert("L"))这里的技巧是:把网上找的轮廓图在本地用图像处理工具处理成黑白剪影,保存为PNG。我用过一次人物肖像剪影,因为原图灰度层次太多,词云边界非常脏,后来压成纯黑白之后效果立刻好了。
字体方面,Windows系统可以用"C:/Windows/Fonts/msyh.ttc"(微软雅黑),也可以用"simhei.ttf"(黑体)。macOS用户可以用"/System/Library/Fonts/PingFang.ttc"。如果跑在服务器上,需要先下载一份中文字体文件放在项目目录,再指定相对路径。
配色上加一个我私藏的小玩法:ImageColorGenerator可以读取另一张图片的颜色分布,给词云重新上色,让词语颜色跟着蒙版图的色调走。这样生成的词云整体色调统一,非常好看。
3.4 词云生成完整代码
把清洗后的词语拼接成空格分隔的字符串,喂给WordCloud,完整代码如下:
from wordcloud import WordCloud import numpy as np from PIL import Image import matplotlib.pyplot as plt text_words = clean_words(words) text_str = " ".join(text_words) mask = np.array(Image.open("douban_mask.png").convert("L")) wc = WordCloud( font_path="C:/Windows/Fonts/msyh.ttc", width=1920, height=1080, background_color="white", max_words=200, mask=mask, contour_width=1, contour_color="steelblue", colormap="viridis", scale=1.5 ) wc.generate(text_str) wc.to_file("douban_wordcloud.png") plt.figure(figsize=(12, 8)) plt.imshow(wc, interpolation="bilinear") plt.axis("off") plt.show()如果不想用遮罩图,想快速生成一个矩形词云,就把mask参数注释掉,画布由width和height控制。如果希望按词频字典生成而不是自动统计文本,可以先用jieba统计好字典,再用generate_from_frequencies()方法,两种方式效果等价,按场景选。
4. 容易踩的坑:问题排查与避坑实录
4.1 高频问题速查表
我整理了这段时间最常遇到的几类问题,列成表格方便对照排查:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 请求返回403或418 | 缺少浏览器UA和Cookie | 在浏览器Network面板复制完整请求头 |
| 返回页面是验证码页 | 请求频率太高触发风控 | 随机休眠3到5秒,降低抓取页数 |
| 评论内容为空列表 | 页面结构改版,选择器失效 | 保存HTML到本地,检查新的class名 |
| 写出的文本出现乱码 | 没有显式指定编码 | 设置resp.encoding = "utf-8" |
| 词云全是汉字方块 | 没有设置中文字体路径 | 指定font_path为中文字体 |
| 词云只有重复的常见词 | 停用词表不完整 | 扩充停用词表,过滤无意义词汇 |
4.2 针对豆瓣反爬的几条保命经验
豆瓣的反爬策略不是固定不变的,我观察下来主要有三个特点:对无Cookie请求限制逐渐加强、对高频率请求触发验证、对异常UA直接拒绝。针对这三点,我总结了几条一直被验证有效的经验。
第一,请求头里的Accept-Language建议加上zh-CN,zh;q=0.9,会让服务器返回正常的简体中文页面,某些情况下还能避免返回特殊处理过的内容。第二,保持单次会话的连贯性,用同一个Session去翻页,因为Cookie的连续性对请求成功率有明显影响。第三,抓取页数不要贪,短评区拉到20页之后基本就是重复劳动了,200条评论做词云已经完全足够。
我还发现一个容易被忽略的细节:某个页面在浏览器里能正常打开,但用requests请求却返回异常的页面,这时候先不要怀疑代码,把响应内容保存成HTML文件,在浏览器里打开看一眼,确认是不是被引导到了验证页面。这比反复调试代码高效得多。
4.3 词云常见的四个翻车现场
词云翻车的原因80%在文本清洗,20%在参数配置。
第一个翻车现场是词云里出现“很好看”和“很不好看”同时高频,这其实反映了一个处理缺陷:停用词过滤得太粗暴。更好的做法是把高频修饰词和核心名词一起保留,让词云显示“好看”而不是“很”“看”两个散字,jieba对双字词的分词效果往往比三字词稳定。
第二个翻车现场是字体重叠。原因通常是max_words设太大,或者遮罩图太复杂导致布局空间不够。我试过把max_words从200调到300,结果整个图乱成一团。多试几个值,找到让画面最舒展的那个。
第三个翻车现场是词云生成后没有文字,只有空白背景。这大概率是文本内容为空或者分词后所有词都被停用词表过滤掉了。排查思路很简单:先打印清洗后的词语列表,看看长度是多少,如果只剩几个词,说明清洗条件太苛刻。
第四个翻车现场是图片颜色浑浊。使用ImageColorGenerator上色时,如果来源图片本身色彩杂乱,词云颜色就会很脏。我的解决方法是先把来源图片调成统一的低饱和色调,再用recolor方法上色,出来的效果会干净很多。
5. 扩展玩法与个人心得
5.1 让词云更好看的三个思路
第一种思路是做个“多风格对比”。同一批评论数据,分别用白色背景加亮色、黑色背景加霓虹色、人物剪影蒙版三种配置生成三张图,拼在一起发出来的视觉冲击力会强很多,也能更直观地向别人展示词云技术的可塑性。
第二种思路是按时间维度拆数据。把抓到的评论按日期分段,分别生成不同时间段的热词词云,比如上映首周和上映一个月之后观众关注点的变化。这个玩法对数据分析的价值比单张词云大得多。
第三种思路是结合情感倾向做分离。对评论先做简单的情感分类,把好评和差评拆成两个文本集合,分别生成词云。这样做出来的图能直接回答“大家喜欢这部电影的什么、讨厌这部电影的什么”,非常直观。
5.2 个人实际使用的几个建议
最后分享几个我自己调试项目时的习惯,希望能帮你少走弯路。
先小规模验证再全量跑。我在正式生成词云前,习惯先用20条评论跑通整个流程,确认字体、遮罩、配色都没问题,再跑200条,这样能避免浪费时间在大数据量上反复调参。
文本清洗永远比参数调优重要。我见过太多人执着于调max_font_size和colormap,却不肯花时间维护停用词表。事实是,只要停用词表干净,默认参数生成的词云就不会太差;反过来,停用词表全是垃圾词,参数调得再花哨也救不回来。
Cookie文件要养成单独管理的习惯。我会把Cookie和Headers放在一个配置区里,每次运行项目前检查一次是否需要更新,而不是每次都去浏览器里翻找。这个习惯帮我在很多次“为什么昨天还能跑今天就不行”的排查中节省了大量时间。
词云图最终是给人看的,不是给代码看的。生成完图片之后,退后一步,想象一下如果自己是观众,第一眼看到这张图会不会想看图里的文字。如果答案是否定的,别犹豫,去改数据清洗和配色方案,直到改出让人愿意驻足的那一版为止。