☰
旅游景点评论情感分析系统实战:从Python爬虫到前后端分离
2026/10/3 2:49:00 网站建设 项目流程

简介:一套基于Python的旅游景点评论情感分析系统,面向本科毕业设计、课程设计及项目实践,采用前后端分离架构,内置携程与马蜂窝两大旅游平台的评论爬虫,完整覆盖评论采集、数据清洗、情感倾向分析与可视化展示,适合计算机、人工智能、自动化等专业学生及开发者研究学习。资源包共102个文件、合计47.71MB,以Python源码为核心(约32个.py脚本),并集成Vue前端组件、TypeScript/JavaScript配置、HTML页面、JSON数据及Markdown说明文档,爬虫、后端接口、前端界面与部署配置均清晰可查。该项目为个人毕设作品,答辩评审分达98分,代码已经过调试测试,可正常运行,已有228人学习下载。通过完整项目源码、爬虫采集思路与情感分析实现细节,既能快速跑通系统用于毕业设计答辩或课程作业,也能在此基础上替换景点、扩充评论来源或优化分析模型,完成二次开发与功能拓展。

1. 旅游景点评论情感分析系统:带着携程和马蜂窝数据,到底能做成什么样

一到毕业季,旅游评论方向的项目十个里有八个卡在同一处:不是模型不收敛,而是爬回来的数据用不了、前后端接不通。这个基于 Python 的旅游景点评论情感分析系统,包含携程、马蜂窝爬虫,走前后端分离架构,目标就是把一条完整链路走通——评论数据能从两个平台落进数据库,情感分析能给出正负结论,前端能把这些结论画成图表。它适合两类人:一类是拿它做毕业设计,需要演示完整系统;另一类是想独立完成数据项目的新手工程师,想看看爬虫、算法、接口和页面是怎么拼起来的。看完你会有一个明确判断:这套东西值不值得做,真正花时间的点在哪。

2. 评论数据从哪来:携程与马蜂窝的接口设计与抓取落地

2.1 先找接口,别一上来就写死页面

抓携程、马蜂窝评论,最常见的错误是把整个 HTML 页面拉下来再用 XPath 抠字段。这个方案能跑,但是慢,而且平台改版一次就要大改一次。我一般先打开目标景点页面,按 F12 进开发者工具,切到 Network 面板,筛 XHR,然后翻到评论列表。浏览器会发出一个返回 JSON 的请求,这个请求就是最稳定的数据入口。

步骤可以这样记:

  1. 打开景点评论页,Network 面板清空。
  2. 往下翻页,触发新的评论请求。
  3. 逐个看返回体,找到包含评论内容、评分、发布时间的 JSON。
  4. 把这条请求的完整 URL、请求头和参数复制下来,先用 Postman 或 curl 验证一遍。

这个方法对绝大多数网页端有效。返回体里的字段名每家平台不太一样,但通常都有 content、rating、time 之类的关键字段。拿到真实接口后再写 Python 请求,后面的事情就顺了。

2.2 携程评论抓取:带 Cookie 分页循环的最小实现

携程网页端的评论列表,一般有一个独立的评论接口,返回 JSON。不同城市、不同景点域名可能有差异,所以我在代码里保留了一个很关键的操作:以抓包时看到的请求路径为准,而不是把一个 URL 写死。

import requests import random import time # 建议从浏览器复制一份完整的 UA,不要用默认 requests 的 UA HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/122.0.0.0 Safari/537.36", "Referer": "https://you.ctrip.com/", "Cookie": "这里填你抓包时浏览器里的 Cookie" } def fetch_ctrip_comments(spot_id, page=1, page_size=20): # 这是我抓到的评论列表接口路径,不同区域可能不同 url = "https://you.ctrip.com/dest/restapi-gw/comment/commentlist" params = { "resourceId": spot_id, # 景点资源 ID,在页面 URL 里能看到 "page": page, "pageSize": page_size, # 如果分页请求里带了 sign 或 token,需要从页面脚本里提取后补上 } resp = requests.get(url, headers=HEADERS, params=params, timeout=10) data = resp.json() comments = data.get("Result", {}).get("CommentList", []) return comments

