☰
城市评论爬虫与SnowNLP情感分析实战:从采集到结果验证
2026/10/1 16:53:29 网站建设 项目流程

简介:项目以潍坊与淄博城市评论为样本,利用爬虫采集约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, params=params, headers=headers, timeout=10) 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_size=20) 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, retries=3): for attempt in range(retries): try: resp = requests.get(url, params=params, headers=get_headers(), timeout=10) 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才能生成有效Cookie,requests再怎么写都绕不过去。常见做法是切换Selenium,用真实浏览器把页面渲染完,再从中截取接口返回的数据。

from selenium import webdriver from selenium.webdriver.chrome.options import Options import time options = Options() options.add_argument("--disable-blink-features=AutomationControlled") options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=options) driver.get("https://example.com/city/110000") time.sleep(3) comments = driver.execute_script( "return JSON.parse(localStorage.getItem('commentData') || '[]')" )

这段代码里,disable-blink-features=AutomationControlled的作用是去掉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_key=True, autoincrement=True) city_id = Column(Integer, index=True) user_name = Column(String(64), default="") content = Column(Text, nullable=False) rating = Column(Float, default=0.0) comment_time = Column(DateTime, nullable=True) create_time = Column(DateTime, nullable=True) engine = create_engine("sqlite:///comments.db", echo=False) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)

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", name="uq_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会把&还原成&,😀这类格式才会被后续正则处理干净。方括号标签是表情占位符,比如[大笑]、[流泪],直接删掉。这里的空格压缩也很有必要,爬虫拿到的文本经常带着换行和连续空格,不处理会影响后面相似评论合并。

3.3 jieba 分词、停用词和自定义用户词典

情感分析虽然不强制要求先分词,但做主题统计和词典迭代时,分词结果反而比模型分数更容易解释。我给这个项目配了一套jieba精确模式,先加载自定义词典,再做切分。

import jieba jieba.load_userdict("city_words.txt") STOP_WORDS = set() with open("stopwords.txt", encoding="utf-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一次。如果数据量再大一个量级,就直接改用游标的executemany,5万条数据能压到几秒内完成。另外写多读少的表不要建太多索引,每增加一个索引都会拖慢写入速度。

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条人工过一遍再上线,这个习惯帮我躲过了好几次准确率虚高的翻车。希望帮到你。

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

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

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

立即咨询