☰
基于HanLP的Steam评论情感分析:爬虫+分词+可视化完整项目
2026/10/3 7:56:07 网站建设 项目流程

简介:面向Python课程设计与期末大作业场景,这套基于hanlp的Steam评论情感分析可视化项目,完整实现了评论爬取、文本清洗、情感极性判断与可视化展示的闭环。项目基于Steam评论接口抓取真实数据,配合hanlp自然语言处理能力进行情感倾向分析,最终以图表形式直观呈现结果,代码注释详细、模块划分清晰,新手也能快速读懂并部署运行。资源包共53个文件,包含多个Python源码、CSV/TXT数据文件、爬虫运行效果截图、数据挖掘分析报告Word文档,以及答辩PPT和配置文件等,整体压缩包大小25.8MB,目录结构便于按功能模块检索使用。目前已有264人学习下载。项目不仅能直接作为高分的课程设计或期末大作业,还可更换游戏评论数据并扩展分析对象,在数据采集、文本处理和可视化展示方面具备良好的参考与复用价值。

1. 它不是泛泛的项目模板,而是一套能把期末大作业直接交差的 Steam 评论情感分析流水线

这套基于 HanLP 的 Steam 评论爬取、情感分析与可视化 Python 源码,是热门课设题目里少有的完整包。它拿《赛博朋克 2077》当样本,爬 Steam 官方评论接口拿数据,再用 HanLP 做中文分词和情感打分,最后输出 HTML 图表、CSV 结果和一份能直接改的答辩报告。对正在找期末大作业的人来说,这份资源的意义不只是省事,而是数据采集、清洗、建模、可视化每个环节都有对应文件,新手按 requirements.txt 装完依赖就能进入复现。文章后面我会把文件分工、接口参数、HanLP 的两种分析方案和踩过的坑都拆开讲一遍。

2. 项目结构与数据主链路:从 review.json 到 2077.csv,每个文件都有明确分工

2.1 文件盘点:脚本、数据、产物三类各管什么

拿到压缩包之后别急着跑代码,先把根目录过一遍。2077CommentAnalyze-master 里的文件可以分成三类:第一类是脚本,也就是 main.py、sentiment_analysis.py、sentiment_analysis2.py;第二类是数据,包括 review.json、2077origin.csv、2077.csv、reviews_over.txt;第三类是辅助文件,dict.txt、stopwords.txt、html 目录、材料目录、PPT 目录。

表里面的对应关系是这样的:

文件角色实际用途
main.py爬虫入口请求 Steam 评论接口,把回包落成 JSON 和原始 CSV
sentiment_analysis.py情感分析主脚本基于 HanLP 分词加 dict.txt 词典打分,产出 2077.csv
sentiment_analysis2.py第二版分析脚本用预训练分类器做对比实验,结果列稍有不同
review.json原始缓存Steam 接口返回的评论数组,没有这只剩爬虫可以重新生成
2077origin.csv清洗前数据从 JSON 转出来的规整表,字段包括作者、语言、是否好评
2077.csv分析结果每一条评论带 sentiment_score 和情感标签
dict.txt情感词典每行一个情感词和对应的权重分数
stopwords.txt停用词表过滤“的、了、我”这类无意义词
html可视化输出pyecharts 渲染出来的图表页,直接浏览器打开
材料、PPT答辩素材docx 分析报告和演示文稿,可替换数据复用

一眼看下来,这个项目的设计思路是“缓存优先”:爬虫把原始回复先存成 review.json,后面所有处理都从这个文件开始,这样就算接口反爬导致断点重跑,也不需要重新把全部评论拉一遍。

2.2 主链路:四个模块连成一条可替换的流水线

整个项目的数据流是一条单向链路:爬虫先把评论写进 review.json;数据转换脚本把 JSON 摊平成 2077origin.csv;情感分析脚本读 CSV,逐条跑 HanLP 分词和词典匹配,把分数写进 2077.csv;可视化脚本再读结果 CSV,生成 html 目录下的图表。

