2026/9/25 23:13:15

Python爬虫+情感分析实战:5万条城市评论数据处理全流程

Python爬虫+情感分析实战:5万条城市评论数据处理全流程 简介面向爬虫与情感分析学习者的实战项目资源项目利用Python爬虫采集潍坊、淄博两地约5万条游客评论并对评论进行情感倾向与满意度分析为旅游从业者、景区管理者提供舆情参考。资源包共1988个文件压缩后约29.59MB主体为199个csv评论数据、25个Python脚本、656个txt说明文档与781个js脚本还包含md笔记、json配置、map映射等较完整地覆盖了爬虫采集、数据清洗、情感建模与结果可视化的各个环节。已有587人学习下载。通过这套资源使用者可以拿到可直接运行的爬虫与情感分析代码、多城市评论数据集、分析报告与演示文件还能参考其中包含的配置、前端展示和项目文档便于快速复现项目、学习爬虫与NLP实战流程。1. 用爬虫把5万条城市评论变成情感数据这条路值不值得复现你可能也遇到过这种需求想判断某座城市的真实口碑一条条刷评论不现实平台自带的评分又太粗最后只想把评论抓下来自己算一遍情感倾向。这个项目做的正是这件事——利用爬虫爬取5万条城市评论并对其进行情感分析链路从网页接口取数、SQLAlchemy落地存储到Jieba分词加情感词典打分最终输出按城市聚合的情绪画像。它不是研究性质的多模态情感分析而是能直接改参数换站点复用的工程方案。适合正在找爬虫实战项目的初学者也适合手头有短文本舆情分析任务、想快速跑通一条可解释管线的从业者。接下来我按自己拆这套代码的顺序把每个环节的选型理由、参数设置和实际翻过车的地方都说清楚。2. 把评论数据抓下来接口选型与请求伪装参数解析2.1 选接口而不是解析页面成本与稳定性的取舍爬虫入门的通病是一上来就去解析HTML页面用BeautifulSoup或XPath把评论一条条从DOM里抠出来。这在静态页面上确实能跑通但一旦目标站点改版、class名字换掉匹配规则全部失效。我拆这个项目的时候第一件事是打开浏览器开发者工具切到Network面板勾选Fetch/XHR刷新评论区列表观察真实的数据请求。绝大多数城市生活类平台的评论列表都不是首屏直出的而是页面JavaScript向后端接口发起异步请求拿JSON数据再渲染。找到这个数据接口后返回的字段往往是结构化的每条评论有唯一ID、用户信息、评分、评论文本、发布时间比从HTML里解析省掉了一大半清洗工作量。常见做法是复制一条请求的请求头直接去构造自己的请求但不要原封不动搬因为Requests库发的请求和你浏览器发出的请求在头字段顺序和默认值上有差别目标站点做了请求头校验时很容易拦截。我一般会保留下面几个关键头字段User-Agent必须改成真实的浏览器UA不要用Requests默认的python-requests/x.x.x那是反爬系统最容易识别的特征。Referer来源页很多站点的接口会校验这个字段缺失或错误时返回403。Accept和Accept-Language部分服务端框架会按请求头里的语言偏好返回内容缺了可能拿到默认语言或空数据。除了请求头字段接口的认证和签名参数也是硬门槛。有的平台在请求URL里带一个不断变化的sign或token这类接口就不能用静态参数直接打得在页面JavaScript里找签名生成逻辑或者用Selenium/Playwright这类浏览器自动化拿cookie。这个项目里我判断目标接口没有加密签名只是普通的登录态加分页参数所以直接用Requests硬打就足够这也是很多公开评论接口的常态。如果遇到加密参数我的建议是先用浏览器自动化方案验证数据格式再考虑要不要逆向不要一开始就陷进JS逆向里。2.2 请求伪装与分页抓取的完整实现下面这段代码是我在这个项目里实际用到的抓取骨架目标接口是某城市评论的公开JSON接口按城市ID和页码翻页import requests import time import random from fake_useragent import UserAgent # 随机取一个桌面端浏览器 UA避免固定 UA 被识别 ua UserAgent() def fetch_comments(city_id, page, page_size20): headers { User-Agent: ua.chrome, Referer: https://example-city-site.com/, Accept: application/json, text/plain, */*, Accept-Language: zh-CN,zh;q0.9, } params { city: city_id, page: page, pageSize: page_size, sort: time, # 按时间排序保持增量抓取稳定 showEmpty: false, # 过滤掉无评论内容的占位条目 } try: resp requests.get( https://example-city-site.com/api/comments, headersheaders, paramsparams, timeout10, # 超过 10 秒放弃避免长时间阻塞 ) if resp.status_code 200: data resp.json() return data.get(items, []) else: # 403/429 时打印状态码后面由调用方决定是否暂停 print(fpage {page} got status {resp.status_code}) return [] except requests.RequestException as exc: print(frequest failed: {exc}) return [] # 单城市分页抓取从第 1 页开始直到返回空列表为止 def crawl_city(city_id, max_pages100): total [] for page in range(1, max_pages 1): items fetch_comments(city_id, page, 20) if not items: break total.extend(items) # 随机延时 0.8~2.5 秒压低请求频率 time.sleep(random.uniform(0.8, 2.5)) return total这段代码有三个参数值得细看。第一个是timeout10。它不只是一个超时保护也是防止网络抖动导致线程卡死的底线。爬虫任务跑在单线程里还好如果是多线程并发线程池里的任务一旦卡在慢请求上会越积越多内存被未完成的future占满。我通常还会在外面套一层重试装饰器对ConnectionError和Timeout这两个异常做最多三次重试重试间隔递增避免在服务端暂时过载时硬冲。第二个是sort: time。评论列表接口一般支持按时间排序相比默认的智能排序按时间排的好处是分页边界稳定配合后面的SQLAlchemy唯一约束可以实现断点续爬时不重复、不遗漏。如果把排序改成默认的“综合排序”评论顺序可能随热度实时变化第1页和第2页之间会出现重叠窗口数据去重会多一道活。第三个是random.uniform(0.8, 2.5)。这个随机延时的下限和上限取值是我在请求成功率和爬取效率之间调出来的经验值。延时太短小于0.3秒容易触发频率限制出现大面积429太长大于5秒会导致爬完5万条要十几个小时体验很差。0.8到2.5秒这个区间配合单线程每小时能稳定抓到1800到2500条5万条数据大约24到30小时可以爬完属于能接受的范围。抓完数据别急着写存储先打印一条原始记录看结构确认字段名和类型再继续。这一步能省掉后面大量调试时间因为你以为的comment字段在真实接口里可能叫content或feedContent对不上号时越早发现越好。3. 数据不落地等于白抓SQLAlchemy 存储与清洗实测3.1 为什么用 SQLAlchemy 而不是 CSV很多爬虫教程习惯把数据追加到一个CSV文件里简单直接。但5万条评论级的数据量CSV方案有三个实际痛点一是去重麻烦每次重新跑脚本时都得把整个文件读进内存去重数据量上来后越来越慢二是条件查询不便想看某个城市的所有差评得全文件扫描三是增量写入时机不好控制进程中途崩了之前写进文件的数据可能格式错乱。用SQLAlchemy落地到MySQL正好把这三个问题都解掉。SQLAlchemy是Python生态里最主流的ORM对象关系映射框架它帮你把评论数据从Python对象映射到数据库表不用手写SQL语句就能完成建表、插入、查询这些操作。对爬虫场景来说它还有一层额外价值ORM层定义好的表结构约束比如唯一键会在create_all时直接同步到数据库里等于在数据源头做了一道去重防线。选SQLAlchemy而不是直接写原生SQL原因是代码的可读性和字段变更成本。爬虫字段经常要加比如后来我想新增一个“评论来源设备”字段用原生SQL要写ALTER TABLE用ORM只需要在模型类上加一个字段然后重新建表开发体验好很多。缺点是多了一层映射批量插入时性能比纯SQL低大概10%到20%但对5万条评论这种量级完全不是瓶颈。3.2 建表、去重约束与批量写入下面是这个项目里评论表的核心模型定义from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, UniqueConstraint from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from datetime import datetime Base declarative_base() class CityComment(Base): __tablename__ city_comments id Column(Integer, primary_keyTrue, autoincrementTrue) city Column(String(32), nullableFalse, indexTrue) # 城市名称常查询字段加索引 content Column(Text, nullableFalse) # 评论文本 rating Column(Integer, default0) # 平台星级评分0 表示未评分 comment_time Column(DateTime, defaultdatetime.now) # 评论发布时间 source Column(String(64), default) # 来源标识便于多批次追溯 # 城市 评论内容组合唯一防止重跑脚本时重复入库 __table_args__ ( UniqueConstraint(city, content, nameuq_city_content), ) # 连接字符集用 utf8mb4评论里可能带 emoji 字符 engine create_engine( mysqlpymysql://user:pass127.0.0.1:3306/comment_db?charsetutf8mb4, pool_size10, # 连接池大小避免频繁创建连接 pool_recycle3600, # 连接空闲超过 1 小时自动回收 echoFalse, # 设为 True 可打印 SQL 调试 ) Base.metadata.create_all(engine) Session sessionmaker(bindengine)这段设计里最关键的一行是连接串尾部的charsetutf8mb4。很多人在这一步栽跟头创建库的时候字符集建成了默认的utf8往表里写评论时遇到emoji表情直接报Incorrect string value错误数据静默丢失。原因在于MySQL的utf8字符集最多存3字节字符而emoji大多数是4字节编码必须用utf8mb4才能完整存储。我在建库和建表时都会显式指定CHARACTER SET utf8mb4而不是依赖MySQL默认配置。去重约束用的是组合唯一键(city, content)。这个约束的真实价值体现在增量抓取场景上一次跑到一半进程崩了重启后从头抓一遍同一批评论会被再次写入如果没有唯一键约束表里就会积累大量重复数据。加了约束之后重复插入会抛IntegrityError异常正常处理逻辑是捕获这个异常后跳过该条数据继续插下一条。写入端的核心逻辑是这样的from sqlalchemy.exc import IntegrityError def save_comments(comment_list): session Session() skipped 0 try: for item in comment_list: record CityComment( cityitem.get(city, ), contentitem.get(content, ).strip(), ratingint(item.get(rating) or 0), comment_timeitem.get(timestamp, datetime.now()), sourceapi_v1, ) session.add(record) # 每积累 200 条提交一次降低事务体积 if len(session.new) 200: try: session.flush() except IntegrityError: session.rollback() # 跳过重复数据单独统计数量 skipped 1 except Exception as exc: session.rollback() print(fsave error: {exc}) finally: session.commit() session.close() print(fsaved {len(comment_list) - skipped}, skipped {skipped})写入时要注意flush()和commit()的区别。flush()是把ORM中的变更发送到数据库但不提交事务遇到唯一键冲突时可以单独处理而不影响整体事务commit()是真正把事务落盘不可回退。上面的代码里每200条做一次flush()把一整批数据作为一个工作单位冲突时回滚该批再跳过冲突记录。这个“攒批提交”的做法能显著降低数据库事务准备次数5万条数据大约能提速3倍同时避免了一次性提交5万条导致的事务日志过大问题。数据清洗在写入前就应该完成而不是等入库后再处理。我在这个项目里做了两步清洗第一步是文本去噪把评论文本里的HTML标签、转义符、多余空白符全部替换掉第二步是长度过滤少于5个字符的内容比如只写了“赞”或“好”无法承载有效情感信号直接丢弃。清洗全部放在存储前是为了让后面的情感分析阶段拿到的数据已经是最干净的状态不用重复处理。4. 情感分析打分词典规则为主、SnowNLP为辅的调优方案4.1 通用模型在城市评论上的域漂移问题5万条评论落库之后下一步就是给每条评论算情感分。教材里常见的做法是直接调SnowNLP用TextBlob或snownlp自带的通用模型对文本打分。这在小样本演示里效果不错但放到城市评论这个场景马上就露馅SnowNLP的默认模型是在购物评论和影评语料上训练的对城市评论里常见的“地铁方便”“夜景漂亮”“停车坑爹”这类表达判断不稳定很多明显负面的评论被分出0.6以上的情感分。这就是“域漂移”问题一个在其他领域表现良好的模型换个数据分布就失效。城市评论的数据特点是短句多、口语化强、地域名词密集通用模型很难分配恰当权重。实际测试下来我在这个项目里对300条人工标注的评论做验证SnowNLP裸跑正确率差不多58%到62%基本处于“能用但不可靠”的状态。所以我的处理方案是词典规则为主、SnowNLP为辅。情感词典法的核心思路是准备一份正向词表和负向词表再叠加程度副词和否定词的权重逻辑对评论文本逐词扫描打分。优点是逻辑完全透明每条评论为什么得分可以反查到具体是哪个词贡献的分数缺点是词典覆盖率有限需要准备相对完整的领域词表。对城市评论这个场景通用的情感词典覆盖度不够需要补充领域词汇。比如“网红打卡地”在词典里是中性词但在城市评论语境下多数是正面信号反过来“脏乱差”“乱收费”这类词通用词典未必收录。我在项目里维护了一个扩展词表把从历史评论里反复出现、且人工判断带有明显情感倾向的词追加进去每次追加后重新跑一遍验证集观察准确率变化。4.2 打分函数实现与中性段阈值下面是我在项目里实际使用的打分函数核心逻辑是遍历分词结果命中情感词后按前后语境加权import jieba # 加载正向词表、负向词表和程度副词表 pos_words set(open(pos_words.txt, encodingutf-8).read().strip().split()) neg_words set(open(neg_words.txt, encodingutf-8).read().strip().split()) degree_dict { 很: 1.5, 太: 1.8, 非常: 2.0, 特别: 1.7, 有点: 0.6, 稍微: 0.7, 比较: 1.2, 极其: 2.2, } negators {不, 没, 没有, 不太, 不怎么} def score_sentiment(text): tokens jieba.lcut(text) score 0.0 i 0 while i len(tokens): word tokens[i] # 命中情感词时检查前一个词是否是否定词或程度副词 if word in pos_words or word in neg_words: weight 1.0 if i - 1 0: prev tokens[i - 1] if prev in degree_dict: weight degree_dict[prev] elif prev in negators: # 否定词直接反转情感方向 weight -1.0 base 1 if word in pos_words else -1 score weight * base i 1 return score def classify_sentiment(score): # 阈值经验取 0.5绝对值小于 0.5 判为中性 if score 0.5: return positive elif score -0.5: return negative else: return neutral打分逻辑的关键是只考虑情感词前一个词的短距离依赖。为什么不看整句的完整语法树因为开源中文NLP工具的依存句法分析在短句上表现一般而且引入句法分析会让处理速度慢一个数量级5万条评论跑下来要额外多花几十分钟收益却有限。短距离规则只处理“很赞”“太坑”“不推荐”这类最常见的相邻结构已经能覆盖城市评论里七八成的情感表达。degree_dict里的权重是我按语感调的初始值比如“非常”的2.0是“很”的1.5的加权增强版“有点”降到0.6是为了表达弱正面的语气偏移。这些参数不是拍脑袋确定的实际调优方法是用人工标注的300条评论当验证集遍历不同的权重组合选准确率最高的那组。很多教程跳过这一步直接使用默认权重结果情感分布严重偏向正面的情况很常见因为普通词给1.0权重正面词表数量多总分自然就正了。中性的判断阈值设为0.5也有讲究。城市评论里有相当一部分是客观描述型内容比如“酒店距离地铁站步行10分钟”“门票需要提前一天预定”这些句子既不含正向词也不含负向词打分结果接近0。如果把阈值设为0这类句子会被强行归入负向城市整体情感画像就会失真。我特意统计了原始分数分布发现中性段聚集在-0.5到0.5之间所以把分类边界定在这里。最后提醒一句不要直接信任字典规则的结果去下结论。我实际跑完5万条评论后抽了100条做人工复核其中“停车方便但没地方充电”这种转折句被明显错判成了正向因为规则只看词级特征不理解转折语义。如果你的场景里转折句比例很大就得考虑引入大模型做二次分类但在小成本项目里先跑通规则版本看清楚整体分布比一上来上大模型更务实。5. 避坑实录从空响应到全中性评论的五个翻车点5.1 爬虫侧的坑空响应与频率限制坑一请求返回200但items永远是空列表。我第一次跑通抓取脚本时拿到的响应状态码全是200页面结构也正常但items字段始终为空一页数据都抓不到。排查后发现是sort参数传入了一个目标接口不支持的取值服务端没有报错而是静默返回空结果。解决先手动构造一条完整请求放到Postman里逐步删参数定位哪个参数导致返回结果变化。确认是sort参数问题后我改成从浏览器实际请求里复制参数名和取值不做任何自创参数。这个习惯我一直保留到现在接口参数永远以真实请求为准不以猜测为准。坑二爬了20分钟IP被临时封禁。现象是单线程跑了一会儿后开始密集出现429状态码再往后变成了403。原因是对单一接口使用固定延时仍然显得太规律服务端的限流系统直接识别出机器行为。解决方法是把固定延时改成随机延时并增加一个失败退避机制一旦碰到429立刻把当前延时翻倍暂停3到5分钟再恢复。不要在同一秒内发多个请求更不要用多线程并发去冲一个无代理的接口这个错误新手犯的最多。5.2 数据侧的坑乱码与重复写入坑三emoji评论写入MySQL直接报错整批数据回滚。我项目的表结构里comment_time等字段都能正常写入但遇到带表情符号的评论时SQLAlchemy抛出Incorrect string value: \xF0\x9F\x98。原因是建库时用了默认的utf8字符集而不是utf8mb4。解决把数据库、表、连接串三处的字符集全部改成utf8mb4。要特别注意连接串的参数是在create_engine的URL里以查询参数形式加的不是写在代码别的地方。改完字符集后重启MySQL连接再用原来会报错的那条评论做插入测试。坑四脚本重跑一遍表里的数据直接翻倍。现象是第一次跑完5万条第二次为了补爬新增评论重新执行脚本跑完后总数变成了9万多条大量重复。原因是我最开始没有给表加唯一约束重跑脚本时没法识别哪些数据已经存在。解决给表加UNIQUE KEY组合约束城市评论内容插入时捕获IntegrityError后跳过重复记录。如果表已经建好且数据量很大不需要重建表用一条ALTER TABLE加上唯一键即可MySQL会自动对历史数据做去重校验。5.3 情感分析侧的坑阈值与词表边界坑五5万条评论跑完九成都是中性。我第一次用词典法跑完全量数据后统计下来的分布是中性78%、正向15%、负向7%这个结果基本没有参考价值。排查原因有两个一是正向和负向词表规模太小只覆盖了不到500个词大量情感词没被识别二是阈值设得太高所有轻微情感表达都被压进了中性段。解决先把阈值从1.0降到0.5中性段占比立刻降到45%左右然后扩充词表从历史评论里用高频词统计捞出一批候选词逐个人工确认后加入词表再来一轮验证集准确率测试。最终词表扩充到1300词左右情感分布才变成了正向42%、负向25%、中性33%和各城市实际口碑对得上。词表覆盖率才是词典法的命门阈值只是调节器不要靠调阈值硬撑准确率。6. 把情感分变成趋势曲线一个全局后处理技巧与验证习惯单个评论的情感分只能回答“这条内容是好是坏”回答不了“这座城市最近口碑变好了还是变差了”这种问题。把5万条评论全部打过分之后我做的第一件事不是算平均分而是按天分组把每天的所有评论情感分取均值画出一条情感走势线。from sqlalchemy import func from datetime import datetime # 按天聚合情感分数pandas 读取后再画走势 rows session.query( CityComment.comment_time, CityComment.sentiment_score, ).all() import pandas as pd df pd.DataFrame( [(r.comment_time.date(), r.sentiment_score) for r in rows], columns[date, score], ) trend df.groupby(date)[score].mean().reset_index()为什么画趋势而不是算全局平均分因为5万条评论的时间跨度可能有半年到一年在此期间城市的设施、政策、季节都可能变化。全局平均分把一整年的情绪混在一起完全掩盖了口碑的波动。按天聚合之后一眼就能看出哪个时间段出现了明显的负面尖峰再定位到那个时间段的原始评论直接读原文本就能找到原因。趋势曲线的另一个用途是做数据质量验证。如果你看到某天的平均分突然跳到4.0以上而前后几天都在1.0左右徘徊大概率是爬虫采集规则在那天出现了偏差比如只抓到了评论数很少的热门好评页而不是完整页面。这种异常在原始评论列表里很难发现但在趋势曲线上一眼就能识别。最后分享一个我现在固定执行的习惯每次跑完一轮情感分析手动抽100条评论做人工打分和程序打分做交叉验证。准确率低于75%就回头查词表和阈值绝不直接拿结果进入汇报。这个习惯救过我很多次因为偶尔有一批评论的表述风格和之前完全不同旧词表覆盖率明显不够趋势曲线看着正常但实际方向是错的。从那以后我每次做完打分都会强制走一遍抽检交叉验证再报结论希望帮到你。本文还有配套的精品资源点击获取