
简介一套基于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 的请求这个请求就是最稳定的数据入口。步骤可以这样记打开景点评论页Network 面板清空。往下翻页触发新的评论请求。逐个看返回体找到包含评论内容、评分、发布时间的 JSON。把这条请求的完整 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, page1, page_size20): # 这是我抓到的评论列表接口路径不同区域可能不同 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, headersHEADERS, paramsparams, timeout10) 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, page1): # 马蜂窝 POI 详情页下方的评论通常有一个异步接口 url fhttps://www.mafengwo.cn/poi/{poi_id}.json params {page: page} resp requests.get(url, headersHEADERS, paramsparams, timeout10) 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_keyTrue, autoincrementTrue) source Column(String(20)) # ctrip / mafengwo spot_id Column(Integer, indexTrue) spot_name Column(String(100)) content Column(Text) # 评论原文 rating Column(Float) # 平台星级5.0 / 4.0 comment_time Column(DateTime) # 发布时间 sentiment_label Column(Integer, nullableTrue) # 0 负面1 正面 sentiment_score Column(Float, nullableTrue) # 模型输出的概率 engine create_engine( mysqlpymysql://root:你的密码127.0.0.1/scenery_db?charsetutf8mb4, echoFalse ) Base.metadata.create_all(engine) Session sessionmaker(bindengine) session Session()这里最容易踩坑的地方是连接串里的 charsetutf8mb4。评论里经常有表情符号用 utf8 存储会直接报错utf8mb4 才认 emoji。建表时顺手把 sentiment 两个字段一并设计进去后面分析完直接 UPDATE 回填不用改表结构。spot_id 加索引查询按景点筛选时快不少。2.5 简单调度与去重别裸跑请求也别全量重复抓旅游评论不是天天变的数据源我建议做成增量抓取每天最多抓每个景点前几页。每一次请求之间加随机延时避免请求节奏太规律。for page in range(1, 6): comments fetch_ctrip_comments(SPOT_ID, pagepage) for c in comments: # 用“内容发布时间”判断是否已存在 existed session.query(Comment).filter_by( contentc[content], comment_timec[commentTime] ).first() if not existed: session.add(Comment(sourcectrip, contentc[content], comment_timec[commentTime], spot_idSPOT_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(rhttps?://\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_size128, window5, min_count2, workers4) 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_size128 表示词向量维度太大训练慢太小语义信息装不下window5 表示每个词看前后 5 个词做上下文min_count2 过滤只出现一次的生僻词。MAX_LEN32 是截断长度短评论直接补零长评论只取前 32 个词。补零的位置在模型里用 Masking 层跳过避免无效计算。from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Masking model Sequential() model.add(Masking(mask_value0.0, input_shape(32, 128))) model.add(LSTM(64, dropout0.3)) model.add(Dense(1, activationsigmoid)) model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) model.fit(X, y, epochs5, batch_size32, validation_split0.2)LSTM 单元数取 64对短文本分类已经够用dropout0.3 用来防小数据集过拟合epochs5 是我实测下来比较稳的迭代次数继续跑下去训练集精度会涨但验证集大概率不涨反跌。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, sizeGET/api/stats返回情感统计结果spot_idPUT/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(bindengine)() 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, typeint) comments session.query(Comment).filter_by(spot_idspot_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_id3如果返回 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 存储失败后整条记录可能写入不正常。解决连接串统一写成 mysqlpymysql://root:密码127.0.0.1/scenery_db?charsetutf8mb4建表语句保持 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 blockedNetwork 面板里状态码是 200但响应被拦截。原因前端 5173 端口和后端 5000 端口不同浏览器默认拦截跨源请求。这个和代码本身没关系是浏览器的安全策略。解决优先用 Vite 的 proxy前端请求里写 /api/statsvite 把请求转发到后端。或者在 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 每天凌晨拉一次增量评论。第三数据库里造好一批演示数据无论演示时网络环境怎么样页面都能立刻出图。我在页面右侧加了三个统计卡片总评论数、正面占比、负面占比下面放柱状图和词云。字段不多但一眼能看出系统做了什么。答辩时打开页面从景点列表点几下能流畅出图这个项目的验收就稳稳过线。以前我做类似项目时最大的教训是花了两周调模型最后发现清洗和去重才是耗时最多的环节。模型跑通只用了半天数据准备却用了一整周。如果你现在刚开始写这个系统我建议先把爬虫和存储链路打通再回头碰模型。数据没进来之前所有模型都是黑匣子。希望这篇内容能帮你少走一段弯路把这套旅游评论情感分析系统扎扎实实落地。本文还有配套的精品资源点击获取