Scrapy爬取豆瓣TOP250短评到中文情感分析全流程实践 简介这是一套围绕豆瓣TOP250电影短评的完整数据挖掘项目融合Scrapy爬虫、数据清理与特征工程、中文文本情感分析面向有一定Python基础、希望掌握从数据采集到模型评估全流程的开发者。资源包共214个文件大小约73.43MB以Python源码、Jupyter Notebook、JSON数据、可视化图表为主并附带地图边界shp文件、模型权重与配置说明便于对照复现。目前已有3774人学习。内容覆盖movie_item、movie_comment、movie_people三张数据集前者可复现评分最高TOP10、电影类别/国家/导演/演员/语言排名、时长与票数分布等统计后者聚焦短评包括词云展示并实现朴素贝叶斯、支持向量机、fastText、卷积神经网络、循环神经网络、门控循环单元等中文文本分类与情感分析模型肖申克的救赎作为案例贯穿三表协同分析还能通过短评预测电影类型。整体链路完整从爬虫采集、数据清洗到多维度统计与文本建模均有代码和笔记支撑适合学习者拆解项目并迁移到其他NLP场景。1. 这套 TOP250 短评资源能让你从爬虫一路走到情感分析如果你正愁没有一份干净的中文文本数据用来练手这套 TOP250 豆瓣电影短评的 Scrapy 爬虫加数据分析资源刚好能补上这一步。项目思路很简单用 Scrapy 抓豆瓣电影 TOP250 下每部电影的热门短评然后把文本清洗成结构化 DataFrame再基于评分给短评打情感标签最后训练一个中文情感分析模型。整个过程不需要你登录、不需要手动标注短评自带的星级评价就是现成的弱监督标签这是我能想到最快跑通「爬虫 → 数据清理 → 数据分析 → 情感分析」全链路的方式。这套资源适合三类读者一是刚学完 Scrapy 基础语法、想拿真实站点练手的爬虫新人二是做数据分析但手头没有中文评论文本的从业者三是对 NLP 感兴趣、想用最小成本体验一次分类建模的人。它不是什么高深论文复现而是一个能直接运行、结果可验证的完整流程。你拿到后改一改字段就能迁移到其他电影、其他评论站点。2. 爬虫框架搭建为什么用 Scrapy以及短评页怎么翻2.1 选型理由Scrapy 比 requests 更适合批量采集短评做 TOP250 短评采集最常见的错误想法是用 requests 写个 for 循环一页一页手动拼接 URL。这能跑通但你很快会发现自己要处理超时重试、请求头伪装、数据去重、断点续爬写到最后代码比爬虫本身还复杂。Scrapy 把这些问题都内置了并发请求由下载器管理去重由调度器过滤数据清理和存储交给 Pipeline你只需要专心写解析逻辑。所以这个资源里选型很明确原则上是 Scrapy 静态页面解析。豆瓣电影 TOP250 列表页和短评页里的内容基本都在 HTML 源码中不需要执行 JavaScript。如果你遇到某些页面用动态 iframe 加载评论那可能得引入 playwright 另写一套但在 TOP250 这个场景下Scrapy 能稳定拿到完整文本。有个细节值得注意Scrapy 默认的请求头很显眼容易被反爬拦截。所以我们需要自定义 User-Agent并设置 Download Delay。别小看这两步很多人爬豆瓣直接裸奔第一页就被封了。2.2 定义 Item 和 Spider列表页解析与短评页翻页先定义数据字段。短评不只是「评论内容」这一列还要保留用户名、评分、评论时间、有用数、所属电影标题。这些字段既是后面数据分析的基础也是情感标签的来源。# items.py import scrapy class DoubanShortCommentItem(scrapy.Item): movie_title scrapy.Field() # 电影名 movie_id scrapy.Field() # 豆瓣电影 id commenter scrapy.Field() # 评论者昵称 rating scrapy.Field() # 评分星级例如 5、4、3 comment scrapy.Field() # 短评文本 comment_time scrapy.Field() # 评论时间 useful_count scrapy.Field() # 有用数Spider 需要做两件事先抓 TOP250 列表页里的电影链接再对每部电影发起短评请求。# douban_top250.py import scrapy from douban.items import DoubanShortCommentItem class DoubanTop250Spider(scrapy.Spider): name top250_comment allowed_domains [movie.douban.com] start_urls [https://movie.douban.com/top250?start0filter] custom_settings { DEFAULT_REQUEST_HEADERS: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://movie.douban.com/, Accept-Language: zh-CN,zh;q0.9, }, DOWNLOAD_DELAY: 2, ITEM_PIPELINES: {douban.pipelines.CleanPipeline: 300}, } def parse(self, response): # 提取 TOP250 列表页中每部电影的子页面链接 movie_links response.css(div.hd a::attr(href)).getall() for link in movie_links: # link 形如 https://movie.douban.com/subject/1292052/ yield scrapy.Request(urllink, callbackself.parse_movie) # 处理翻页TOP250 共 10 页每页 25 部电影 next_page response.css(span.next a::attr(href)).get() if next_page: yield scrapy.Request( urlresponse.urljoin(next_page), callbackself.parse ) def parse_movie(self, response): movie_title response.css(h1 span[propertyv:itemreviewed]::text).get() movie_id response.url.split(/subject/)[1].split(/)[0] # 短评页起点https://movie.douban.com/subject/{id}/comments?start0limit20 comment_url fhttps://movie.douban.com/subject/{movie_id}/comments?start0limit20 yield scrapy.Request( urlcomment_url, callbackself.parse_comments, meta{movie_title: movie_title, movie_id: movie_id}, dont_filterTrue ) def parse_comments(self, response): movie_title response.meta[movie_title] movie_id response.meta[movie_id] comment_items response.css(div.comment-item) for item in comment_items: comment DoubanShortCommentItem() comment[movie_title] movie_title comment[movie_id] movie_id comment[commenter] item.css(a::attr(title)).get() # 评分文本形如 力荐、推荐、还行 rating_text item.css(span.rating::attr(class)).get() comment[rating] rating_text.split(star)[-1].strip() if rating_text else None comment[comment] item.css(span.short::text).get() comment[comment_time] item.css(span.comment-time::attr(title)).get() comment[useful_count] item.css(span.vote_count::text).get() yield comment # 单部电影的短评也分页start 按 20 翻 next_page response.css(div.paginator a.next::attr(href)).get() if next_page: yield scrapy.Request( urlresponse.urljoin(next_page), callbackself.parse_comments, meta{movie_title: movie_title, movie_id: movie_id}, dont_filterTrue )这段代码的逻辑分三步。第一步parse负责 TOP250 列表页把每部电影链接交给parse_movie第二步parse_movie拼出该电影的短评列表页 URL第三步parse_comments提取短评条目并继续翻页。注意我在custom_settings里写了 DOWNLOAD_DELAY2意思是每次请求间隔 2 秒对豆瓣这种反爬严格的站点这是必须要付的成本。关键参数有两个start和limit。短评列表 URL 中start是偏移量limit是每页条数豆瓣通常固定为 20。当你要减少采集量时不要改 limit否则豆瓣可能返回异常页面正确做法是限制翻页次数比如只取前 5 页。2.3 Pipeline 存储为什么用 JSONLines 而不是直接写 CSVScrapy 的 Pipeline 是数据离开 Spider 后的第一站。这里资源包里用 JSONLines 格式存储原始数据因为它是逐行写入即使爬虫中途崩溃已经写入的数据不会丢。CSV 则容易遇到字段中换行符导致的串行问题。# pipelines.py import json import hashlib class CleanPipeline: def open_spider(self, spider): self.file open(top250_comments.jsonl, w, encodingutf-8) self.seen set() # 基于 comment 内容去重 def close_spider(self, spider): self.file.close() def process_item(self, item, spider): comment item.get(comment, ) if not comment or len(comment.strip()) 5: raise DropItem(过滤过短评论) # 简单去重同一用户对同一电影只保留一条短评 dedup_key hashlib.md5( f{item[movie_id]}_{item[commenter]}_{comment}.encode(utf-8) ).hexdigest() if dedup_key in self.seen: raise DropItem(重复评论) self.seen.add(dedup_key) self.file.write(json.dumps(dict(item), ensure_asciiFalse) \n) return item这个 Pipeline 做了三件事过滤空评论和太短的灌水短评基于「电影 用户 文本」生成哈希去重最后按 UTF-8 写入 JSONLines。process_item返回item表示继续交给后续 Pipeline如果抛出DropItem该条数据会被丢弃。这里我没有用数据库存储是因为后面数据清理会交给 pandas 处理JSONLines 转 DataFrame 只需要一行pd.read_json(..., linesTrue)。如果你后续要持续维护数据我建议在 Pipeline 里换成 SQLAlchemy 写一张comment表字段结构不变只是把write改成session.add。但第一次跑通流程用文件存储成本最低。3. 数据清理与预处理从原始文本到干净 DataFrame3.1 原始数据的脏在哪里跑完爬虫你会发现top250_comments.jsonl 里大概有两三万条短评但这些数据没法直接建模。常见脏数据包括评论前后带不可见空格、HTML 转义符号如amp;、重复的空白行、评分字段缺失。更麻烦的是有用数字段有时带换行有时是空字符串如果不统一处理后面转数值类型时必然报错。按我的习惯数据清理永远先打印几条原始记录用print看而不是直接写一堆替代逻辑。看清脏数据长什么样才知道要清什么。# clean_data.py import pandas as pd import re # 读取 JSONLines df pd.read_json(top250_comments.jsonl, linesTrue) print(df.head(10)) print(df.info())输出里最明显的问题是rating列类型为 objectcomment文本含\u3000全角空格comment_time不是标准时间格式。接下来逐一处理。3.2 用 pandas 做标准化清理# clean_data.py 后续代码 def clean_comment(text): if not isinstance(text, str): return text re.sub(r[^], , text) # 去掉 HTML 标签 text text.replace(amp;, ).replace(quot;, ) text re.sub(r\s, , text).strip() # 合并空白 return text def parse_rating(value): if value is None or not value.isdigit(): return None rating int(value) return rating df[comment] df[comment].apply(clean_comment) df[rating] df[rating].apply(parse_rating) df[useful_count] pd.to_numeric(df[useful_count], errorscoerce).fillna(0) df df.dropna(subset[comment, rating]) df df.drop_duplicates(subset[movie_id, commenter, comment]) # 只保留针对短文本的正常长度去除重复刷屏 df df[df[comment].str.len() 5] df.to_csv(top250_clean.csv, indexFalse, encodingutf-8-sig) print(df.describe())这段代码的核心是dropna和drop_duplicates。dropna(subset[comment, rating])直接扔掉没有评分或空文本的短评因为情感标签依赖评分drop_duplicates在 Pipeline 去重后做第二次兜底防止爬虫重复翻页导致同一条评论被写两次。pd.to_numeric可以把有用数列转成数值errorscoerce把非法值变成 NaN 后填充 0这样后面做聚合统计才不会报类型错误。这里有个容易翻车的点to_csv的编码参数如果是utf-8Excel 打开 csv 大概率是乱码因为 Windows 下 Excel 默认用 GBK 解码。所以保存给下游分析的文件我固定用utf-8-sig它会在文件开头加 BOM 标记Excel 能正确识别。这个细节我在第一次导出 CSV 时踩过后来每份文件都强制走一遍utf-8-sig。3.3 为情感分析构造弱监督标签清洗后的数据已经有评分了但情感分析模型需要的是文本对应的情感极性标签。手工给几万条短评打标不现实好在豆瓣短评自带评分可以直接映射成标签。def rating_to_label(rating): if rating 4: return 1 # 正面 elif rating 2: return 0 # 负面 else: return 2 # 中性 df[sentiment] df[rating].apply(rating_to_label) df.to_csv(top250_labeled.csv, indexFalse, encodingutf-8-sig)边界划分很直接4 星和 5 星是正面1 星和 2 星是负面3 星作为中性。你可能会质疑评分并不完全等同于情感比如有人给 3 星但文本写的全是吐槽这就是弱监督的代价。这类标签噪声会在后面模型评估时暴露到时候你别惊讶准确率上不去这属于数据本身的复杂性不是代码 bug。清洗后的 DataFrame 里sentiment列就是情感分析模型的 y 目标。如果想要更干净的二元分类可以只保留正面和负面把中性丢掉训练一个二分类模型。这个策略我在第 4 章会用到因为三分类的中性样本边界靠评分映射确实太模糊模型很难学出稳定的规律。4. 构建中文文本情感分析模型分词、向量化与逻辑回归4.1 中文分词不用 jieba 的文本分析都是耍流氓中文短评没有天然空格分隔直接扔给 TF-IDF 向量化只会得到一堆整句的特征完全学不到词共现信息。这里必须用 jieba 做精确模式分词并去掉对情感判断没帮助的停用词。import jieba import re STOP_WORDS {的, 了, 是, 在, 我, 有, 和, 就, 不, 人, 都, 一, 一个, 这部, 电影} def tokenize(text): text re.sub(r[^\w\u4e00-\u9fa5], , text) words jieba.cut(text, cut_allFalse) return .join([w for w in words if w.strip() and w not in STOP_WORDS])jieba.cut的cut_allFalse表示精确模式适合做特征提取如果切出「全模式」会产生大量无意义组合词特征维度成倍膨胀。停用词表里我写的是常见虚词你也可以用现成的中文停用词表但注意不要过度过滤像「不」这种词在很多中文评论里是情感转折的关键词一删就会让负面评论的特征消失。分词后建议抽查几条结果。用print看分词后文本是不是符合直觉比如「这部电影不好看」应该切成「这部 电影 不好看」如果被切成了「这 部 电影 不好 看」那后续就需要调自定义词典。4.2 TF-IDF 与逻辑回归用 sklearn Pipeline 一体化训练特征提取和模型训练这里用 scikit-learn。我先做了个简化实验只取清洗后数据里 rating 等于 5 和 rating 等于 1 的极端短评跑一个二分类。为什么要这么做因为弱监督标签中 4 星和 2 星多少参杂情绪极端 5 星和 1 星文本表达往往更明确模型能学到更清晰的模式。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import Pipeline from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report df_labeled pd.read_csv(top250_labeled.csv) # 只保留正负样本构造二分类 df_binary df_labeled[df_labeled[sentiment].isin([0, 1])].copy() df_binary[text_tokenized] df_binary[comment].apply(tokenize) X df_binary[text_tokenized] y df_binary[sentiment] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy ) model Pipeline([ (tfidf, TfidfVectorizer(ngram_range(1, 2), max_features5000)), (clf, LogisticRegression(max_iter1000, C1.0)), ]) model.fit(X_train, y_train) y_pred model.predict(X_test) print(classification_report(y_test, y_pred, target_names[负面, 正面]))这条 Pipeline 把向量化和分类器串在一起训练和预测都只需要调用model.fit和model.predict不用手动转换特征矩阵。TfidfVectorizer的ngram_range(1, 2)代表同时取单个词和相邻两个词作为特征这样像「不好笑」「太差」这种双词情感短语能被保留。max_features5000限制特征数量避免每条评论分词后出现太多生僻词导致维度爆炸。LogisticRegression这里用默认 L2 正则C1.0控制正则权重。如果你发现模型在训练集准确率很高但测试集很低大概率是 C 太小导致过拟合可以把 C 降到 0.5 试试。分类报告里重点看「负面」类的 F1 值因为负面评论占比通常少于正面模型很容易偷懒地把所有样本都预测成正面这时候需要调class_weightbalanced。4.3 模型预测结果应用给新短评算情感分训练完的模型可以拿来预测任意中文短评。用同一个 Pipelinemodel.predict_proba能输出正面概率量化短评的情感倾向。new_comments [ 剧情拖沓看得我快睡着了, 太好看了今年最佳没有之一, 画面很美但故事一般, ] for c in new_comments: text tokenize(c) proba model.predict_proba([text])[0] pred model.predict([text])[0] print(f评论: {c}\n 预测: {负面 if pred 0 else 正面} 正面概率: {proba[1]:.2f}\n)注意预测输入也是分词后的文本因为训练时X_train就是分词后的字符串。如果你忘了调用tokenizeTF-IDF 会把这个长句当成一个单独的特征和训练特征空间完全对不上预测结果基本随机。这套模型的边界在于它完全基于词频统计对讽刺、反语无能为力。比如「居然还不错」在短评里常见但模型可能抓不到「居然」的反转语气。要解决这类语义问题后面可以换预训练语言模型但那是另一个量级的工作量。对于 TOP250 短评这种相对直白的文本逻辑回归已经能给出 80% 以上的 F1 分数这就够用了。5. 避坑与常见问题短评爬取和情感建模的五个真实翻车点5.1 爬虫采集阶段从 403 到断点续爬现象 1请求发送后立刻返回 403 Forbidden日志里全是DEBUG: Crawled (403)。原因豆瓣反爬会根据 User-Agent 和请求频率判断你是真人还是脚本。默认 Scrapy 的 UA 形如Scrapy/2.x明显暴露身份而且 DOWNLOAD_DELAY 如果设成 0短时间大量并发请求必然触发封锁。解决在settings.py里配置真实浏览器 UA并设置DOWNLOAD_DELAY 2。如果还不够稳可以再加一个中间件轮换多个 UA。注意不要把这个延迟降成 0.1那等于邀请封 IP。现象 2爬到第 6 页短评时后续页面的评论缺失或直接空列表。原因豆瓣对未登录用户只开放前 5 页短评继续往后翻不会有完整数据而是返回空容器。这不算封禁而是业务规则。解决不要无限翻页在parse_comments里对start做硬限制最多取到start100。或者改用登录后的 Cookie 注入请求头但我不建议为了数据量去碰账号体系合规风险不值得。现象 3生成的 JSONLines 用文本编辑器打开是好的但用 pandas 读出来后中文全部变成乱码。原因Scrapy 在 Windows 上默认输出编码由FEED_EXPORT_ENCODING决定如果不设置可能按本地 GBK 写文件而你读 JSON 时又用了 UTF-8。解决在settings.py里显式设置FEED_EXPORT_ENCODING utf-8或者像 Pipeline 那样自己open文件时指定encodingutf-8。记住读文件和写文件的编码必须完全一致。5.2 数据清理与模型训练阶段标签噪声和分词盲区现象 4模型测试准确率意外地高比如 98%但拿到新评论上预测完全不准。原因这通常不是因为模型强而是数据泄漏。比如清洗时没有把重复评论完全去掉同一部电影里大量相似短评同时出现在训练集和测试集模型靠着记住这些重复文本刷高分数。另一种可能是我前面提到的弱监督标签边界太极端把 5 星和 1 星分开本就不难模型真实能力并没有那么强。解决训练前做严格去重用df.drop_duplicates(subset[comment])然后用 StratifiedKFold 交叉验证代替单次划分观察每一折的准确率波动。如果波动大就说明数据分布不稳定模型没有学到泛化规律。现象 5电影名、演员名被 jieba 错误切分导致「流浪地球」变成「流浪」「地球」情感特征丢失。原因jieba 的词典更新有滞后性对近几年出现的新片名、新网络词识别不佳。短评里高频出现但切错词时模型无法把它们作为完整特征。解决准备一个自定义词典mydict.txt每行一个词和一个权重比如流浪地球 10 n在tokenize函数里调用jieba.load_userdict(mydict.txt)。这样分词器会优先按你指定的词切分。还有一个容易忽略的坑不要在清洗阶段把标点符号全部删掉。中文里「」和「」本身能反映情绪比如「这也叫好看」明显是负面。如果标点全没了这类评论的情感特征会被削弱。我在代码里用re.sub(r[^\w\u4e00-\u9fa5], , text)删掉了标点这是为了简化模型但你做严肃分析时最好把常见的疑问号和感叹号保留为特殊标记。6. 进阶用法把模型接入增量短评验证用混淆矩阵看错误样本到了这一步你已经有了一个能预测短评情感的模型。接下来我习惯做两件事一是用增量爬虫去抓一部你最近看过的电影短评验证模型迁移能力二是打印预测错误的样本看看模型的失败模式。这比看准确率数字更有价值。先写一个极简增量爬虫。复用之前的parse_comments把start改成从页面获取最新短评时间只爬当天的短评然后经过同样的清洗和分词用训练好的model.predict做情感预测。这样你能实时看到观众情绪变化也算让爬虫真正服务于内容分析。# predict_new_comments.py import pandas as pd import joblib # 假设模型已保存 model joblib.load(sentiment_model.joblib) new_df pd.read_csv(new_today_comments.csv) new_df[text_tokenized] new_df[comment].apply(tokenize) new_df[predicted] model.predict(new_df[text_tokenized]) new_df[positive_prob] model.predict_proba(new_df[text_tokenized])[:, 1] print(new_df[[comment, predicted, positive_prob]].head(20))joblib保存整个 Pipeline加载后直接调用不用重新拟合。注意tokenize函数必须和训练时完全一致包括停用词表。我建议把tokenize写进一个独立模块训练和预测都 import 它避免复制粘贴导致差异。然后画混淆矩阵看模型把哪些评论分错。这一步我常用ConfusionMatrixDisplay辅助判断。你会发现错误大多出现在含有转折词的句子比如「虽然画面不错但是剧情太弱」模型往往只看局部高频词。看到这些样本后我通常会增加一个规则如果文本中同时出现「但是」和「但是」前有负面词就强制把预测改成负面。这种方法很江湖但效果立竿见影。从那以后我每次做情感分析项目都强制走一遍「先看错误样本、再补规则、最后重训」的循环而不是闷头调参。模型永远只是工具错误样本才是需求的答案。这套 TOP250 短评资源的价值恰恰在于它把整个链路串在了一起——从爬虫到数据清理再到情感模型每一环你都能看到真实的中间产物。希望帮到你。本文还有配套的精品资源点击获取