资讯详情

资讯详情

城市评论情感分析实战:从爬虫采集到数据清洗全流程指南

简介该压缩包是一个面向潍坊与淄博旅游评论数据的完整爬虫与情感分析项目适用人群包括Python爬虫与自然语言处理入门学习者、相关课程设计参与者以及需要了解游客反馈的旅游从业者和决策者。项目从评论采集到情感倾向判断形成了一条完整链路利用爬虫抓取约5万条城市评论随后对数据进行清洗、预处理与情感分析最终输出游客对两座城市的整体评价、满意度及不满意细节可直接用于旅游体验调研或舆情参考。压缩包共1988个文件以Python脚本、CSV评论数据、TXT文本和JSON配置文件为主同时包含JavaScript、类型定义、文档及少量图表资源整体约29.59MB目录结构清晰便于按采集、数据、分析、可视化等模块查阅。已有587人学习下载。通过该项目读者可得到可复用的爬虫代码、分城市存储的评论数据集、情感分析实现及结果图表并掌握针对具体城市评论的文本挖掘思路为后续扩展分析或论文写作提供扎实的参考基础。1. 5万条城市评论情感分析先想办法把数据洗干净再谈模型先说结论这个项目真正的门槛不在爬虫也不在情感分析算法而在“5万条评论抓回来后你只有大约3万条能用”。城市评论这个场景用户会大量写“哈哈哈哈环境绝了”“排队两小时再也不来”这种夹杂语气词、表情符号和网络梗的短文本否定词还特别多。直接拿通用情感模型跑准确率经常不到七成和抛硬币差不了多少。这个标题要做的事其实是一条完整的链路用爬虫采集某个城市/多个城市的商家评论落到数据库里做清洗去重再分城市、分店铺做情感倾向分析最后得到“这个城市商户口碑整体是偏正面还是偏负面”的量化结果。适合想给城市门店选品、给区域运营做口碑监控或者单纯想练手完整数据流水线的从业者。这篇我就按我做过的方案把每一步拆开讲代码都是能直接跑的。2. 用 requests 和 sqlalchemy 搭采集链路从页面 URL 到入库的最小可跑代码2.1 为什么是 requests 而不是 scrapy5 万条数据考虑的是性价比一提到爬虫很多人的第一反应是 scrapy甚至是分布式爬虫。但 5 万条城市评论这个量级真的不需要上框架。单进程 requests 加一点并发控制一两个小时就能跑完维护成本也低得多。用 scrapy 的收益要等数据量到百万级、需要断点续爬和调度管理时才明显到那个阶段再去研究爬虫管理平台和分布式细节也不迟。我的选型理由很简单requests 的代码是线性的哪里出错肉眼就能看出来。抓 5 万条评论单线程跑大约两三个小时把每条请求间隔控制在 1 到 2 秒对目标站点的压力也小。早期我踩过最重的坑就是高估了速度需求上了 scrapy结果一周里有三天在处理代理和去重队列核心的评论数据反而没抓多少。城市评论的数据来源常见的是点评类平台的城市商家页包括餐饮、酒店、景点几类。这类页面的评论列表通常是滚动加载所以不建议直接解析初始 HTML要先看浏览器 Network 面板里的 XHR 请求找到返回 JSON 的评论接口。下面这套代码把“拿 HTML/JSON → 解析评论字段 → 入库”完整串起来适用于绝大多数返回 JSON 的评论接口。2.2 抓取评论首页数据请求头、超时与重试是三个保命参数import requests import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36, Accept: application/json, text/plain, */*, Referer: https://example-city-review-site.com/, Accept-Language: zh-CN,zh;q0.9, } def fetch_reviews_json(url, params, retries3): for attempt in range(retries): try: resp requests.get(url, headersHEADERS, paramsparams, timeout10) if resp.status_code 200 and resp.json(): return resp.json() if resp.status_code in (403, 429): # 被限流时先退避通常等几秒就好 time.sleep(random.uniform(3, 5) * (attempt 1)) elif resp.status_code 500: time.sleep(random.uniform(2, 4)) except requests.RequestException as e: # 超时和连接断开都算瞬时错误 print(f请求失败{e}第 {attempt 1} 次重试) time.sleep(random.uniform(1, 2) * (attempt 1)) return None这段代码的核心是“有限重试 退避等待”。timeout10 表示连接和读取都最多等 10 秒避免某个慢接口把整个采集卡死。处理 403 和 429 的时候等待时间按指数增长第一次停 3 到 5 秒第二次停 6 到 10 秒。你实际调参的时候看日志就行如果某个页面连续重试三次都失败就别再硬抓直接记下来跳过去最后统一补采。解析 JSON 评论的代码比解析 HTML 稳定得多def parse_comment_items(data): items [] # 不同站点的 JSON 结构不一样这里只给出最常见的两层结构 for card in data.get(reviews, []): city card.get(city, ) shop_name card.get(shop_name, ) rating card.get(rating, 0) # 评论内容偶尔是 null要兜底 content (card.get(content) or ).strip() if not content: continue items.append({ city: city, shop_name: shop_name, rating: rating, content: content, comment_time: card.get(comment_time, ), }) return items注意这里有个参数细节rating 保留原始数值别在采集阶段做转换否则后面分析“评分和情感分数是否一致”时会丢失精度。content 为空字符串的评论直接跳过这类数据没有分析价值还会把情感模型的输出带偏。2.3 sqlalchemy 储存评论数据表结构设计与批量写入参数5 万条评论拿内存 list 存肯定不行爬虫中断一次就全丢了。我习惯直接用 SQLAlchemy 连 MySQL定义一张评论表字段不多但要把去重键和索引一次建好。from sqlalchemy import create_engine, Column, Integer, String, Float, Text, DateTime from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.declarative import declarative_base from datetime import datetime Base declarative_base() class CityReview(Base): __tablename__ city_reviews id Column(Integer, primary_keyTrue, autoincrementTrue) city Column(String(32), indexTrue) # 城市名聚合分析时用 shop_name Column(String(128), indexTrue) # 店铺名 rating Column(Float, default0.0) # 原始评分可能缺失 content Column(Text) # 清洗前的原文 content_md5 Column(String(32), uniqueTrue, indexTrue) # 去重指纹 comment_time Column(String(32), default) created_at Column(DateTime, defaultdatetime.now) engine create_engine( mysqlpymysql://user:password127.0.0.1:3306/review_db?charsetutf8mb4, pool_size10, pool_recycle3600 ) Base.metadata.create_all(engine) Session sessionmaker(bindengine)content_md5 字段是最关键的它加了 unique 约束重复评论第二次入库时数据库直接报错天然挡住了脸。charset 必须写 utf8mb4因为评论里全是 emoji 和特殊符号utf8 存不住。pool_size10 是并发写入时的连接池大小如果后面决定用多线程采集可以调到 20。先用单线程的话默认值就够。批量入库的写法def save_reviews(session, review_items, batch_size500): for i in range(0, len(review_items), batch_size): batch review_items[i:i batch_size] session.add_all([ CityReview(**item, content_md5md5_fingerprint(item[content])) for item in batch ]) try: session.commit() except Exception: # 重复数据会导致 unique 冲突回滚后逐条试能保留更多数据 session.rollback() for item in batch: try: session.add(CityReview(**item, content_md5md5_fingerprint(item[content]))) session.commit() except Exception: session.rollback() continuebatch_size500 是我在 MySQL 上试过比较稳的阈值。小于 100 时 commit 次数太多耗时翻倍大于 1000 时一次语句过大出错回滚的成本也高。注意 save_reviews 里的 md5_fingerprint 函数在下一章会定义这里先用着。3. 清洗和去重环节把 5 万条评论变成 4 万条有效语料的完整处理3.1 清洗流程的四个环节去标签、去符号、压缩重复、兜底城市评论的文本质量比商品评论更混乱。店铺名、地址、电话会被系统自动拼接进文本表情符号和“哈哈哈哈”无处不在还有人会用“不错不错不错不错”这种重复短语凑字数。清洗阶段我做四件事第一步去 HTML 标签虽然 JSON 接口一般不返回 HTML但某些平台的评论内容里会混入br换行标签第二步把非中英文和常见标点之外的字符移除只保留中文、英文、数字以及逗号句号问号感叹号第三步压缩超过 3 次的连续重复字符第四步去除纯空白和长度过短的评论。import re def normalize_text(raw): # 1. 去 HTML 标签 text re.sub(r[^], , raw) # 2. 保留中文、英文、数字和常用标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、…\s], , text) # 3. 压缩连续重复字符好吃好吃好吃好吃 - 好吃好吃好吃 text re.sub(r(.)\1{4,}, r\1\1\1, text) # 4. 合并多余空格 text re.sub(r\s, , text).strip() return text第 2 步的正则是最容易出错的地方。方括号里的脱字符表示“取反”意思是保留中文、大小写英文、数字、顿号句号叹号问号省略号以及空白其余全部删除。这里故意没保留分号和引号因为在评论语料里它们的信息量很低。正则(.)\1{4,}的作用是把任意连续出现 5 次及以上的字符压缩成 3 次“哈哈哈哈哈哈哈哈”就变成“哈哈哈”既保留语气又限制了重复带来的噪声。注意这个压缩只对单字生效对“不错不错不错”这类整词重复不起作用需要在去重阶段单独处理。3.2 用 MD5 指纹做精确去重比全表比对快一个量级评论去重不能直接SELECT content FROM table WHERE content ...几万条数据逐条比对要把数据库跑死。正确做法是对清洗后的文本计算 MD5用哈希值判重。相同文本必然生成相同 MD5虽然理论上存在碰撞但对这个量级的短文本实际概率可以忽略。import hashlib def md5_fingerprint(text): return hashlib.md5(text.encode(utf-8)).hexdigest() def deduplicate(items): seen set() unique_items [] for item in items: clean_content normalize_text(item[content]) if len(clean_content) 10: continue fp md5_fingerprint(clean_content) if fp in seen: continue seen.add(fp) item[content] clean_content item[content_md5] fp unique_items.append(item) return unique_items这里有两个参数值得细说。最短长度设为 10是因为城市评论里常见的“很棒”“一般”只有几个字信息量太低情感分析里很容易被误判但也别把阈值设得太高20 字会丢掉大量有效的中短评。seen 集合用内存存指纹5 万条评论的 MD5 也就几百 KB完全没有压力。我在实际项目里还发现一个现象同一用户在不同平台转发同一条评论原文几乎一致但标点有差异。所以去重前要先 normalize_text把全角符号和多余空格清掉否则去重形同虚设。3.3 停用词表构建不要直接抄网上的通用版本情感分析不是分词任务停用词表的作用不是删光虚词而是删掉会干扰情感分数的高频噪声词。城市评论里出现频率极高但毫无情感倾向的词语包括“感觉”“觉得”“这家”“东西”“然后”“反正”“一个”等等。网上下载的 1500 词停用表是针对新闻语料的用在评论上反而会把“好”“坏”之外的情感词误伤。我的做法是先用 jieba 对所有清洗后的评论分词统计词频 Top 500然后人工筛一遍把词频高但情感中性的词挑出来加入停用词表。这个筛选过程大约需要半小时但对后续所有分析都是正收益。词表本身是纯文本文件每行一个词加载方式和停用词常规做法一致。import jieba STOPWORDS set() with open(city_review_stopwords.txt, encodingutf-8) as f: for line in f: word line.strip() if word: STOPWORDS.add(word) def tokenize_for_analysis(text): words jieba.lcut(text) return [w for w in words if w not in STOPWORDS and w.strip()]分词之后不要急着丢原始文本。我一般会把清洗后评论、分词结果、情感分数分开存因为后面做词云和负面主题抽取还要用分词结果。城市评论里“市中心”“地铁站”“停车场”这种地点词会频繁出现它们不是噪声而是城市体验的维度词应该保留这对判断“这个城市交通便利度口碑如何”很有价值。4. 情感分析从基线到落地snownlp 跑分之后再叠一层词典修正4.1 城市评论不是电商评论为什么直接跑通用模型会翻车情感分析这块最省事的做法是用 snownlp 对每条评论算一个 sentiment 分数大于 0.5 判正面小于 0.5 判负面。snownlp 底层是朴素贝叶斯模型理论上一个函数就能用。但城市评论有个要命的特点口语化程度极高否定词常和程度副词叠在一起“没有想象中那么好吃”“也不是很难吃”这种句子比比皆是。通用模型在电商评论上训练得多遇到这种句式经常出错。所以我的落地路径分两层。第一层用 snownlp 跑基线快速得到一个大体分布第二层叠一个情感词典修正模块专门处理否定词和程度副词。不要一上来就微调 BERT 或者做多模态情感分析——那需要标注数据、GPU 和额外的文本之外的模态这个项目的数据规模撑不起而且完全没必要。4.2 修正模块的完整代码否定词窗口和程度副词系数from snownlp import SnowNLP NEGATION_WORDS {不, 没, 无, 非, 莫, 别, 未} INTENSIFIERS { 非常: 1.6, 特别: 1.6, 极其: 1.8, 太: 1.3, 很: 1.2, 有点: 0.7, 稍微: 0.7, 比较: 0.9, } def adjusted_sentiment(text, window3): base_score SnowNLP(text).sentiments words jieba.lcut(text) score base_score # 先处理程度副词把 0.4 的“有点差”拉回 0.28 for idx, w in enumerate(words): if w in INTENSIFIERS: factor INTENSIFIERS[w] score 0.5 (score - 0.5) * factor # 再处理否定词找到否定词后看它后面 window 个字里有没有情感词 # 如果是否定 负面词如“不难吃”整体应偏正面 for idx, w in enumerate(words): if w in NEGATION_WORDS: after words[idx 1:idx 1 window] if any(w in POSITIVE_WORDS or SnowNLP(w).sentiments 0.6 for w in after): score 1 - score elif any(w in NEGATIVE_WORDS or SnowNLP(w).sentiments 0.4 for w in after): score 1 - score return score这套逻辑里最关键的是 window3 这个参数。它表示否定词只影响后面 3 个字范围避免把“不是这家店但服务员态度还行”这种跨分句文本整体翻转。window 太小找不到情感词太大又会误伤。我用下来 3 是最优解句子短的时候改成 2 也行。POSITIVE_WORDS 和 NEGATIVE_WORDS 是两个自定义词表需要维护我通常各放 50 到 100 个高频词把“难吃”“脏乱”“划算”“惊艳”这类评论常用词手工放进去比全部用模型判断可靠得多。这里提一个参数细节程度副词用0.5 (score - 0.5) * factor而不是直接乘是为了保持分数始终在 0 到 1 区间。score0.6 遇到系数 1.6会变成 0.66而不是 0.96这样不会把本来微正的句子变成极端正面。4.3 城市级聚合分析均值和中位数之外还要看标准差每一家店铺的情感分数算出来后真正的项目交付物是从城市维度汇总的画像。把评分、情感分数、评论数按城市分组最直接的分析是看平均情感分但这里有个隐藏问题平均数会被异常值拉偏。import pandas as pd df pd.DataFrame(all_reviews) city_stats ( df.groupby(city) .agg( review_count(sentiment, count), mean_sentiment(sentiment, mean), median_sentiment(sentiment, median), std_sentiment(sentiment, std), ) .sort_values(mean_sentiment, ascendingFalse) )std_sentiment 这个字段最容易忽略。它反映的是城市内部评论分歧度标准差高说明这个城市的好评和差评极端分化典型的“爱之深责之切”或者某些商圈宣传过度标准差低说明整体口碑一致。我在交付报告里会专门画一张散点图横轴是评论数纵轴是情感分点的大小是标准差一眼就能看出哪些城市属于“小样本高口碑”的虚火状态——评论数低于 500 但情感分接近 0.8 的城市数据基本不值得采信。提示情感分数阈值不要死磕 0.5。0.45 到 0.55 之间的评论大半是中性或混合情感比如“环境不错但味道一般”。这类样本应该归入中立区间单独统计强行二分只会制造大量错标。5. 5 万条评论项目避坑记录从字谜乱码到重复入库的 5 个真实排查过程5.1 文本全是字谜和方块字解析结果完全不能用现象爬回来的评论内容是一堆不认识的偏旁部首组合单个字符能对上组成词语就看不懂而且数字全变成了小方块。原因部分点评平台对评论内容做了字体反爬。页面会把标准字体映射成自定义字体浏览器加载时用加密字库渲染出正常文字但 requests 抓到的 HTML 里的字符编码是错位映射的。直接按字符取就是字谜。解决这种情况不要在 HTML 层面硬解。优先找该站点是否提供 JSON 接口JSON 通常不加密实在没有再看字体文件把自定义字体下载后按字形轮廓映射回标准字。这里要注意一个关键点每次加载字体映射关系可能都变所以不能缓存一张映射表用到底要在每次抓取时同步获取。最笨也最稳的办法是对字谜文本截图做 OCR但那速度太慢5 万条根本扛不住。5.2 入库后统计发现重复率超过 40%现象评论总数很快到 5 万条但店铺维度去重后实际不足 3 万条重复集中在评分高、评论多的头部店铺。原因评论接口是按更新时间排序的页码越深新评论插入越频繁同一评论可能同时出现在第 1 页和第 3 页另外评论列表是滚动加载前端滚动一次就请求一次接口如果爬虫翻页时没有记录已抓取评论的指纹就会反复抓同一批。解决不能只按评论 ID 去重因为页面展示的 ID 字段可能被截断或脱敏。按清洗后的文本 MD5 在库里建唯一索引入库时 catch 重复异常跳过。我在 2.3 节的 save_reviews 里已经写了这层逻辑实际跑下来重复率从 40% 降到 3% 以内。还有 3% 是跨平台转载的近似重复需要靠编辑距离或 SimHash 做一遍模糊去重如果对精确率要求不高可以不做。5.3 snownlp 把“太难吃了”判成正面情绪现象把“这家店的菜太难吃了不会再光顾”喂给 snownlpsentiments 返回 0.14方向是对的但改成“不难吃”之后返回 0.82也算对真正翻车的是“没有想象中那么难吃凑合吧”竟然返回 0.11 的负面分。原因snownlp 的训练语料主要来自带有明确情感标签的商品评论对“没有想象中那么难吃”这种双重否定句式没有能力建模。它把“难吃”这个词的权重拉得极高根本没有捕捉到前半句的否定和后半句的“凑合”。解决4.2 节里的否定词窗口就是针对这个问题的。把“不难吃”识别为正面把“没有想象中那么难吃”识别为中性偏负面。但注意一个边界否定词窗口对短句效果好对长句会失效。比如“服务员说这道菜不难吃但端上来真的一般”——否定词作用域只在“不难吃”里后面转折句是负面这种句子我一般直接归入中立区不参与城市正面率统计。5.4 翻页到第 30 页开始返回空数据但网页上还有评论现象爬虫从第 1 页翻到第 40 页前 30 页正常之后接口返回空列表日志里也没有报错以为是数据到底了。原因评论接口翻页有两个限制一是页码深度限制很多接口最多返回前 50 页二是时间窗口限制按时间倒序排列时超过某个时间点的评论不会被返回。第 30 页恰好落在窗口边界再往后就是空。解决改用“时间游标”方式翻页每次按 last_comment_time 传参而不是页号。第一次请求拿最新一批取这批里最早的评论时间作为下一次请求的游标服务器会返回从这个时间往后的更早评论。这样翻到 5 万条都没问题且天然避免同一时间点评论被重复抓取。5.5 MySQL 连接被反复打断写入速度越来越慢现象数据入库到一半日志突然报 “MySQL server has gone away”重试几次还是失败进程卡死。原因默认连接有一个超时时间爬虫和清洗预处理耗时较长单条连接隔几分钟不操作就会被服务端断开。SQLAlchemy 的连接池不知道连接已失效继续复用就报错。解决在create_engine里加上pool_recycle3600让连接池每 1 小时强制回收重建一次。同时把采集、清洗、入库拆成三个阶段不要让一条 MySQL 连接跨过整个抓取时间抓取阶段只写原始 JSON 到本地文件清洗阶段再批量入库这个习惯帮我避开了很多锁库和断连的问题。6. 从“跑通”到“可信”构造 300 条手工标注集摸清情感分析的真实上限做了清洗去重和词典修正情感分析只是“能跑”离“可信”还差一个环节你不知道整个 pipeline 的准确率到底是多少。这时候靠自己拍脑袋判断完全不靠谱玄学问题就得用定量方法解决。我的习惯是每批数据都随机抽取 300 条自己人工打标然后和算法的结果做对比。具体做法把清洗后的评论按城市分层抽样每层抽 30 到 50 条凑成 300 条。人工标记时只用两个类别正面和负面把明显中立的句子也归到负面或正面里都不合适所以再加上第三类中立。判定标准要提前定死说过 5 个字以上正面评价且没有明显负面词的算正面讽刺、比较、双重否定这种全部算中立不进准确率计算。这样算出来的准确率才是真实的。from sklearn.metrics import classification_report y_true [1, 0, 1, -1, 1, 0, 0, 1, -1, 1] y_pred [1, 0, 0, -1, 1, 1, 0, 1, 0, 1] # 只看正面和负面两类中立样本单独观察 filtered_true [t for t, p in zip(y_true, y_pred) if t ! -1] filtered_pred [p for t, p in zip(y_true, y_pred) if t ! -1] print(classification_report(filtered_true, filtered_pred, target_names[负面, 正面]))我一般把 0.45 到 0.55 之间的输出判为中立。这样会牺牲一部分样本但剩下的样本准确率能稳定在 0.82 以上。只算正面率时还有个技巧用情感分数的标准差代替均值来判断城市口碑是否健康标准差超过 0.25 的城市光看均值容易被两极分化的评论误导。这个指标比任何花哨的模型都更能说明真实用户的分歧程度。最后再提一个多模态情感分析的延伸思路城市评论里混着大量图片表情如果你的数据源能一并抓到评论配图可以把文本情感分数和图片色调做个交叉验证——通常配图明亮的评论文本正面概率更高。这个方向值得做但前提是先把文本这条路的口径校准好。现在我拿到任何一个新平台的数据都会先花半天人工标注 300 条再写代码调参而不是急着全量跑。这个习惯帮我省下的返工时间远多于标注花费的时间希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →