☰
Python旅游景点评论情感分析系统:从爬虫到可视化落地
2026/10/12 14:14:40 网站建设 项目流程

简介:这套基于 Python 构建的旅游景点评论情感分析系统,是前后端分离的毕业设计项目,集成了携程与马蜂窝评论爬虫,覆盖评论采集、文本清洗、情感判断与可视化展示的完整链路,适合计算机、通信、人工智能等专业学生作为毕设或课程设计参考,也适合从业者学习真实场景下的情感分析项目。压缩包共 102 个文件,总体积 47.71MB,包含 32 个 Python 脚本、10 个 Vue 组件,以及 JS、JSON、HTML、SCSS 等配置与静态资源;Python 脚本承载爬虫与情感分析逻辑,Vue 文件构成前端界面,整体目录分工清晰,便于按模块检索和修改。目前已有 228 人浏览学习,代码经过调试运行,答辩评审达到 98 分,可直接用于方案演示、二次开发或期末课设。其中爬虫封装、情感分析模块、接口联调与前端展示相互衔接,还能帮助快速理解前后端分离项目的组织方式,并在数据源或算法层面做进一步拓展。

1. 基于Python的旅游景点评论情感分析系统:毕设选题为什么选它,以及它到底要做什么

如果你正在挑毕业设计题目,大概率会看到“基于Python的旅游景点评论情感分析系统”这个方向。它把爬虫、中文分词、情感模型、前后端分离串成了一条完整的工程链路,正好覆盖计算机专业毕设最常被问到的几个能力点。简单说,这套系统从携程、马蜂窝抓取景点评论,用Python做情感倾向分析,最后把正面占比、情绪趋势、高频关键词展示在网页上。适合想往数据分析或NLP方向走的同学,也适合导师要求“必须有原型系统”的工程型毕设。这篇笔记会沿着评论数据怎么抓、情感模型怎么选、前后端怎么接这条主线往下拆,最后给你一份可以直接复现的落地路径和排错清单。

2. 先从数据说起:携程、马蜂窝的评论爬虫怎么设计才能不被封

爬虫是整套系统的数据源头,也是很多毕设第一个卡住的地方。你需要的不是一次性抓十万条,而是能稳定、可重复地补数据。先理解网络爬虫原理:浏览器打开页面时会发起大量请求,其中评论内容多数不是写死在HTML里,而是通过XHR异步加载。你要做的,就是找到那个返回评论数据的接口,用Python模拟请求拿回JSON或HTML片段。

2.1 评论数据长什么样:先抓包看清楚接口和字段

不要一上来就写代码。打开浏览器开发者工具(F12),切到Network面板,勾选XHR筛选,然后手动翻一页景点评论。你会看到若干请求,逐个点开看响应体:携程的评论接口通常返回JSON,里面包含socre(注意是socre,不是score)、content、userName、commentTime等字段;马蜂窝的评论接口有时返回JSON,有时返回一段HTML片段,需要根据响应类型选择解析方式。

抓包时重点记录三样东西:接口URL、请求方法(GET/POST)、关键请求头(尤其是Referer和User-Agent)。很多反爬判断就藏在Headers里。用curl先重放一次请求,比如:

curl -X GET 'https://you.ctrip.com/dest/restapi/comment/list?resourceId=xxx&page=1&size=20' \ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)' \ -H 'Referer: https://you.ctrip.com/'

如果curl返回了正常JSON,说明接口可以直接用requests模拟;如果返回验证码或空数据,就得检查Cookie、加密参数,或者改用浏览器渲染方案。这一步能帮你省掉后面大半的排错时间。

2.2 用requests+xpath写一个可用的爬虫:携程点评接口抓取实例

抓到接口后,最稳妥的做法是先用requests写一个最小可运行的爬虫。下面是一个携程评论抓取示例,注意接口地址和字段名是我按常见接口结构写的示意,实际请以你抓包的地址为准:

import requests import time import pandas as pd def fetch_ctrip_comments(resource_id, pages=5): url = "https://you.ctrip.com/dest/restapi/comment/list" # 示意接口 headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://you.ctrip.com/" } all_comments = [] for page in range(1, pages + 1): params = { "resourceId": resource_id, # 景点ID,从景点详情页URL获取 "page": page, "size": 20, "tag": "全部" } try: resp = requests.get(url, params=params, headers=headers, timeout=10) except requests.exceptions.Timeout: print(f"第{page}页超时,跳过") continue if resp.status_code != 200: print(f"第{page}页失败,状态码{resp.status_code}") continue data = resp.json() comment_list = data.get("data", {}).get("commentList", []) for item in comment_list: all_comments.append({ "comment_id": item.get("id"), "user_name": item.get("userName"), "content": item.get("content", "").strip(), "score": item.get("socre"), # 携程接口里评分字段是socre,注意别打错 "time": item.get("commentTime") }) # 友好限速,避免触发封IP time.sleep(1 + page % 3) return pd.DataFrame(all_comments) # 用法示例 # df = fetch_ctrip_comments(resource_id=360, pages=3) # df.to_csv("ctrip_comments.csv", index=False, encoding="utf-8-sig")

逻辑说明:requests.get的params参数会自动把字典拼成查询字符串,不需要手动拼接URL;headers里必须带上Referer,否则很多站点会返回406;timeout=10防止某个接口卡死拖慢整个采集。参数说明:resource_id是景点ID,可以在景点页面的URL里找到;page是页码;size是每页条数,不建议超过接口上限,一般20到30比较安全。这里用time.sleep(1 + page % 3)做了简单限速,让请求间隔在1到3秒之间波动,比固定间隔更不容易被识别成脚本。

如果接口返回的是HTML而不是JSON,就用lxml的xpath提取。xpath路径要用抓包时看到的DOM结构来写,比如评论列表项是//div[contains(@class,"comment-item")],具体class名要按实际情况调整。用xpath的text()函数时,如果评论里有图片或换行,text()只能取到当前节点直接文本,建议改用string(.//p)或者把所有//text()拼起来,避免内容被截断。

2.3 马蜂窝景点评论的翻页与重试策略

马蜂窝的评论加载方式更“野生”一点。有的接口返回JSON,有的返回渲染好的HTML片段,这时用requests加xpath就能搞定。先抓包看响应类型,再决定处理方式。如果是HTML片段,示例代码如下:

from lxml import etree import requests import time def fetch_mafengwo_comments(url, headers): resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 按内容检测编码,防止中文乱码 html = etree.HTML(resp.text) comments = html.xpath('//div[contains(@class,"comment-item")]') result = [] for item in comments: # 有的节点文本分散在多个子节点里,必须把所有text()拼起来才能拿到完整句子 content_parts = item.xpath('.//p[contains(@class,"text")]//text()') content = "".join(content_parts).strip() if content: result.append(content) return result

逻辑说明:resp.apparent_encoding会根据页面内容推断编码,比直接写死utf-8更稳;xpath里的.//p[contains(@class,"text")]//text()会取出该节点下所有层级文本节点,解决了部分文本被<span>或<br>隔断的问题。参数说明:headers里建议带上完整的浏览器请求头,包括Accept-Language,否则可能返回繁体或乱码。

马蜂窝对请求频率很敏感,连续快速翻页十几页就可能有验证码。常见做法是给爬虫加重试机制,下面这个指数退避装饰器可以直接用:

import time from functools import wraps def retry(max_retries=3, delay=2): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: print(f"第{attempt + 1}次失败: {e}") if attempt == max_retries - 1: raise time.sleep(delay * (2 ** attempt)) return None return wrapper return decorator @retry(max_retries=3, delay=2) def fetch_page(url, headers): resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() return resp

参数说明:max_retries是最大重试次数,delay是初始等待秒数,每次失败后等待时间翻倍,第二次等4秒,第三次等8秒。这样做能有效应对临时网络抖动,也不会在被封后继续疯狂打请求。如果整个毕设的数据量很大,可以再考虑Scrapy加Redis做分布式爬虫,但单机加限速对景点评论这个体量已经够用。记住,爬虫只是获取学习数据的工具,控制频率,遵守目标站点的robots协议,别做影响站点正常运行的抓取。

3. 把中文评论变成可计算的向量:情感分析模型选型和预处理链路

爬下来的评论是中文自然语言,不能直接丢给模型。要先经过清洗、分词、去停用词,再转换成特征向量,最后才能分类。这一章的链路同样适用于绝大多数中文文本分析任务。

3.1 分词、去停用词和情感词典:朴素但有效的baseline

先写一个通用的文本清洗函数。旅游评论里常见的问题是:带表情符号但不显示、标点混杂、还有“哈哈哈”这类无信息词。清洗规则要简单可解释,别堆太多正则。

import re import jieba # 从外部文件加载停用词表 stopwords = set() with open("stopwords.txt", "r", encoding="utf-8") as f: for line in f: word = line.strip() if word: stopwords.add(word) def clean_text(text): if not isinstance(text, str): text = str(text) # 去掉非中文、数字、字母的字符,保留基本语气词 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text) words = jieba.lcut(text) # 过滤停用词、单字和纯空白 words = [w for w in words if w.strip() and w not in stopwords and len(w) > 1] return " ".join(words)

逻辑说明:正则先把评论里所有标点、表情符号、特殊字符替换成空字符串,避免它们干扰分词。jieba.lcut会把文本切成词列表,默认包含单字。过滤条件len(w) > 1是为了去掉“的、了、在”这类高频但没有情感贡献的字,停用词表则用来过滤更长的无意义词组。参数说明:停用词表可以自己维护,也可以从网上下载哈工大停用词表,但记得把景点地名和“酒店”“门票”这类专有词保留,否则会影响后续特征。

有了清洗后的文本,最简单的baseline是用情感词典打分。比如加载一个含情感极性权重的词典,统计正向词和负向词的数量差:

def sentiment_score_by_dict(words, pos_dict, neg_dict): score = 0 for w in words: if w in pos_dict: score += 1 elif w in neg_dict: score -= 1 return score

这个方法的优点是快、可解释,缺点是很难处理“景色很美但人太多”这种转折。但你可以把它作为基准线,后续模型如果连这个基准都超不过,说明特征工程或训练数据有问题。

3.2 用SnowNLP还是BERT?毕设的精度与算力平衡

很多同学一上来就选BERT,结果发现自己只有一个CPU笔记本,训练一轮要几个小时。毕设合理的路线是:先用SnowNLP或者TF-IDF加逻辑回归把流程跑通,再根据剩余时间和算力决定要不要升级到深度学习模型。

SnowNLP的好处是开箱即用:

from snownlp import SnowNLP text = "风景很美,但是人太多,排队排到崩溃" s = SnowNLP(text) print(s.sentiments) # 大概率输出一个低于0.5的数

但要注意,SnowNLP默认的朴素贝叶斯模型是在电商和微博语料上训练的,对旅游评论的“山清水秀”“栈道很险”这类表达感知很差。直接拿默认模型来做毕设展示,很容易被导师问倒。常见的做法是拿它当baseline,然后自己标注几千条旅游评论重新训练。

更稳的替代方案是用TF-IDF向量加逻辑回归。这个组合在几千条短文本分类上通常能拿到0.8以上的准确率,而且训练只需要几十秒:

from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split # X是clean_text处理后的评论列表,y是0/1标签(0负面,1正面) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) vec = TfidfVectorizer(max_features=5000, ngram_range=(1, 2), min_df=2) X_train_vec = vec.fit_transform(X_train) X_test_vec = vec.transform(X_test) clf = LogisticRegression(C=1.0, solver="liblinear", max_iter=1000) clf.fit(X_train_vec, y_train) print("测试集准确率:", clf.score(X_test_vec, y_test))

逻辑说明:TfidfVectorizer把每个评论变成稀疏向量,ngram_range=(1, 2)同时考虑单个词和相邻两个词组合,能捕捉“不美”和“不”“美”连在一起的含义。参数说明:max_features=5000限制特征数量,防止维度爆炸;min_df=2过滤掉只在一条评论里出现的词,这类词对分类没有统计意义;C=1.0是正则化强度的倒数,越小越防过拟合。liblinear适合小数据集。

如果你确实想在论文里写深度学习,也可以加一个LSTM或者TextCNN层,但前置条件是你有至少一万条标注数据。不然小数据上深度学习很容易被传统方法碾压。另外,现在多模态情感分析是热门方向,你可以额外抓取评论里附带的图片,用预训练的VGG或ResNet提取视觉特征,再和文本特征拼接,做一个简单的多模态融合。这个点可以作为论文创新点,但核心还是先把文本基线做好。

3.3 标注数据不够怎么办:伪标签与主动学习

毕设最常见的坑是:爬了几千条评论,不可能全部人工标注。我当时用了一个取巧的办法:先用规则和SnowNLP给未标注数据打一个初步标签,再人工抽查修正。更科学的做法是伪标签加主动学习。

伪标签的思路:先用一小部分人工标注数据训练一个模型,然后用这个模型对未标注数据预测,把置信度超过阈值的样本直接当作真实标签加入训练集,再重新训练。代码示意:

import numpy as np # 假设unlabeled_vec是未标注数据的TF-IDF向量 probs = clf.predict_proba(unlabeled_vec)[:, 1] # 置信度大于0.9或小于0.1的样本,模型非常有把握 pseudo_idx = (probs > 0.9) | (probs < 0.1) pseudo_y = (probs > 0.5).astype(int) # 将伪标签样本加入训练集(X_unlabeled是原始未标注文本) X_augmented = np.concatenate([X_train, X_unlabeled[pseudo_idx]]) y_augmented = np.concatenate([y_train, pseudo_y[pseudo_idx]]) # 然后用新的训练集重新fit和transform

参数说明:置信度阈值0.9和0.1是经验值,阈值设得越高,引入噪声越少,但可用的新增数据也越少。你可以用验证集来回测不同阈值。主动学习则是反过来:训练完之后,把模型预测概率最接近0.5的样本挑出来让人工标注,因为这些样本最“不确定”,信息量最高。两个方法结合,能让你用几百条人工标注就得到还不错的模型。

4. 前后端分离落地:Flask/Vue还是Django+React,接口怎么设计

很多毕设的系统演示环节问题不在算法,而在前后端联调。前后端分离项目实战的核心理念是:后端只提供数据和能力接口,前端只负责渲染和交互。这样你可以分别开发、分别测试,答辩时也能清楚说清每个部分。

4.1 项目结构拆分:后端只做接口,前端只做展示

我推荐的目录结构是这样的:

travel_sentiment/ ├── backend/ │ ├── app.py # Flask入口 │ ├── spider/ # 爬虫模块 │ ├── analysis/ # 情感分析模块 │ ├── database.py # 数据库连接与ORM │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ ├── views/ │ │ ├── components/ │ │ ├── api/ # 前端封装的后端请求 │ │ └── router/ │ ├── package.json │ └── vue.config.js └── README.md

后端用Flask足够轻量,前端用Vue或React都行。有人喜欢用若依框架快速搭后台管理界面,但如果你只想做几个可视化页面,若依反而偏重。前后端分离的意义是:前端只通过HTTP接口拿数据,不直接碰数据库,也不直接调用模型。这让你在答辩时可以演示“把爬虫模块停掉,系统还能通过已有数据库展示分析结果”,逻辑很清晰。

4.2 情感分析接口的输入输出设计与并发处理

接口设计要统一返回格式,不让前端去猜。下面是一个Flask接口示例:

from flask import Flask, request, jsonify from analysis.sentiment import predict_sentiment app = Flask(__name__) @app.route("/api/analyze", methods=["POST"]) def analyze(): data = request.get_json(silent=True) or {} text = data.get("text", "") # 防止空文本导致模型崩溃 if not text.strip(): return jsonify({"code": 1, "msg": "text is required"}), 400 result = predict_sentiment(text) # 统一返回结构:code、msg、data return jsonify({ "code": 0, "msg": "success", "data": { "sentiment": result["label"], "score": result["score"] } }) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)

逻辑说明:request.get_json(silent=True)不会因为请求体不是JSON而直接抛异常,而是返回None,再用空字典兜底。参数说明:host="0.0.0.0"让后端可以被同一局域网内的其他设备访问,方便答辩时用一台笔记本连投影仪和手机做演示;debug=False避免调试模式下暴露过多信息。

前端调用时用fetch就行:

const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "风景不错,但门票太贵" }) }); const json = await res.json(); if (json.code === 0) { console.log(json.data.score); }

前后端分离后最大的坑是跨域。开发时前端跑在8080端口,后端在5000端口,浏览器会拦截请求。解决方式有两种:第一种在后端启用CORS,第二种用前端代理。前端代理更干净,Vue脚手架里配置vue.config.js:

module.exports = { devServer: { proxy: { "/api": { target: "http://localhost:5000", changeOrigin: true } } } };

这样前端请求/api/analyze时,开发服务器会把请求转发到localhost:5000,浏览器看到的是同源请求。参数说明:target是后端地址,changeOrigin: true会把请求头里的Origin改成目标地址,很多框架依赖这个判断来源。

4.3 用ECharts做景点情绪趋势图表的快速方案

可视化不必做得太复杂,ECharts就够。你需要从后端拿到按日期聚合的正向评论占比,比如接口返回:

{ "dates": ["2024-10-01", "2024-10-02"], "positive_rates": [0.72, 0.65] }

前端画一个折线图:

const chart = echarts.init(document.getElementById("trend")); chart.setOption({ tooltip: { trigger: "axis" }, xAxis: { type: "category", data: dates }, yAxis: { type: "value", name: "正面占比", max: 1 }, series: [{ type: "line", data: positiveRates, smooth: true, areaStyle: { opacity: 0.1 } }] });

逻辑说明:smooth: true让折线更圆滑,好看但不改变数据真实性。参数说明:max: 1对应0到1之间的情感得分占比。你还可以做一个饼图展示正面、负面、中性评论的分布,ECharts官方示例可以直接改数据。这里真正的工作量在后端聚合接口,前端只是把数组塞进去。

5. 避坑与常见问题:从爬虫被封到模型精度差,我踩过的五个坑

毕设翻车大多不是某个环节不会,而是小问题叠加。我把调试过程中最常见的五个现象、原因和解决办法列出来,按“现象→原因→解决”的顺序看,能省很多时间。

5.1 现象:携程接口返回空数据或验证码

明明抓包时用浏览器能看到评论,用requests请求却返回空列表或者一个验证码页面。原因通常有三个:请求头少了Referer、Cookie没带、请求频率太高被临时限流。解决方法是先在代码里补全浏览器常用请求头,然后手动从浏览器复制一次Cookie放进去验证能否请求成功。如果带着Cookie成功,说明接口对登录状态有要求。再检查代码里的翻页参数是不是有签名之类的动态字段,比如timestamp加sign。如果发现加密参数,就别硬刚,改用Selenium或Playwright加载页面后直接提取渲染后的DOM,速度慢一点,但毕设完全够用。

5.2 现象:马蜂窝评论字数被截断

抓下来的一批评论只有前十几个字,后文全是省略号或者直接没了。原因是页面使用懒加载,初始化时只渲染摘要,需要点击“展开”才显示全文。解决方法是抓包查看“展开”按钮对应的XHR请求,通常这个请求里会带有commentId参数,服务端会返回完整评论内容。如果你的爬虫拿的是HTML片段,那就把提取规则从只取text()改成取某个节点的innerText或所有子文本节点拼接。注意,拼接时要用空格或换行连接,否则多个句子会粘在一起。

5.3 现象:SnowNLP对旅游评论准确率偏低

用默认SnowNLP跑几条真实评论,发现“景区的导游服务很贴心”被判成负面,“酒店环境一般”反而被判成正面。原因是SnowNLP自带的模型是在电商购物、微博语料上训练的,情感表达和旅游场景差异很大。解决方法是不要直接用默认模型,而是用自己标注的评论数据训练一个领域模型。如果没时间标注,就换用TF-IDF加逻辑回归,你自己标注的几百条数据足以训练出一个明显好过SnowNLP的分类器。实际操作里,SnowNLP只建议用来做初筛和baseline,不能作为最终结果。

5.4 现象:前端调接口跨域报错

浏览器控制台报CORS policy或者Access-Control-Allow-Origin错误,原因很简单:前端和后端运行在不同的端口,跨域请求被浏览器拦截。解决方法是给后端加flask-cors,一行代码搞定:

from flask_cors import CORS CORS(app, resources={r"/api/*": {"origins": "*"}})

或者在前端开发服务器里配置代理。这两种方式我都用过,建议开发阶段用前端代理,答辩部署时用CORS,因为打包后的前端会跟后端分成两个服务,代理配置就不好使了。

5.5 现象:数据库表设计不合理导致评论去重困难

爬虫跑了两轮之后,数据库里出现了大量同一条评论的重复记录,情感分析结果被重复统计,导致图表数据失真。原因是在设计表时没有为评论创建唯一约束。解决方法是给评论ID字段建立唯一索引,并设置INSERT OR IGNORE。如果用MySQL,表结构里加上:

CREATE TABLE comments ( id INT PRIMARY KEY AUTO_INCREMENT, comment_id VARCHAR(64) NOT NULL, source VARCHAR(16) NOT NULL, content TEXT, score DECIMAL(2,1), create_time DATETIME, UNIQUE KEY uk_source_comment (source, comment_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这样同一来源的同一个comment_id只能插入一次,重复运行爬虫也不会污染数据。注意comment_id在携程和马蜂窝里长度不一定一样,统一用VARCHAR(64)存。

6. 把毕设做成加分项:模型效果验证与部署演示技巧

情感分析系统不能只展示准确率,答辩时要让评委看到模型真正区分正负面的能力。最简单的做法是输出分类报告和混淆矩阵:

from sklearn.metrics import classification_report, confusion_matrix print(classification_report(y_test, y_pred, target_names=["负面", "正面"])) print("混淆矩阵:") print(confusion_matrix(y_test, y_pred))

分类报告里Precision、Recall、F1比准确率更敏感。比如正面类别Recall低,说明很多正面评论被漏掉了,你可以在答辩时主动解释“这是样本不平衡导致,下一步可以增加正面样本或调整类别权重”。这句话比“准确率0.8”有说服力得多。

部署演示有一个技巧:准备5到10条覆盖不同场景的真实评论,比如“景色绝美但爬山太累”“交通方便服务好”“门票贵且排队久”,写进一个固定测试集,在演示时逐条跑,让评委直观看到系统对转折句、情绪混合句的处理结果。不要临时输入随机评论,万一模型输出不稳定,答辩会很难看。

我当时做这个方向时最亏的一件事是前两周都在折腾爬虫,后来才发现真正加分的是把模型和演示闭环做好。经验是先把爬虫接口、数据库、情感分析接口、前端页面串成一个最小demo,哪怕先用50条数据,也要把整条链路跑通,再回头补数据量和模型精度。这样你始终有一个“能演示的版本”在手,后面每一步优化都是在已有系统上做增量。希望你做毕设时别走这个弯路,早点把闭环跑起来。希望帮到你。

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

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

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

立即咨询