这段代码的逻辑很直白:构造请求头,拼分页参数,拿到 JSON 后把 CommentList 取出来。参数里最重要的三个是 resourceId、page、pageSize。resourceId 对应景点 ID,从景点详情页 URL 里复制;pageSize 我习惯用 20,太大容易被校验;分页一般抓前 5 页就足够做分析,不要贪多。

2.3 马蜂窝评论与游记:JSON 接口优先,Selenium 只做兜底

马蜂窝的景点页面结构比携程散,不同 POI 可能走不同接口。我常用的方式仍然是以 XHR 请求为主,因为页面里能看到一个异步加载的 JSON 文件,返回结果里直接带着评论内容。下面是简化后的请求逻辑。

def fetch_mafengwo_comments(poi_id, page=1): # 马蜂窝 POI 详情页下方的评论,通常有一个异步接口 url = f"https://www.mafengwo.cn/poi/{poi_id}.json" params = {"page": page} resp = requests.get(url, headers=HEADERS, params=params, timeout=10) if resp.status_code != 200: return [] data = resp.json() if "data" not in data: return [] # 不同页面结构里,评论列表字段叫 lists 或 comment_list items = data["data"].get("lists", []) return [item.get("content", "") for item in items if item.get("content")]

如果异步接口拿不到数据,我才会退回去用 Selenium 打开页面等渲染完成,再截取评论节点。但 Selenium 慢,而且容易被识别,能不走就不走。马蜂窝的评论内容里经常带图片描述和 @用户,清洗阶段要额外处理这些噪声。poi_id 的定位方式和携程一样,从景点页 URL 的数字字段里取。

2.4 用 SQLAlchemy 存储评论:模型字段与入库参数

爬下来的数据不落库,后面做分析就无从下手。常见做法是用 SQLAlchemy 操作 MySQL,两张小表就能撑起整套系统:一张存景点,一张存评论。评论表里提前留好情感分析的字段,爬虫阶段先把内容写进去,后面再批量回填情感结果。

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__ = "comments" id = Column(Integer, primary_key=True, autoincrement=True) source = Column(String(20)) # ctrip / mafengwo spot_id = Column(Integer, index=True) spot_name = Column(String(100)) content = Column(Text) # 评论原文 rating = Column(Float) # 平台星级:5.0 / 4.0 comment_time = Column(DateTime) # 发布时间 sentiment_label = Column(Integer, nullable=True) # 0 负面,1 正面 sentiment_score = Column(Float, nullable=True) # 模型输出的概率 engine = create_engine( "mysql+pymysql://root:你的密码@127.0.0.1/scenery_db?charset=utf8mb4", echo=False ) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session()

这里最容易踩坑的地方是连接串里的 charset=utf8mb4。评论里经常有表情符号,用 utf8 存储会直接报错,utf8mb4 才认 emoji。建表时顺手把 sentiment 两个字段一并设计进去,后面分析完直接 UPDATE 回填,不用改表结构。spot_id 加索引,查询按景点筛选时快不少。

2.5 简单调度与去重:别裸跑请求,也别全量重复抓

旅游评论不是天天变的数据源,我建议做成增量抓取,每天最多抓每个景点前几页。每一次请求之间加随机延时,避免请求节奏太规律。

for page in range(1, 6): comments = fetch_ctrip_comments(SPOT_ID, page=page) for c in comments: # 用“内容+发布时间”判断是否已存在 existed = session.query(Comment).filter_by( content=c["content"], comment_time=c["commentTime"] ).first() if not existed: session.add(Comment(source="ctrip", content=c["content"], comment_time=c["commentTime"], spot_id=SPOT_ID)) session.commit() time.sleep(random.uniform(0.5, 1.5))

