尧图精选

城市评论爬虫与SnowNLP情感分析实战:从采集到结果验证

🕒 发布时间:2026/10/1 16:53:32 📁 来源:尧图网络
简介项目以潍坊与淄博城市评论为样本利用爬虫采集约5万条用户评论并实施情感分析旨在为潜在游客提供目的地口碑参考也为旅游从业者和景区管理者理解游客满意度、改进服务提供数据支撑。压缩包共收录1988个文件总大小约29.59MB其中csv文件存储评论原始数据与清洗结果py脚本覆盖评论采集、文本预处理和情感分类建模流程txt/json/ts用于存放中间数据、配置与前端可视化素材md文档则梳理了分析思路与结论便于快速复现和二次开发。资源还包含地图、图表等可视化辅助文件适合对爬虫与NLP情感分析有基础了解的学习者或相关行业数据分析师下载研究。已有587人学习过下载后可通过项目代码、数据与文档快速掌握从爬虫抓取到情感分析落地的完整链路并迁移到其他城市评论场景中。1. 从爬虫到情感分析5万条城市评论是如何变成可用结论的接到一个城市评论分析的需求时对方说得很轻巧抓5万条城市评论做个情感分析看市民对商圈的满意度。等真正动手才发现爬虫只是前半场情感分析才是后半场的坑。数据量是够了但评论里大量口语化表达、反讽和方言让开箱即用的情感模型频频翻车。这篇笔记把我拆解这个项目时的完整路径写成可复现的步骤从Ajax接口分析、Requests与Selenium选型到SQLAlchemy入库、jieba分词再到SnowNLP自定义训练和结果校验每个环节都附能直接改的代码。适合正在做爬虫、评论数据挖掘或舆情分析的工程师照着复现一遍再替换成自己的业务字段就能用。2. 评论采集工程化Requests 抓接口、Selenium 兜底、限速重试一条龙2.1 评论接口分析与翻页参数构造很多城市评论站点的评论内容并不是页面静态渲染出来的而是通过Ajax接口异步加载。肉眼看到的网页评论只是前端渲染层真正的数据藏在XHR请求里。我接到这类需求时会先打开开发者工具的Network面板过滤XHR请求往下滚一页评论列表然后逐个看返回JSON的接口。大多数城市评论接口都长得很像路径一般是/comment/list或/reviews请求方式是GET返回结构里带一个分页的data字段。定位到接口之后重点抓三个东西请求方式、请求参数、返回字段。下面这个函数是我在这个项目里用的基础采集方法把城市ID和页码传进去就能拿到一页JSON数据。import requests def fetch_comments(city_id: int, page: int, page_size: int 20) - dict: url https://example.com/api/comment/list params { cityId: city_id, pageNum: page, pageSize: page_size, orderBy: time, } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), Referer: https://example.com/city/110000, } resp requests.get(url, paramsparams, headersheaders, timeout10) resp.raise_for_status() return resp.json()这里最需要注意的是raise_for_status()它会拦截掉4xx和5xx状态码避免把反爬页面当成JSON去解析。cityId通常可以从列表页的URL里找到先用浏览器手工确认一个页面能返回数据再把循环写死。pageSize不要调得太大很多站点限制了最大单页条数我一般用20到50太大反而容易被识别成脚本请求。下面这段是采集5万条数据时的主循环逻辑我把每一页的结果追加到一个列表里并在中间加了随机延时和进度输出。import time import random def collect_all(city_id: int, total_pages: int 2500) - list: rows [] for page in range(1, total_pages 1): data fetch_comments(city_id, page, page_size20) items data.get(data, {}).get(list, []) if not items: break for it in items: rows.append({ city_id: city_id, user_name: it.get(user, ), content: it.get(content, ), rating: it.get(score, 0), comment_time: it.get(time, ), }) time.sleep(random.uniform(2, 5)) if page % 200 0: print(f已完成 {page} 页累计 {len(rows)} 条评论) return rows采集过程中最怕的就是进程中断爬了四个小时的数据还没落盘一旦挂掉全部白费。我一般会在入库前先落地一份原始JSON文件至少给后面留一条后悔药。翻页循环里每200页打印一次进度是让自己心里有数不然长时间没有输出根本分不清是卡住了还是在正常跑。2.2 Header 伪装与请求频率控制城市评论类站点通常有三道反爬门槛校验User-Agent、校验Referer、统计IP访问频率。更严的还会做字体加密或滑块验证。第一步就是维护一个随机的UA池每次请求随机取一个User-Agent同时把请求频率控制在2到5秒一条这不是保守这是让它别盯上你。USER_AGENTS [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Firefox/119.0, ] def get_headers(): return { User-Agent: random.choice(USER_AGENTS), Accept: application/json, text/plain, */*, Referer: https://example.com/, } def safe_get(url, params, retries3): for attempt in range(retries): try: resp requests.get(url, paramsparams, headersget_headers(), timeout10) if resp.status_code 403: print(触发反爬等待更长时间) time.sleep(10) continue resp.raise_for_status() return resp.json() except (requests.RequestException, ValueError): time.sleep(2 ** attempt) raise RuntimeError(f连续{retries}次请求失败: {url})这里的指数退避逻辑值得说清楚第一次失败等2秒第二次4秒第三次8秒给服务器一个恢复窗口。不要为了赶时间把间隔强行压到0.1秒一旦IP被封锁整个项目直接停工这种成本和风险完全不值得。我见过有人为了半天跑完数据最后被封了IP只能换代理重新来等于时间一点没省。2.3 Selenium 兜底当接口加了签名参数如果评论接口的参数里出现了sign、token或者页面需要先执行一段JavaScript才能生成有效Cookierequests再怎么写都绕不过去。常见做法是切换Selenium用真实浏览器把页面渲染完再从中截取接口返回的数据。from selenium import webdriver from selenium.webdriver.chrome.options import Options import time options Options() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(--window-size1920,1080) driver webdriver.Chrome(optionsoptions) driver.get(https://example.com/city/110000) time.sleep(3) comments driver.execute_script( return JSON.parse(localStorage.getItem(commentData) || []) )这段代码里disable-blink-featuresAutomationControlled的作用是去掉WebDriver标记降低被识别的概率。execute_script直接从localStorage里取浏览器缓存过的接口数据比解析DOM要稳得多。Selenium的代价是内存占用大跑五六个页面后浏览器就会变得很重需要定期driver.quit()再重建实例。需要清楚的是Selenium不是首选方案它只用来解决requests拿不到数据的场景。如果接口没有签名拦截老老实实用requests并发能力和稳定性都比Selenium强一截。3. 数据持久化与预处理SQLAlchemy 批量入库、去重和 jieba 分词3.1 SQLAlchemy 表结构设计与批量写入很多入门教程喜欢把评论直接存成CSV前几万条用起来没问题等要做查询去重和抽样时就难受了。我会用SQLAlchemy建一张评论表把爬到的数据结构化存起来。结合这个项目的查询习惯表里至少要有城市ID、用户名、正文、评分、评论时间和入库时间。from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Float from sqlalchemy.orm import declarative_base, sessionmaker Base declarative_base() class Comment(Base): __tablename__ city_comments id Column(Integer, primary_keyTrue, autoincrementTrue) city_id Column(Integer, indexTrue) user_name Column(String(64), default) content Column(Text, nullableFalse) rating Column(Float, default0.0) comment_time Column(DateTime, nullableTrue) create_time Column(DateTime, nullableTrue) engine create_engine(sqlite:///comments.db, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine)city_id加索引的原因是后面会频繁按城市维度做情感聚合这条SQL会被反复执行不加索引的话数据量到5万条时查询会明显发飘。内容字段必须用Text不能用String(255)一条真实评论动不动就会超过255个字符用短字段会导致写入报错。批量写入时有很多人习惯在循环里逐条session.add()然后立刻commit()这是很伤性能的操作。我更倾向于把所有对象先收集到一个列表里最后一次性提交。def batch_save(comment_list): session Session() try: session.add_all(comment_list) session.commit() except Exception: session.rollback() raise finally: session.close()add_all在flush时会将多条INSERT打包提交速度比逐条插入快出一个数量级。5万条评论在SQLite下基本十来秒就能落库如果是逐条commit可能要拖到几分钟甚至更久。项目中途我加过一条经验不要一边爬一边写库爬虫进程本身就占着CPU和网络再频繁开数据库事务两边都会变慢。3.2 评论去重、脏数据清洗与原始数据备份爬虫跑久了重复数据几乎不可避免。分页接口在翻页过程中偶尔会把上一页最后一条评论再返回一次或者页面跳转时出现了数据重叠。我在表结构里加了一个content_hash字段用city_id content_hash做唯一约束入库前先算一遍MD5重复的直接跳过。from sqlalchemy import UniqueConstraint import hashlib class Comment(Base): __tablename__ city_comments __table_args__ ( UniqueConstraint(city_id, content_hash, nameuq_city_content), ) content_hash Column(String(32), default) def make_hash(text: str) - str: return hashlib.md5(text.strip().encode(utf-8)).hexdigest()text清洗这一块容易漏。评论里经常出现的HTML实体、表情符号、以及“此用户未填写评价”这类占位文本都会污染后续分词和情感统计。清洗顺序有讲究要先做html.unescape还原实体再做正则剔除顺序反了会导致正则匹配不上。import re import html def clean_text(raw: str) - str: text html.unescape(raw) text re.sub(r\[[^\]]*\], , text) text re.sub(r\s, , text) return text.strip()html.unescape会把amp;还原成#x1F600;这类格式才会被后续正则处理干净。方括号标签是表情占位符比如[大笑]、[流泪]直接删掉。这里的空格压缩也很有必要爬虫拿到的文本经常带着换行和连续空格不处理会影响后面相似评论合并。3.3 jieba 分词、停用词和自定义用户词典情感分析虽然不强制要求先分词但做主题统计和词典迭代时分词结果反而比模型分数更容易解释。我给这个项目配了一套jieba精确模式先加载自定义词典再做切分。import jieba jieba.load_userdict(city_words.txt) STOP_WORDS set() with open(stopwords.txt, encodingutf-8) as f: for line in f: STOP_WORDS.add(line.strip()) def tokenize(text: str) - list: words jieba.lcut(text) return [w.strip() for w in words if w.strip() and w not in STOP_WORDS]city_words.txt每行放一个词比如“五棵松”“回龙观”这些地名如果不指定会被拆成“五”“棵”“松”“回”“龙”“观”后续统计主题词时一塌糊涂。停用词表不需要多大两三百个词就够重点是不是把否定词加进去像“不”“没”“别”“无”这类词一旦被过滤情感信息就丢了这个坑我在项目中期踩过一次导致某类评论情感分全部偏向正向。分词之后可以顺手做一轮高频词统计很快能判断数据质量到底行不行。from collections import Counter counter Counter() for content in sample_comments: counter.update(set(tokenize(content))) print(counter.most_common(20))这里刻意用了set(tokenize(content))目的是让同一句话里重复出现的词只统计一次不然“好好好好好好”这种评论会把“好”字冲到第一位主题分布失真。4. SnowNLP 情感建模轻量模型、自定义词典与样本重训4.1 为什么用轻量模型而不是大模型这可能有点反直觉现在大模型做情感分析看起来很聪明但在处理5万条评论这个任务量级时逐个调大模型接口的成本和时间都不可控而且评论数据属于半公开信息批量外发还有合规隐私问题。常见做法是先用SnowNLP这类本地轻量模型跑一轮把明显正负的样本分开再对边界样本做人工复核或调大模型。SnowNLP自带一个训练好的情感模型输入句子输出0到1之间的情感分数越接近1越正向。from snownlp import SnowNLP def sentiment_score(text: str) - float: if not text: return 0.5 return SnowNLP(text).sentiments如果评论太长SnowNLP处理速度会明显下降我一般会把文本截断到200字以内再做情感分析。城市评论里的情绪往往集中在前几句后面大多是补充细节截断不会丢太多信息。另一个经验是对空字符串返回0.5中性分数避免后面聚合统计时空记录被算成负面。4.2 情感词典的迭代与规则修正SnowNLP对购物评论语料更友好搬到城市评论场景后准确率会掉这是预期内的事情。提高准确率最直接的手段不是换模型而是先把领域词喂进去。城市评论里“堵”“涨价”“脏乱差”“遛娃”这些词在SnowNLP原始词典里的权重不一定恰当。我自己维护了一组正向词和负向词用简单加权规则修正基础分数。POS_WORDS {点赞: 2, 惊喜: 2, 方便: 1.5, 干净: 1.5} NEG_WORDS {涨价: -2, 拥堵: -1.5, 脏: -1.5, 坑: -2} def adjust_score(text: str, base_score: float) - float: score base_score for word, weight in POS_WORDS.items(): if word in text: score weight * 0.1 for word, weight in NEG_WORDS.items(): if word in text: score weight * 0.1 return max(0.0, min(1.0, score))注意最后一步做了裁剪避免重复词汇把分数推出0到1区间。这种规则修正方式的可解释性比纯模型好出错了知道是哪条规则错了可以直接回滚。后面我还会讲否定词处理它比词典加权更影响最终效果。4.3 人工标注样本重训 SnowNLP如果只是想把准确率往上再拉几个点更彻底的做法是重训SnowNLP的情感分类器。SnowNLP内部用的是贝叶斯分类训练接口很简单但需要准备正负样本集。我从5万条评论里抽了3000条人工标注正负各一半用训练样本重新训练模型。from snownlp import sentiment sentiment.train(pos_comments.txt, neg_comments.txt) sentiment.save(city_sentiment.marshal)训练语料格式很简单每行一条评论正样本放在pos_comments.txt负样本放在neg_comments.txt。训练完成后模型会保存成本地marshal文件之后每次调用SnowNLP前先加载这个文件就能用上领域化的模型。人工标注的样本质量比样本数量更重要500条标得准确的样本比5000条标错的样本更管用。第6章里我会专门说怎么验证这批训练结果是不是真的靠谱避免评测出来的准确率虚高。5. 爬虫与情感分析常见问题五个高频翻车点5.1 请求频率过高导致IP被临时封锁现象爬到第8000条评论时requests正常请求的接口突然不再返回JSON而是返回一段带“请完成安全验证”字样的HTML页面。原因单位时间内单个IP的请求频率超过了站点阈值触发了反爬临时封禁。这个阈值有时候低到每分钟20次根本不需要用暴力刷请求。解决先停掉跑批任务等待半小时恢复。再把单次请求间隔从0.5秒调大到2到5秒并且使用随机延时让请求节奏更接近真人浏览器。更稳妥的做法是接代理池给每个代理分配不超过2000次请求配额用完就换。5.2 入库后中文全部变成乱码现象SQLAlchemy写入SQLite后用数据库工具打开表中文全部变成类似“ç¦æ¥½”的乱码但爬虫命令行里打印的内容看起来是正常的。原因requests在解码响应时没有正确识别字符集用了拉丁编码去解UTF-8内容或者是SQLAlchemy连接串没有指定UTF-8字符集。解决在请求阶段显式指定编码resp.encoding utf-8不要依赖自动猜测。连接SQLAlchemy时同样固定字符集参数。这里最忌讳的就是“猜”不同页面编码不一致时写死再统一清洗反而更稳定。5.3 SQLAlchemy 逐条写入速度慢到怀疑人生现象5万条评论写入SQLite耗时将近10分钟CPU利用率还很低跑批任务被数据库写操作拖住了。原因在循环里逐条执行session.add()并立即commit()每一条记录都在创建一个单独的事务事务提交的开销远大于写入本身。解决改成先爬完数据最后用session.add_all()一次性提交只commit一次。如果数据量再大一个量级就直接改用游标的executemany5万条数据能压到几秒内完成。另外写多读少的表不要建太多索引每增加一个索引都会拖慢写入速度。5.4 SnowNLP把“不怎么样”判成正面现象一条明确吐槽“不怎么样以后不会再来了”的评论情感分数竟然高达0.82模型把它归成了正面。原因SnowNLP的模型基于词袋和朴素贝叶斯对否定词的敏感度不够。句子里的“怎么样”“再来”这些偏正向的词在数量上压过了“不”导致整体判正。解决先对含否定词的句子做规则修正。当“不”“没”“别”这类否定词出现时对后续正向词做一次反转处理然后再进入模型。另一个方案是重训在负样本里多放这类转折句让贝叶斯模型学会否定结构。5.5 准确率看着很高实际可用性很差现象训练完成后跑测试集准确率报出95%但抽看新评论时错误率极高业务方拿到的结论完全不能用。原因训练集和测试集来自同一时段、同一城市的热门评论分布高度一致模型学到了时段的局部特征并没有学到通用的情感判断。解决我后来用时间维度拆验证集用上一周的评论训练、下一周的评论验证准确率直接从95%掉到78%这才暴露了过拟合问题。样本不均衡的时候准确率本身就是个陷阱要看精确率、召回率和F1而不是被一个高分迷惑。6. 让情感分析结果更可信人工校验、混淆矩阵与词典回填模型重训完成之后最重要的不是堆更多数据而是建立一套可信的验证流程。我的做法是固定抽200条评论做人工基线先不告诉模型预测结果标完再和模型输出做对比。对比时不能只看准确率要按城市、按评论长度、按是否含否定词分别统计这样能定位模型在哪个子集上出错比较多。评估部分我直接用sklearn的classification_report把人工标注和模型预测结果放到一起看。from sklearn.metrics import classification_report # y_true: 人工标注结果, 0为负向, 1为正向 # y_pred: 模型预测结果, 0为负向, 1为正向 print(classification_report(y_true, y_pred, target_names[neg, pos]))输出里的precision和recall比准确率重要得多。比如正向类的recall低说明很多正面评论被判丢了负向类的precision低说明大量中性评论被误判成负面。对城市评论项目来说少判情绪比误判情绪更安全误判会直接误导业务决策。我还习惯把模型预测概率落在0.4到0.6之间的样本全部抽出来人工复核这批样本是模型自己都不确定的边界样本也是翻车率最高的地方。人工复核完把这些错误样本回填到训练集模型的短板就会被一点一点补齐。整个流程跑下来这个项目的5万条评论最终按城市维度聚合成表每个城市得到平均情感分、评分、评论数三个核心指标业务方拿去直接能看。从那以后我每次做情感分析都会先强制自己抽200条人工过一遍再上线这个习惯帮我躲过了好几次准确率虚高的翻车。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →