ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Steam评论爬取与HanLP情感分析流水线实战:从分词到可视化

Steam评论爬取与HanLP情感分析流水线实战:从分词到可视化 简介这是一份基于HanLP的Steam评论爬取与情感分析可视化Python源码面向需要完成课程设计或期末大作业的高校学生。项目通过Steam评论接口抓取真实评论借助HanLP进行情感倾向分析并以可视化图表展示结果代码包含详细注释新手也能看懂。资源包共53个文件主要涵盖Python脚本爬虫与情感分析模块、HTML可视化页面、CSV评论数据集、TXT停用词与词典、PPT展示文档及分析报告等整体压缩包大小25.8MB目录结构清晰便于直接部署使用。项目中附带2077origin.csv等真实数据以及数据挖掘分析报告和演示PPT可快速理解完整流程。目前已有264人学习适合作为Python课程设计、期末大作业的高分参考方案。1. 为什么把“Steam评论HanLP情感分析”做成一条流水线Steam 评论区是个混着黑话、梗和真实反馈的文本池。想快速知道一款游戏最近口碑到底怎么样靠人工翻几百条评论效率太低靠看那一条“好评如潮”又容易被带偏。标题里 HanLP 的定位很清晰中文分词和词性标注的主力工具配合爬取、情感分析、可视化三段搭成一条能反复跑的评论分析流水线最后输出可交互图表而不是一堆 csv。适合三类人独立游戏运营想盯竞品口碑、发行同学想看玩家对版本更新的态度、以及正在用 Python 做文本分析练手的开发者。别一上来就堆深度学习先用评论数据把这条链路跑通后面再谈精度。2. Steam评论爬取请求参数、分页边界与落库字段设计2.1 评论数据的真实入口appreviews JSON 接口爬 Steam 评论最常见的翻车方式是去模拟浏览器打开商店页面再逐条点击“查看全部”那样等着你的是一堆动态加载和反爬脚本。实际上商店页面背后有一个固定 JSON 接口Python 爬虫爬取网页数据时直接打这个接口就行不需要 Selenium。接口格式是https://store.steampowered.com/appreviews/{appid}把 appid 换成游戏编号比如《Baba Is You》是 736260。带上json1参数后返回体就是结构化 JSON字段比从 HTML 里正则抠干净得多。import requests def fetch_reviews(appid, cursorNone, languageschinese, page_size20): url https://store.steampowered.com/appreviews/ str(appid) params { json: 1, language: language, purchase_type: all, filter: recent, num_per_page: page_size, cursor: cursor or } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Accept-Language: zh-CN,zh;q0.9, Referer: https://store.steampowered.com/app/ str(appid) } resp requests.get(url, paramsparams, headersheaders, timeout10) data resp.json() next_cursor data.get(cursor) return data.get(reviews, []), next_cursor逻辑说明参数里filterrecent表示按时间排序适合持续跟踪口碑变化purchase_typeall会把买断用户和挂卡号都算进去后面的情感分析要自己过滤而不是在这里直接把非买断评论丢掉。num_per_page最大只能给 20给大了接口直接返回空列表这是 Steam 端写死的分页大小不能靠调大它来减少请求次数。返回的cursor是下一页凭证第一次请求传空字符串之后原样传递。参数说明languageschinese拿到简体中文但前提是这款游戏支持中文如果语言设为english而游戏没英文评论返回也是空的先确认目标游戏支持哪种语言再定参数。要拿到更多历史评论把filter换成all即可代价是请求次数更多。2.2 分页是循环不是递归cursor 的用法分页逻辑不复杂但有个常见误解有人把 cursor 当成页码数字去加一导致第二页开始全部请求失败。正确的做法是把上一次响应里的cursor字段原样传进下一次请求不用关心它内部怎么编码。import sqlite3 import time def crawl(appid, max_pages50, delay1.0): cursor None for page in range(max_pages): try: reviews, cursor fetch_reviews(appid, cursor) except requests.exceptions.JSONDecodeError: print(f第{page 1}页返回非JSON疑似被拦暂停10秒) time.sleep(10) continue for r in reviews: save_review(r, appid, cursor) time.sleep(delay) if not reviews or not cursor: print(没有更多评论或游标失效结束) break逻辑说明循环退出有两个条件一是接口返回了空reviews二是cursor为空字符串两者满足其一就说明已经翻到尾部。delay是每次请求之间的间隔我一般控制在 0.8 到 1.5 秒之间太快容易被限流。捕获JSONDecodeError是因为限流时接口可能返回一段 HTML 而不是 JSON这时候字符串也能分页但必须停下来等一会再试。参数说明appid用filterrecent时能翻到的页数并不固定热门游戏可能只给你最近几千条冷门游戏可能 20 页就到底了。max_pages设成 50 对大多数分析场景都够用真要爬全量评论区建议按“当天日期减一天”做增量爬取而不是一次性翻完。2.3 落库字段设计为 HanLP 留好原料爬下来的评论如果不落库下游分词和情感分析就得反复请求接口纯属给自己找麻烦。我一般用 SQLite 做本地存储字段尽量贴合后续分析需要别偷懒只存一条文本。下面这个表结构是踩过几次坑后定的字段类型用途review_idTEXT PRIMARY KEY评论唯一 ID去重靠它app_idTEXT游戏 ID方便多游戏对比voted_upINTEGER用户是否点了好评1 好评 0 差评timestamp_createdINTEGER评论发布时间戳秒级review_textTEXT评论原始文本languageTEXT评论语言cursorTEXT抓到该条评论时的游标断点续爬用def save_review(r, appid, cursor): conn sqlite3.connect(steam_reviews.db) sql INSERT OR REPLACE INTO reviews (review_id, app_id, voted_up, timestamp_created, review_text, language, cursor) VALUES (?, ?, ?, ?, ?, ?, ?) conn.execute(sql, ( r[recommendationid], appid, int(r[voted_up]), int(r[timestamp_created]), r[review], r.get(language, ), cursor )) conn.commit() conn.close()逻辑说明recommendationid是评论的稳定 IDINSERT OR REPLACE保证重复请求时不会产生重复数据。voted_up是玩家的主观表态后面算情感分析准确率的对照标签就是它。注意r[review]可能是空字符串这种脏数据在下一章预处理里必须滤掉不能直接喂给 HanLP。数据库表字段里留language是因为有些游戏中英文评论混杂后期可以只分析中文子集。这里提醒一下把cursor存进每一条记录不是单纯为了字段好看。如果爬取中途程序崩了重启后可以取最后一条记录的cursor接着爬不用从第一页重来。我实际用的时候还会单独建一张crawl_log表存每次任务的开始时间和结束游标方便看这次增量爬了多远。3. HanLP分词与评论预处理自定义词典、停用词与词性过滤3.1 为什么先分词再谈情感情感分析的最小单位是词而不是字。“好评如潮”如果按字切就成了“好 评 如 潮”四个孤立的字“好评”的情感权重被拆散了“物理支持”这种游戏圈黑话更没法靠单字判断。所以第一步必须分词。HanLP 和常见分词工具相比优势在于词表大、对中文口语容忍度好还自带词性标注。很多人问 HanLP 分词在 SpringBoot 里怎么集成其实思路一样只是把 Python 换成 Java 调用 API本方案直接用 Python 包省掉跨进程通讯的麻烦。我用过一段时间 jieba 切游戏评论最大感受是它把“不推荐”切成“不 / 推荐”后面要单独做否定词还原。HanLP 虽然同样可能把否定词单独切出但至少词性标注足够细配合自定义词典能把游戏黑话固定成一个词。下面的预处理链路是HanLP 分词 → 词性过滤 → 停用词过滤 → 保留否定词。3.2 最小可运行的分词代码安装 pyhanlp 后首次 import 会自动下载数据包网络不好的时候会卡很久这是正常现象。写代码时建议把长文本按句子划分再逐句分词避免一次性喂太长导致内存压力。from pyhanlp import HanLP def tokenize(text): if not text or len(text.strip()) 2: return [] terms HanLP.segment(text) return [(term.word, str(term.nature)) for term in terms] sample 游戏很好玩但优化太烂不推荐大家现在就买。 for word, nature in tokenize(sample): print(f{word}\t{nature})逻辑说明HanLP.segment返回 Term 列表每个 Term 有word和nature两个属性nature是词性标注比如n名词、v动词、a形容词。把词性保留下来是为了后续过滤名词、动词、形容词才是情感词的主要载体。运行上面代码你会看到“优化”和“烂”被正确标成名词和形容词“不推荐”被切成了“不 / 推荐”这个结果对情感分析是可接受的。参数说明如果机器内存吃紧可以在启动脚本里加 JVM 参数-Xmx512mpyhanlp 是基于 HanLP 1.x 的 Java 实现JVM 堆太小会直接 OOM。另外首次调用HanLP.segment要加载词典耗时可能达到十几秒后续就快了别因此误判程序卡死。3.3 自定义词典把游戏黑话焊死Steam 评论里的“白给”“喜加一”“好评如潮”“库中吃灰”如果按默认词典切大概率是拆散的。补救办法是往用户自定义词典里加词。HanLP 1.x 提供CustomDictionary追加后立刻生效。from pyhanlp import HanLP, JClass CustomDictionary JClass(com.hankcs.hanlp.dictionary.CustomDictionary) CustomDictionary.add(好评如潮, nz) # 自定义词性nz 表示专名 CustomDictionary.add(喜加一, v) CustomDictionary.add(库中吃灰, vi) CustomDictionary.add(白给, v) print(HanLP.segment(这游戏好评如潮我却库中吃灰))逻辑说明CustomDictionary.add的后一个参数是词性简写可以自定义不影响 HanLP 对已有词的切分。加进词典后“好评如潮”会作为一个整体出现情感分析打分时直接命中不用再去计算“好”和“评”的组合关系。要注意自定义词典存在内存里每次启动脚本需要重新加载批量数据可以写一个初始化函数统一执行。参数说明词性只影响过滤规则不影响情感分值。“库中吃灰”标成vi不及物动词是因为它常单独出现在“玩完就库中吃灰”这类句子里标为动词更符合后续过滤逻辑。如果你用的是 HanLP 2.x等效操作是tok.dict_merge {好评如潮: nz}底层道理一样写在上游配置里即可。3.4 词性过滤与停用词表别把“不”删掉分词之后不是所有词都该进情感计算。“的、了、吗、吧、这、那”这类停用词没有情感信息但要保留“不、别、没、未”这些否定词它们是情感反转的关键。很多现成停用词表一刀切删掉所有虚词结果“我不推荐”变成“推荐”情感方向直接反了。STOP_WORDS set(的 了 吧 啊 呢 这 那 你 我 他 它 什么 怎么.split()) def clean_tokens(seq): keep [] for word, nature in seq: nature_code nature[:2] if word in STOP_WORDS: continue if word in {不, 别, 没, 未, 无}: keep.append((word, neg)) continue if len(word) 1 and nature_code in {n, v, a, d, adv, nz, vi}: keep.append((word, nature_code)) return keep逻辑说明clean_tokens先用停用词表滤掉无意义词再单独把否定词捞出来并打上neg标记最后按词性过滤。nature_code取前两位字符是为了兼容 HanLP 输出里v、vn、vi这类细分词性避免漏掉。这样处理后“不”不只没有被丢掉还获得了情绪反转的标记下一章打分时直接参考这个标记。参数说明len(word) 1过滤单字词但对“好”“烂”“值”这类情感强度高的单字形容词会误伤所以我一般只过滤单字名词形容词单字保留。实际跑数据时你会遇到大量“牛”“赞”“卡”“糊”这类单字评价把它们都丢进情感词典反而对召回率提升最明显别套死这个条件。4. 情感分析实现HanLP方案下的词典打分与句式修正4.1 为什么先走词典基线而不是深度学习看到“情感分析”四个字很多人直接想到微调 BERT甚至是视频多模态情感分析那套框架。Steam 评论是纯文本而且充满了“白给”“物理支持”这类圈内黑话训练数据也不好找深度学习模型很可能在你还没把数据准备好时就变成黑匣子训出来一个准确率数字但你不知道它为什么把“打折再买”判成好评。我的建议是先搭词典加分规则这条基线效果好就够用效果差也有明确的迭代方向。视频人物情感分析、多模态情感分析这些方向确实热但短信级的中文评论用不上视觉特征。先把文本基线跑通后面如果真要上模型这条基线还是天然的对照实验组能告诉你模型到底有没有进步。4.2 情感词典打分从词到句子的聚合情感词典本身是一个文本文件每行一个词对应一个极性分数正分表好感负分表负面。打分逻辑很简单遍历上一步产出的 tokens命中词典就累加分数。def load_sentiment_dict(path): scores {} with open(path, r, encodingutf-8) as f: for line in f: line line.strip() if not line or \t not in line: continue word, score line.rsplit(\t, 1) try: scores[word] float(score) except ValueError: pass return scores sentiment_dict load_sentiment_dict(steam_sentiment.txt) def score_tokens(tokens, sentiment_dict): total 0.0 hit_words [] for idx, (word, pos) in enumerate(tokens): if word not in sentiment_dict: continue s sentiment_dict[word] total s hit_words.append(f{word}:{s:.1f}) return total, hit_words情感词典里我放了这些典型的 Steam 评论词格式是词加制表符加分数神作 1.0 好玩 0.8 佳作 0.6 白给 0.5 推荐 0.5 优化差 -0.9 掉线 -0.7 卡顿 -0.8 枯燥 -0.5逻辑说明score_tokens只是机械累加命中词的分值hit_words用来调试——当你发现某条评论被打成负分但实际是好评时打印hit_words就能看到是哪些词在捣乱。情感词典是半自动工程不是一次写完就完事每次跑完批量数据都要抽样看误判。4.3 否定与转折修正解决“不推荐”和“虽然…但…”纯词典打分最大的坑是“不推荐”会被算成正向因为“推荐”加了 0.5而“不”没有参与计算。上一章保留的否定词标记在这里派上用场遇到否定词紧跟情感词时把情感分反转。同时“虽然优化差但很好玩”这种转折句里作者的真实态度在“但”字后面后面句子的权重应该更大。NEGATORS {不, 别, 没, 未, 无} def score_with_context(tokens, sentiment_dict): total 0.0 turn_weight 1.0 for idx, (word, pos) in enumerate(tokens): if word in {但, 但是, 不过, 然而}: turn_weight 1.6 continue if word not in sentiment_dict: continue s sentiment_dict[word] if idx 0 and tokens[idx - 1][0] in NEGATORS: s * -0.8 total s * turn_weight return total逻辑说明turn_weight默认是 1.0句子一旦出现“但、不过、然而”权重就升到 1.6后面的情感词对总分影响更大。“不推荐”被切成了“不 / 推荐”检测到前一个 token 是否定词就把“推荐”的 0.5 乘以 -0.8修正为 -0.4方向正确。这个 0.8 不是拍出来的我对比过几组值0.6 会漏掉部分否定1.0 会把“不怎么样”整句推得太极端0.8 是折中。参数说明turn_weight设成 1.6 是因为游戏评论句式通常短“虽然……但……”的后半句往往只有一两句话不加权就会被前面的负面词盖过。如果处理的是长评可以把这个值提高到 2.0但要注意别让后半句情感词数量多时直接爆分。4.4 阈值与对照标签和用户点过的好评差评比一比打分之后要定分类阈值多少分算好评、多少分算差评。Steam 用户自己点过voted_up这就是天然的验证集。拿它算一遍准确率才知道这套规则到底跑偏了多远。def classify(score): if score 0.2: return positive if score -0.2: return negative return neutral def evaluate(conn, app_id): rows conn.execute( SELECT review_text, voted_up FROM reviews WHERE app_id ? AND LENGTH(review_text) 0, (app_id,) ).fetchall() correct 0 total 0 for text, voted_up in rows: tokens clean_tokens(tokenize(text)) score score_with_context(tokens, sentiment_dict) pred classify(score) label positive if voted_up else negative total 1 if pred label: correct 1 print(f准确率: {correct / total:.2%})逻辑说明这一步把 SQLite 里的评论文本全部读出来跑一遍分词、清洗、打分、分类最后和用户真实表态比较。LENGTH(review_text) 0过滤空评论是前面落库时埋的伏笔。准确率只作参考不等于模型真实效果因为用户点差评但写了“暂时别买”这类中性表达是正常现象但准确率低于 60% 时一定哪里出了问题。参数说明分类阈值 ±0.2 是保守取值避免中性评论被强行推入正负两极。如果你只想看明显怨气可以上调到 ±0.5如果想汇报好评率就按 0.2 保持更多中立样本。准确率评估最好抽 200 条人工复核把机器判错的句子摊开看你会在情感词典里发现自己漏了哪些游戏黑话。5. 避坑从采集到识别四个必看的现实问题这一章全是我在这条链路里摔过的血泪经验。每一条都按“现象 → 原因 → 解决”的顺序说清楚保证你不用重复踩。5.1 空正文评论把数据池搅浑现象统计情感分布时发现差评占比高得离谱下拉明细一看不少记录review_text是空字符串或者整条评论只有“该用户因违规已被封禁”。原因Steam 评论接口会把处罚过的用户评论一起返回这些评论通常没有正文或正文是系统占位文案。上一章的 SQLite 表只是原样落库没做过滤情感分析时空文本被tokenize略过score是 0classify直接判成中性而用户投票是差评所以真正想看的短差评被均匀稀释。解决建表时加一个valid字段或者直接在建表语句里用CHECK约束。更简单的是在爬取落库时判断len(r[review]) 2就跳过。如果数据已经入库执行下面 SQL 清掉DELETE FROM reviews WHERE review_text IS NULL OR LENGTH(review_text) 2;5.2 cursor 被当成普通数字解码导致翻页失败现象第一页评论抓得好好的程序翻到第二页时报400 Bad Request或直接超时重试多次都是同一表现。原因Steam 返回的cursor是一段 base64 变体编码里面还嵌了时间戳和游标位置完全不是“第几页”这个整数。有人为了“搞清楚机制”先把 cursor 解码成数字再传回去接口认不出就拒绝服务。还有人在 SQLite 里存cursor时用的整型字段长度被截断第二页开始全部非法。解决cursor一律用 TEXT 类型存取响应里拿到什么就传什么不做任何解释。注意上一章建表 SQL 里cursor TEXT这个细节不是顺手写的去掉类型约束翻车概率立增。如果不放心打印前 20 个字符看长度通常能看到*eyJ这样的开头这不是加密不用解密。5.3 HanLP 把“好评如潮”切成四个字现象情感词典里明明加了“好评如潮”并给了 1.0 分跑完却一条评论都没命中词频统计里只有“好”“评”“如”“潮”四个孤立字。原因CustomDictionary是进程内存级别的脚本每次重启需要重新调用add。我第一版把加词典的代码放在if __name__ __main__里导入函数时没执行结果自定义词永远没生效。另外“好评如潮”这四个字在默认词典里根本不是词HanLP 不会自动合并成新词。解决把自定义词典和情感词典统一加载成一个函数在任何分词调用前执行。同时给词典加上自检逻辑def check_dict(): terms HanLP.segment(这游戏好评如潮) words [t.word for t in terms] if 好评如潮 not in words: print(自定义词典未生效重新加载) load_user_dict()逻辑说明自检函数每次运行只增加一次分词开销却能避免“词典没生效但分数算了一堆”这种黑匣子问题。词频统计里单字“潮”出现次数多往往不是玩家在夸游戏而是词典没合上这个特征可以用来反向判断预处理是否正常。5.4 “虽然看着值但我不推荐”的极性反转现象准确率评估时发现差评里被误判成好评的句子几乎都有“虽然……但……”结构。比如“虽然打折力度大但游戏根本玩不进去”词典打分时“打折”没分“玩”没分模型只吃到“虽然”后面的中性词把整句判成中性甚至正向。原因转折词之后才是作者真实态度“虽然”引导的从句往往只是铺垫。简单累加式打分没有位置信度给所有情感词相同的权重结果就被前半句拖累。更隐蔽的是“不推荐”这种词HanLP 虽然能切出“不/推荐”但情感词典里只有“推荐”没有“不推荐”修正逻辑一旦漏判整个句子的极性就反了。解决在打分时把“但、不过、然而”之后的情感词乘以 1.6 权重转折词本身不计分。还要专门在情感词典里加“不推荐 -0.6”这个整体词因为 HanLP 对“不推荐”的切分不稳定有时是“不/推荐”有时是“不推荐”两条路都堵上才稳。跑完评估后打印 20 条误判样本看到“不推荐”被当正向第一反应就是情感词典缺完整词条不要怀疑是模型坏了。6. 让结果可交付从图表到每日跑批情感分析算出分数只是中间产物标题里的“可视化”才是让非技术同事愿意相信结果的最后一公里。我一般用 pyecharts 出三张核心图情感占比饼图、Top 情感词词云、按周聚合的情感趋势折线。这三张图拼在一起就是一份能直接汇报的轻量可视化看板想升级成可视化大屏也只需要把生成好的 HTML 嵌入大屏框架。from pyecharts.charts import Pie, Line from pyecharts import options as opts def weekly_line(conn, app_id): rows conn.execute( SELECT strftime(%Y-%m-%d, timestamp_created, unixepoch) AS day, AVG(CASE WHEN voted_up 1 THEN 1 ELSE 0 END) AS good_ratio FROM reviews WHERE app_id ? GROUP BY day , (app_id,)).fetchall() line ( Line() .add_xaxis([r[0] for r in rows]) .add_yaxis(好评率, [r[1] for r in rows]) .set_global_opts(title_optsopts.TitleOpts(title每日好评率趋势)) ) line.render(freport_{app_id}.html)画图代码不复杂关键是数据要可信。趋势线比绝对值有用昨天好评率 80% 今天掉到 62%比“好评率 70%”这个数字更能反映版本更新引起的口碑波动。我每年换一次跑批脚本都会把上周跑出来的情感词频表重新过一遍把新出现的游戏黑话加进自定义词典再把在新数据上误判率高的词调整分数然后再交付给运营。这个习惯帮我躲过了不少“模型怎么突然不准”的玄学时刻。踩过这么多坑之后我最大的教训是别让任何一步成为黑匣子。爬虫要留游标预处理要留词性情感分析要能打印命中的词可视化要有时间轴。每一步都能单独验证整条流水线才不会在你被问到“为什么这个好评率比上周低了十个点”时答不上来。希望帮到你照着这条链路跑通第一版比纠结深度学习模型强得多。本文还有配套的精品资源点击获取
返回列表