代码里每次抓完一页就 sleep 一次,随机范围 0.5 到 1.5 秒,既不会把自己请求频次提得太高,也不至于太慢。去重逻辑放在入库前,用“内容 + 发布时间”做组合条件查一遍,避免定时任务重复跑导致数据翻倍。爬虫启动前请先看目标网站的 robots.txt 和用户协议,这段代码只适合个人学习或毕业设计演示,别拿去做商业采集。

3. 情感分析怎么做:从词典基线到深度模型的三种选型

3.1 先清洗再分析:表情、URL、重复评论的处理

评论数据从平台回来之后,几乎不可能直接用。表情标签、URL、@用户、多余空白都会干扰后续的向量化和统计。我的清洗顺序是先去链接,再去 @用户和话题标签,最后压缩空白。

import re def clean_text(raw: str) -> str: raw = re.sub(r"https?://\S+", "", raw) # 去掉 URL raw = re.sub(r"@\w+", "", raw) # 去掉 @ 用户 raw = re.sub(r"\[.*?\]", "", raw) # 去掉表情标签 raw = re.sub(r"\s+", " ", raw) # 压缩空白 return raw.strip()

这个函数每一行都是针对评论里常见的字符噪声。需要注意顺序:先去 URL,再去 @,最后处理空白。顺序反了的话,URL 里的 @ 会被误删,文本信息丢得更多。清洗后的文本直接覆盖原字段,同时保留一份未清洗版本,方便后续人工核验时对照。

3.2 规则词典基线:否定词、程度词与情感词典怎么配

我强烈建议在跑任何深度学习模型之前,先做一个规则词典基线。它不需要标注数据,就是把公开情感词典里的词分成正面集合和负面集合,再叠加否定词和程度副词,给每句话算一个分。这个方案精度不 fancy,但它让你知道模型翻车时问题出在数据还是出在算法。

import jieba NEG_WORDS = {"不", "没", "别", "无", "莫", "不太", "不怎么"} DEGREE = {"很": 1.5, "非常": 2.0, "太": 1.8, "有点": 0.8} def score_of(text, pos_words, neg_words, stopwords): words = [w for w in jieba.lcut(text) if w not in stopwords] score = 0 negate = 1 for w in words: if w in NEG_WORDS: negate = -1 elif w in pos_words: score += negate * 1.0 negate = 1 elif w in neg_words: score -= negate * 1.0 negate = 1 return score

这里的 pos_words 和 neg_words 可以加载公开的 BosonNLP 情感词典,正面词和负面词各保存成一个 set。打分逻辑是遇到负面词就乘以 -1,相当于处理“不好”“不太满意”这类反转表达。程度词表里给“非常”“太”这类词设置权重,乘以 1.5 或 2.0,让强烈语气影响更大。这个基线在 0 标注数据的情况下能给出可用结果,后面拿它和深度学习模型做对照,才知道模型到底有没有变好。

3.3 把评论转成向量再进 LSTM:一份能跑的简化训练流程

如果数据量到达 1000 条以上,就可以做深度模型方案。我一般用 Word2Vec 把评论转成向量序列,再接一层 LSTM。整个过程比想象中短,关键点在于分词和等长填充。

import numpy as np import jieba from gensim.models import Word2Vec # texts 是清洗后的评论列表 corpus = [jieba.lcut(t) for t in texts] w2v = Word2Vec(corpus, vector_size=128, window=5, min_count=2, workers=4) MAX_LEN = 32 def encode(text): vecs = [] for w in jieba.lcut(text): if w in w2v.wv: vecs.append(w2v.wv[w]) if len(vecs) >= MAX_LEN: break while len(vecs) < MAX_LEN: vecs.append([0.0] * 128) return vecs X = np.array([encode(t) for t in texts])