这里要注意 sentiment_analysis.py 和 sentiment_analysis2.py 的关系。前者是词典打分法,也就是把 dict.txt 里的词和权重累加;后者走的是 HanLP 预训练情感分类器。两版输出的列不完全一样,但最终落点都是 2077.csv。课程设计里两种方法都跑一遍,反而能形成“词典基线+模型对比”的分析结构,答辩时更有说头。

如果 review.json 里不是数组而是接口原包,转 CSV 的代码要按实际结构取值。常见做法是这样:

import json import csv with open("review.json", "r", encoding="utf-8") as f: data = json.load(f) # 接口回包有时套了一层 query_summary,取不到 reviews 就换个取法 reviews = data if isinstance(data, list) else data.get("reviews", []) with open("2077origin.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["recommendationid", "author", "language", "voted_up", "timestamp_created", "content"]) for r in reviews: writer.writerow([ r.get("recommendationid"), r.get("author", {}).get("steamid"), r.get("language"), r.get("voted_up"), r.get("timestamp_created"), r.get("review", "").replace("\n", " ") ])

这里三个细节容易踩坑:第一,CSV 文件编码一定用 utf-8-sig,Excel 直接打开才不会把中文读成乱码;第二,author 字段是嵌套结构,要走.get("author", {}).get("steamid"),写作二级键时不能直接 r.get 到底;第三,评论里的换行符要替换成空格,否则 CSV 里一行记录会被拆成多行。文件里 2077origin.csv 就是从原始评论转出来的标准表,后面情感分析只依赖该表,不会再回头翻 JSON。

review.json 转 CSV 成功后,检查一下行数:Steam 对应《赛博朋克 2077》的接口响应里,如果num_per_page=100,一趟跑下来能拿到几百条到几千条评论,数量大会被接口限流,所以爬虫部分的节奏控制决定了这个转换脚本能拿到多少原料。

3. Steam 评论接口爬取:游标、语言、限流三处最容易翻车

3.1 接口地址与核心参数

Steam 的评论数据不是从 HTML 页面里硬解析出来的,而是走官方评论接口,这也是这个项目能稳定爬到大量评论的原因。接口地址格式是:

https://store.steampowered.com/appreviews/1091500

其中 1091500 是《赛博朋克 2077》的 appid,如果换游戏,去商店地址栏里找对应的数字即可。关键参数如下:

参数含义建议值
json返回 JSON 而非 HTML1
cursor分页游标,第一页传 *每页回包里的 cursor 覆盖
num_per_page每页条数100,接口上限
language评论语言all 或 schinese
purchase_type是否只取购买用户的评论all
day_range时间范围,不限制就传 9223372036854775807全部评论

language 这个参数尤其要说一下。all会把英文、俄文、中文混杂在一起,HanLP 对中文分词很利索,对英文只能按空格硬切,所以情感词典里如果没有英文词,英文评论得分基本都是 0。如果只想拿中文评论,把language=schinese填进去,爬下来的数据会更贴合中文情感分析流程。

3.2 带游标翻页和断点续传的爬虫写法

这个项目里的 main.py 核心思路是先给接口发送请求,拿到响应后把 cursor 取出来供下一页使用。我一般会这样写爬虫循环:

import json import time import requests APPID = 1091500 URL = f"https://store.steampowered.com/appreviews/{APPID}" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36" } def fetch_reviews(cursor="*", num_per_page=100): params = { "json": 1, "cursor": cursor, "num_per_page": num_per_page, "language": "all", "purchase_type": "all", "day_range": 9223372036854775807, } resp = requests.get(URL, params=params, headers=HEADERS, timeout=15) resp.raise_for_status() return resp.json() all_reviews = [] cursor = "*" total = 0 while True: payload = fetch_reviews(cursor) if payload.get("success") != 1: break total = payload.get("query_summary", {}).get("total_reviews", 0) reviews = payload.get("reviews", []) if not reviews: break all_reviews.extend(reviews) cursor = payload.get("cursor", "") # 抓到总数或者游标不动就结束,防止死循环 if len(all_reviews) >= total or not cursor: break time.sleep(2) with open("review.json", "w", encoding="utf-8") as f: json.dump(all_reviews, f, ensure_ascii=False, indent=2) print("collected", len(all_reviews))

这段代码里值得注意的参数有三个。第一个是 cursor,它由服务端返回,不能自己随便乱造,中途停止后要拿上次的游标续传。第二个是 timeout=15,Steam 接口响应不稳定,没有超时控制会让爬虫挂死。第三个是 sleep(2),两秒间隔看起来保守,实际上对短平快的课程设计更友好,跑一页分析一眼再继续觉得太慢就把 sleep 降到 1,但别直接删掉,不然容易被临时限流。

评论数据结构里还有两个能直接复用的字段:voted_up代表该条评论是否推荐,这个命中可以作为情感分析的对照标签;timestamp_created是 Unix 时间戳,如果后面想按月份拆分评论趋势,这个字段可以直接用 datetime 转换。

3.3 拿不到数据时先看日志,再决定重试策略

项目里带了日志.zip,说明作者在开发时是把运行过程记录下来的。如果跑 main.py 没有任何数据,先不要改爬虫逻辑,而是调日志看是哪个环节拦住了。常见情况是第一次请求就失败,这时单独打印一次响应内容,确认返回的是 JSON 还是被重定向到登录页。

如果单次请求成功但翻到第二页就报错,大概率是频率问题。我的做法是设置一个简单的失败重试,重试三次且每次间隔递增:

def get_with_retry(params, retries=3): for i in range(retries): try: resp = requests.get(URL, params=params, headers=HEADERS, timeout=15) if resp.status_code == 200: return resp.json() except requests.RequestException as e: print(f"attempt {i + 1} failed: {e}") time.sleep(2 + i * 2) return None

这里的 retries 和递增 sleep 是为了应对接口偶发抖动,不能写成无限重试,否则程序会卡在一个死循环里。拿到 review.json 之后,后续步骤全部离线跑,爬虫只需要跑一次即可。

4. HanLP 情感分析实操:词典打分和预训练模型两条线都跑通

4.1 依赖安装与模型加载

项目采用 HanLP 作为中文处理核心,requirements.txt 里锁定的几个库至少包含 hanlp、requests、pandas、pyecharts。安装依赖用常规方式:

pip install -r requirements.txt

HanLP 的模型加载很有特点,它跟 jieba 不一样,不是一装就能用,而是首次调用hanlp.load()时把模型文件拉到本地缓存。项目脚本常用的分词模型是PKU_NAME_MERGED_SIX_MONTHS_CONVSEG,加载方式如下:

import hanlp tokenizer = hanlp.load("PKU_NAME_MERGED_SIX_MONTHS_CONVSEG") print(tokenizer("这游戏优化太差了,玩了十分钟就崩溃"))

本地没有模型时,这条语句会显示下载进度,耐心等它跑完即可。模型加载成功后,分词器把中文句子切成词语列表,后面进行情感词匹配的前提是这些词语能被正确识别出来。

4.2 dict.txt 加 stopwords.txt 的词典打分法

sentiment_analysis.py 采用词典累加思路。dict.txt 里每行是一个情感词和权重分数,stopwords.txt 是无意义词表。完整流程分两步:先读词典和停用词,再对每条评论分词、过滤、打分。

def load_sentiment_dict(path): sentiment = {} with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue parts = line.split() if len(parts) == 2: sentiment[parts[0]] = float(parts[1]) return sentiment def load_stopwords(path): stopwords = set() with open(path, "r", encoding="utf-8") as f: for line in f: stopwords.add(line.strip()) return stopwords

字典文件里词的权重有正有负,比如“垃圾”可能是负数,而“好玩”是正数。如果 dict.txt 实际使用的是逗号分隔而不是空格,把 split 的参数改成逗号即可,整体逻辑不变。

接下来是打分函数,这个函数的逻辑是每条评论的最终倾向由分词后所有命中词的权重累加决定:

def review_score(text, tokenizer, sentiment, stopwords): tokens = tokenizer(text) tokens = [t for t in tokens if t not in stopwords and len(t) > 1] score = 0.0 for token in tokens: if token in sentiment: score += sentiment[token] return score

停用词过滤为什么要保留长度大于 1 的词汇,原因很好理解:中文里单个字的情感判断经常不可靠,而且这个项目的数据里混着部分英文单词,单字母过滤掉能减少无匹配的噪音。dict.txt 里如果已经有相关词,单个字的词条也会被分数覆盖,但一般情况下多字词更稳定。

把上面函数套到 2077origin.csv 的 content 列上,逐行计算,再按分数打标签:

def score_to_label(score): if score >= 0.5: return "positive" if score <= -0.5: return "negative" return "neutral"

这里直接按 score 是否大于零判断会有一个问题:很多评论是中性表达,比如“游戏流程大约 30 小时”,既不算正面也不算负面。我把阈值拉宽到 0.5,project 里的默认逻辑可能用 0 做边界,但课程设计里保留 neutral 类会让图表更接近真实分布。

4.3 第二版:HanLP 预训练分类器

如果只跑词典打分,答辩时很容易被老师问“这个字典覆盖不到的网络词怎么办”。sentiment_analysis2.py 存在的意义就是提供一个能对比的模型方案。HanLP 预训练分类器的加载方式是:

import hanlp classifier = hanlp.load(hanlp.pretrained.classifiers.SENTIMENT_CTL_ELECTRA_SMALL) result = classifier("游戏剧情很精彩,但 bug 真的多") print(result)

这个分类器输出通常是概率分布,可以直接看哪一类得分高,也可以把结果映射成“正面/负面”。它的优点是不需要人工维护词典,缺点是对超长评论会截断,所以长评精度不一定比词典法好。

在实际课程设计报告里,最合理的做法是两条线都跑,然后对比两种方法在同样数据上的标签一致性。如果一致性低于百分之七八十,你的分析结论里可以写“因为词典法覆盖了预训练模型忽略的长文本语义片段”。

4.4 结果合并输出到 2077.csv

情感分析的输出文件是 2077.csv,它与 2077origin.csv 一一对应,只是多出两列。输出代码的逻辑大致如下:

import csv with open("2077origin.csv", "r", encoding="utf-8-sig") as f: reader = list(csv.DictReader(f)) with open("2077.csv", "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) writer.writerow(["content", "voted_up", "sentiment_score", "sentiment_label"]) for row in reader: text = row["content"] score = review_score(text, tokenizer, sentiment, stopwords) label = score_to_label(score) writer.writerow([text, row["voted_up"], score, label])

这一步输出的 sentiment_label 就是后面可视化要用的核心字段。如果爬到的数据量大,逐条调用分词器会比较慢,可以先建一个 dict 做评论文本去重,再去掉重复的计算,能省下不少时间。

5. 常见问题与排查:三次翻车记录,从模型缺失到全零得分

5.1 现象一:HanLP 加载时报没有模型文件

现象:第一次运行 sentiment_analysis.py,终端里出现类似 cannot find model 或 no such file or directory 的报错,有时候还会卡在下载进度条一动不动。

原因:HanLP 2.x 的模型名和 1.x 不一样,项目脚本里写的模型名如果和当前安装的 hanlp 版本不匹配,就会触发找不到模型。另外模型下载实际是异步的,网络波动会导致模型文件写入一半。不要理解为“代码有问题”,这在 HanLP 用户里也是高频翻车点。

解决:先执行 pip show hanlp 确认版本,再到 HanLP 的预训练模型列表里找到当前版本支持的模型名,替换脚本中的字符串。如果模型文件是断点损坏的,删掉本地缓存目录后重新加载,等日志里出现模型就绪提示再继续。本人写这个项目时就换过一次模型名,之后固定用 PKU_NAME_MERGED_SIX_MONTHS_CONVSEG 的情况下没有再出过问题。

5.2 现象二:Steam 接口返回 success 0 或抓不到评论

现象:main.py 跑完 debug 日志,review.json 里是空列表,或者打印响应内容时看到 {"success": 0}。

原因:success 为 0 多数是请求被拒绝或参数错误。尤其频繁请求不加间隔时,接口会返回空结果而不是报错。另一个常见诱因是 appid 填错,比如把游戏商店地址里的数字当成别的字段做了拼接。

解决:把 Headers 里的 User-Agent 改成浏览器风格,加上 Accept-Language 字段;每次请求之间 sleep 2 秒;单独打印一遍响应结果,确认 total_reviews 大于零再跑整个循环。只要你确认 appid 是 1091500 且语言参数合法,success 通常能正常返回。

5.3 现象三:情感分析得分全部为零

现象:2077.csv 里 sentiment_score 列所有值都是 0,正负标签分布严重失衡。

原因:要么 dict.txt 加载逻辑没有读到内容,要么词典里的词根本切不出来。比如“不好”在分词结果里被切成“不好”和“不”“好”,而词典里没有“不好”这一条,得分就会归零。另一个隐蔽原因是停用词列表误删了情感词,把“喜欢”放到了 stopwords.txt 里,会导致很多积极表达被直接滤掉。

解决:在情感分析函数里打印前 20 条评论的分词结果,肉眼确认高频词是否被正确切分;再打印 dict.txt 命中的条目数,确认权重确实被读入。如果切词粒度不对,最直接的办法是把该复合词及其权重追加进 dict.txt。我还习惯跑一条样本评论输出中间过程,比如 “这游戏真好玩” 分词后只剩“好玩”命中,分数就变成正数,这就说明链路通了。

6. 进阶玩法:自定义情感阈值与人工抽样复核,换游戏评估时也不会全盘翻车

6.1 调整 dict.txt 权重和阈值,让结果贴合目标游戏

这个项目默认的 dict.txt 是针对《赛博朋克 2077》场景打磨过的,里面有很多游戏相关词条。如果你把它改成另一个游戏的课程设计,首先要做的就是扩充词典。我的习惯是保留原词典不动,新增一个 custom_dict.txt,两个文件在加载时合并:

def merge_dict(base_path, extra_path): merged = load_sentiment_dict(base_path) extra = load_sentiment_dict(extra_path) merged.update(extra) return merged

这里用 update 而不是逐行追加,是因为merged.update(extra)可以直接覆盖同名词条,优先使用自定义文件里的权重。对某个游戏的特定词语,比如“抽卡”“武器手感”“平衡性差”,放进 custom_dict.txt 就能让打分结果更贴近该游戏玩家舆论的实际倾向。

阈值调整同样重要。原脚本默认可能用 0 分作为正负边界,但评分分布是右偏的,大量评论锁定在 0 分附近。我建议把标签划分改成:

def adaptive_label(score, pos_threshold=0.5, neg_threshold=-0.5): if score >= pos_threshold: return "positive" if score <= neg_threshold: return "negative" return "neutral"

pos_threshold 和 neg_threshold 是根据实际数据分布定的,不要先写死。跑一遍全量数据,把分数分布拉出来,看正负样本量再回填阈值,这样得出的结论更有说服力,答辩时也能回答“为什么这么多中性评论”的问题。

6.2 人工抽样复核让图表结论可信

可视化图哪怕画得再好看,也逃不开“结果是否符合直觉”的拷问。我完成 2077.csv 后都会加一个抽样审核步骤,随机抽 15 条 negative 和 10 条 positive,把评论原文打出来人工读一遍:

import random import pandas as pd df = pd.read_csv("2077.csv") sample = pd.concat([ df[df.sentiment_label == "negative"].sample(15, random_state=42), df[df.sentiment_label == "positive"].sample(10, random_state=42) ]) for _, row in sample.iterrows(): print(f"{row.sentiment_label} | {row.sentiment_score:.2f} | {row.content[:60]}")

人工读一遍后,你会发现词典漏掉了不少反讽表达,比如“优化得太好了”这种句子,词典打分很可能偏正,但实际意思完全相反。这时在 dict.txt 里补权重并非长久之计,因为反讽句没有固定词汇。更好的做法是在最终报告里加一个 limitation 段落,写明“基于词典的方案无法处理反讽,后续可引入预训练模型做对抗验证”,这句话反而比硬调词典更显专业。

从那以后,我每次换数据集做情感分析,都会先跑一遍这个抽样复核,再决定展示哪个版。这个习惯帮我挡了不少次数据翻车,希望这套资源和方法也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询