vector_size=128 表示词向量维度,太大训练慢,太小语义信息装不下;window=5 表示每个词看前后 5 个词做上下文;min_count=2 过滤只出现一次的生僻词。MAX_LEN=32 是截断长度,短评论直接补零,长评论只取前 32 个词。补零的位置在模型里用 Masking 层跳过,避免无效计算。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Masking model = Sequential() model.add(Masking(mask_value=0.0, input_shape=(32, 128))) model.add(LSTM(64, dropout=0.3)) model.add(Dense(1, activation="sigmoid")) model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"]) model.fit(X, y, epochs=5, batch_size=32, validation_split=0.2)

LSTM 单元数取 64,对短文本分类已经够用;dropout=0.3 用来防小数据集过拟合;epochs=5 是我实测下来比较稳的迭代次数,继续跑下去训练集精度会涨,但验证集大概率不涨反跌。y 是人工标注好的标签,建议正负样本数量不要差太多,差距超过 3 比 1 时先做欠采样或给少数类加权重。

3.4 三种方案怎么选:规则、SnowNLP 与深度学习对比

毕业设计阶段不需要把三种都跑完,但至少要知道自己选的那条路边界在哪。下面是我用同类项目对比时的经验值。

方案是否需要标注可解释性典型效果运行速度适用场景
规则词典不需要高基础,能区分明显情绪极快快速原型、启动阶段
SnowNLP不需要低对旅游文本不够贴合快当另一个基线做对照
Word2Vec + LSTM需要 500 条以上标注低明显优于词典中毕设系统主模型

SnowNLP 跑旅游评论容易给出接近 0.5 的概率,原因在于预训练语料是购物评价,和旅游场景有偏差。如果不想标注数据,可以先接规则词典做展示;如果想让系统看起来更完整,就去标几百条数据训练 LSTM,这是性价比最高的路线。

3.5 多模态思路留个口子

如果爬到的数据里平台还给了景点图片,可以把图片 OCR 出来的文本拼到原评论后面,再一起送入情感模型,这条路正是多模态情感分析的方向。做毕业设计时不用铺开,作为“后续扩展”写在文档里就够了。

4. 前后端分离怎么串起来:Flask 接口与 Vue 页面设计

4.1 后端接口清单:为什么选 Flask 而不是 Java 体系

前后端分离指的是后端只出 JSON,前端只负责渲染,两边通过 HTTP 接口通信。这里选 Flask 是最省事的方案,因为爬虫和情感分析都是 Python,后端与算法层共用一套依赖和数据库会话。Java 的 Spring Boot 或若依框架适合做后台管理系统,但在这个项目里会平白多出一套环境成本。

我实际项目里只设计了四个核心接口,够演示也够扩展。

方法路径作用主要参数
GET/api/spots返回景点列表无
GET/api/comments分页查询评论spot_id, page, size
GET/api/stats返回情感统计结果spot_id
PUT/api/comments人工修正情感标注id, sentiment_label

接口数量不要堆太多,毕设演示时翻来覆去就那几个页面,接口多了反而分散精力。把 stats 做好,页面展示就成功了 80%。

4.2 写一个聚合统计接口:查询、计数与词频一次返回

核心接口是 stats,它要返回评论总数、正负分布、月度趋势和 top 词频。数据量在几万条以内时,直接在路由里用 Python 算就行,没必要上消息队列。

from flask import Flask, jsonify, request from collections import Counter from sqlalchemy.orm import sessionmaker import jieba app = Flask(__name__) session = sessionmaker(bind=engine)() def month_trend(comments): trend = {} for c in comments: key = c.comment_time.strftime("%Y-%m") trend[key] = trend.get(key, 0) + 1 return trend @app.route("/api/stats") def stats(): spot_id = request.args.get("spot_id", type=int) comments = session.query(Comment).filter_by(spot_id=spot_id).all() pos = sum(1 for c in comments if c.sentiment_label == 1) neg = len(comments) - pos words = Counter() stopwords = {"的", "了", "很", "在", "是", "我", "有"} for c in comments: for w in jieba.lcut(c.content): if len(w) > 1 and w not in stopwords: words[w] += 1 return jsonify({ "total": len(comments), "pos": pos, "neg": neg, "pos_rate": round(pos / max(len(comments), 1), 4), "trend": month_trend(comments), "top_words": words.most_common(10) })

这段代码的重点是对 self 查询结果做二次统计:sentiment_label 是前面情感分析回填的字段,month_trend 按“年-月”分组计数,词频用 Counter 一行搞定。使用 max(len(comments), 1) 是为了防止除零报错。分词时把单字词过滤掉,否则 top 榜上全是“的”“了”“很”。

4.3 Vue3 前端工程:axios 拉数据、ECharts 画图的最小截面

前端我用 Vue3 + Vite + Element Plus 搭的,页面不需要复杂路由,一个仪表盘页面就够:左侧是景点列表,右侧是正负分布柱状图和词云。核心逻辑就是 axios 调后端接口,把数据交给 ECharts。

// src/App.vue import axios from "axios"; import * as echarts from "echarts"; export default { data() { return { stats: null }; }, mounted() { this.loadStats(); }, methods: { async loadStats() { const { data } = await axios.get("/api/stats", { params: { spot_id: 3 } }); this.stats = data; this.renderChart(); }, renderChart() { const chart = echarts.init(document.getElementById("chart")); chart.setOption({ xAxis: { data: ["正面", "负面"] }, yAxis: {}, series: [{ type: "bar", data: [this.stats.pos, this.stats.neg] }] }); } } };

axios 请求放在 mounted 里,页面加载后立刻拉数据。ECharts 的 setOption 接受普通对象,和接口返回的 JSON 结构完全对得上。如果后端返回正负数量,直接塞进 bar 的 data 数组即可。接口返回的 top_words 可以用于词云,需要一个 wordcloud 类型的 series,但核心逻辑和柱状图一样。

4.4 联调时看什么:状态码、跨域和数据结构

前后端分离最容易在“联调”这一步卡住。先用 curl 测后端,再开前端页面。

curl "http://127.0.0.1:5000/api/stats?spot_id=3"

如果返回 JSON,就确认后端正常。前端默认端口是 5173,后端是 5000,跨域问题几乎必然出现。我的处理方式是优先用 Vite 的 proxy 配置,前端代码里只写相对路径,浏览器访问同源地址,不触发跨域。

// vite.config.js export default { server: { proxy: { "/api": "http://127.0.0.1:5000" } } }

如果前端报了 404,先看后端路由是否真的注册了 /api/stats;如果报 CORS,再考虑加 flask-cors 组件。数据出现 undefined,说明接口返回的 key 名跟页面里取的不一致,打开浏览器 Network 面板对比 response body 和代码里的字段名。

5. 毕设避坑:五个让系统翻车的细节和排查手段

5.1 评论入库后全是乱码口口口

现象:MySQL 里查评论内容,中英文正常但 emoji 变成口口口,甚至直接报错。

原因:数据库连接串没加 charset,或建表时默认字符集不是 utf8mb4。MySQL 的 utf8 字符集不完整支持四字节表情字符,emoji 存储失败后整条记录可能写入不正常。

解决:连接串统一写成 mysql+pymysql://root:密码@127.0.0.1/scenery_db?charset=utf8mb4,建表语句保持 utf8mb4。已经建好的表可以执行 ALTER TABLE comments CONVERT TO CHARACTER SET utf8mb4 补救。代码里爬虫返回的文本也要手动设置 resp.encoding = "utf-8",否则拿到的是乱码原文。

5.2 携程评论只爬到前几页就停住

现象:第一页第二页正常,第三页开始返回空数组或校验错误。

原因:评论接口的分页参数里带了动态 token 或 sign,这个值是页面脚本在加载时生成的,直接复用第一页的参数带不过去。

解决:用开发者工具对比第一页和第二页请求的 query string,找出变化的参数。常见的做法是从页面嵌入的 JSON 里提取最新的 sign,把它塞进 params。如果实在拿不到,就接受“只分析前几页评论”的现状,题目本身不会因为数据量小而不成立。

5.3 前端页面白屏,控制台报跨域错误

现象:浏览器 F12 里显示 Access to XMLHttpRequest has been blocked,Network 面板里状态码是 200,但响应被拦截。

原因:前端 5173 端口和后端 5000 端口不同,浏览器默认拦截跨源请求。这个和代码本身没关系,是浏览器的安全策略。

解决:优先用 Vite 的 proxy,前端请求里写 /api/stats,vite 把请求转发到后端。或者在 Flask 里加 flask-cors 并允许 5173 来源。两个方案用任意一个就行,同时开着反而可能出现重复的响应头。

5.4 SnowNLP 对五星好评也只给 0.55 的概率

现象:用 SnowNLP 跑评论,所有文本的概率都集中在 0.5 附近,正负区分度很小。

原因:SnowNLP 默认模型是在购物评价语料上训练的,旅游评论的用词习惯不一样,加上短文本本身情绪词密度低,模型输出在 0.5 附近徘徊是正常的。

解决:不要依赖这个结果做最终展示。把规则词典基线结果拿来直接用,或者标 1000 条数据训练 LSTM。SnowNLP 只能作为对照项,在论文里写“对比发现预训练模型与领域文本存在偏差”,反而是一个合理的分析点。

5.5 请求频率一高就被要求输入验证码

现象:爬虫跑了几百条评论之后,页面开始跳验证码,接口返回 403。

原因:请求频率太快,Cookie 和 UA 长期不变,特征被服务端识别。这算是常见反爬机制,不是异常。

解决:每次请求之间 sleep 随机 0.5 到 1.5 秒,多准备几份 User-Agent 轮换,定时任务每天只跑一次增量。出现验证码时不要硬刚,中断程序等一晚上再继续。如果目标是演示系统,提前把数据抓够存到库里,演示时只查数据库,不触发实时爬虫。

6. 进阶一步:模型验证与项目交付技巧

6.1 用 50 行脚本评估训练好的模型

情感分析模型表现如何,不能靠眼睛看几条评论就下结论。我习惯预留 100 条人工标注数据,训练时不参与,最后单独拿出来评估。

from sklearn.metrics import classification_report y_true = [1, 0, 1, 1, 0, ...] # 人工标注结果 y_pred = [1 if p > 0.5 else 0 for p in model.predict(X_test)] print(classification_report(y_true, y_pred))

把 F1-score 和混淆矩阵截图放进毕业论文,比贴十张图表都有说服力。也建议再算一个 Kappa 系数,反映标注一致性。模型好坏要有数字兜底,这是答辩时最稳的支撑材料。

6.2 做成可验收项目的三个具体建议

第一,爬虫和分析拆开跑。爬虫脚本单独用一个目录,分析结果存库后再启动后端,不要在 Flask 启动时去实时抓数据。第二,加一个定时任务,用 APScheduler 每天凌晨拉一次增量评论。第三,数据库里造好一批演示数据,无论演示时网络环境怎么样,页面都能立刻出图。

我在页面右侧加了三个统计卡片:总评论数、正面占比、负面占比,下面放柱状图和词云。字段不多,但一眼能看出系统做了什么。答辩时打开页面,从景点列表点几下能流畅出图,这个项目的验收就稳稳过线。

以前我做类似项目时,最大的教训是花了两周调模型,最后发现清洗和去重才是耗时最多的环节。模型跑通只用了半天,数据准备却用了一整周。如果你现在刚开始写这个系统,我建议先把爬虫和存储链路打通,再回头碰模型。数据没进来之前,所有模型都是黑匣子。希望这篇内容能帮你少走一段弯路,把这套旅游评论情感分析系统扎扎实实落地。

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

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

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

立即咨询