Python旅游景点评论情感分析系统:爬虫、模型与前后端分离实战
简介一套基于Python的旅游景点评论情感分析系统采用前后端分离架构集成携程与马蜂窝评论爬虫覆盖数据采集、评论文本清洗、情感极性判别与结果可视化等完整流程。项目为个人毕业设计答辩评审达98分代码均经过调试测试、可直接运行面向计算机、通信、人工智能、自动化等专业的学生、教师及从业者适合期末课程设计、课程大作业或毕业设计参考也可在源码基础上修改定制。压缩包共102个文件以32个Python源码和10个Vue组件为主体辅以JavaScript脚本、JSON配置、HTML页面、Markdown说明、图片素材及pyc编译文件整体约47.71MB便于按前端、后端、爬虫、文档等模块检索。已有228人浏览学习既可快速启动体验也能通过完整工程理解旅游评论情感分析从爬虫到可视化的实现思路。1. 旅游景点评论情感分析系统爬虫、模型与前后端分离怎么串成一条线这套基于 Python 的旅游景点评论情感分析系统乍看之下难点在“情感分析模型”真正拆下来最花时间的反而是两个数据源携程和马蜂窝的评论爬虫。携程评论页以服务端渲染为主马蜂窝又经常走异步接口清洗规则完全不是一套。系统整体采用前后端分离结构Python 端负责爬虫、清洗、情感分析和接口输出前端用 QuasarVue 生态做展示中间走 JSON 交互。它能跑到 98 分的答辩分不是靠模型本身多新奇而是链路完整从评论采集、人工标注、模型训练到结果页展示每一步都能现场跑通。适合 Python 课设、毕设也适合想完整走一遍“数据采集—特征处理—建模—可视化”闭环的从业者。2. 携程与马蜂窝爬虫请求参数、XPath 抽取与入库去重先说结论毕设里的爬虫目标是拿到公开评论数据不是跟网站风控系统正面对抗。所以采集范围限定在游客不加登录态也能看到的公开页请求频率控制在 1 到 3 秒一条这是原则也是答辩老师最常追问的合规点。这套系统里爬虫最核心的三件事请求头怎么给、评论文本怎么抽取、数据怎么去重。把这三件事拆开做后面接模型才不会乱。2.1 请求层requests 必要的 header 与限速逻辑写爬虫优先用 requests配合 lxml 做页面解析是这类评论分析项目最常见的技术组合。先把请求封装成一个独立函数后续爬携程还是马蜂窝都复用同一个请求入口。import time import random import requests def fetch_html(url, paramsNone, cookieNone): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept-Language: zh-CN,zh;q0.9, } if cookie: headers[Cookie] cookie resp requests.get(url, headersheaders, paramsparams, timeout10) resp.raise_for_status() resp.encoding resp.apparent_encoding or utf-8 return resp.text有几个参数要特别留意。timeout 设成 10 秒避免某次网络抖动让整个爬虫卡死resp.encoding 用 apparent_encoding 兜底不然携程页面偶尔返回 GBK 时解析出来全是乱码。User-Agent 给一个常见 Chrome 字符串就够用不需要把浏览器里几十个 header 都搬过来。Cookie 只在确实需要时才填直接从浏览器复制到代码里即可不要做登录态采集。实际抓取时每抓一页要 sleep 一下。我习惯用random.uniform(1.5, 3.0)既不会太快触发风控也不会让答辩现场等太久。马蜂窝的部分评论接口返回的是 JSON而不是完整 HTML所以 fetch_html 抓到的可能是半截页面。这种情况下可以先抓接口 URL再用resp.json()解析后面 2.2 会专门说解析差异。2.2 XPath 抽取评论正文text() 与 contains() 的配合评论页面的 DOM 通常包含多个 class直接用xpath(//div[classcommentItem])经常取不到节点。更好的方式是contains(class, comment_item)它不要求 class 完全匹配只判断节点里有这个 token。抽取正文时不要用单个text()要用.//text()把子标签里的文本拼起来否则会漏掉被span或p包住的半句话。from lxml import etree def parse_comments(html_text): tree etree.HTML(html_text) rows tree.xpath(//div[contains(class, comment_item)]) comments [] for row in rows: detail_node row.xpath(.//div[contains(class, comment_detail)]) if not detail_node: continue text_list detail_node[0].xpath(.//text()) text .join(x.strip() for x in text_list if x.strip()) if text: comments.append(text) return comments这里contains(class, comment_item)是应对多 class 场景比如classcomment_item comment_item--new也能命中。.//text()会把当前节点下所有文本节点拉出来再通过 strip 去掉空行最后用空格拼接解决换行和缩进带来的脏文本。数据量不大时用 lxml 比 BeautifulSoup 更好排查因为 XPath 可以复制出来逐层验证但如果前端结构变了最快要做的就是用浏览器把目标评论块选中再右键 Copy XPath然后改成相对路径。如果评论来自马蜂窝的异步接口返回体一般是 JSON。这种场景不要硬套 parse_comments直接解析 JSON 更干净import json def parse_poi_json(json_text): data json.loads(json_text) comment_list data.get(data, {}).get(comments, []) result [] for item in comment_list: result.append({ content: item.get(content, ), user: item.get(user, {}).get(name, ), }) return resultJSON 接口的问题在于字段名不稳定所以取值时全部用 get 兜底宁可漏一条也不要让程序中断。携程评论和某马蜂窝评论的时间字段、评分字段命名差异很大统一在清洗阶段处理。2.3 清洗与入库去重先落 CSV再入 MySQL爬下来的评论不能直接丢给模型。第一里面可能有一大段景区介绍第二同一句话可能被翻页重复抓第三换行符和 HTML 标签没去掉。所以清洗阶段我一般用 pandas 统一处理并先落一个 CSV 做中间产物方便后面人工标注。import pandas as pd all_rows [ {source: ctrip, poi: 龙门石窟, content: 风景不错但人太多了}, {source: mafengwo, poi: 龙门石窟, content: 风景不错但人太多了}, ] df pd.DataFrame(all_rows) df[content] df[content].str.strip().str.replace(\n, , regexFalse) df.insert(0, item_id, range(1, len(df) 1)) df.drop_duplicates(subset[poi, content], inplaceTrue) df.to_csv(comments.csv, indexFalse, encodingutf-8-sig)drop_duplicates 里的 subset 用的是“景点 评论内容”这个组合比只用 content 更安全因为不同景点可能出现完全相同的评论。item_id 是手动加的业务主键如果后续要入 MySQL可以直接拿它当自增 ID 的替代。CSV 保存时一定用 utf-8-sig否则用 Excel 打开会看到中文乱码这个坑在毕设演示时特别容易翻车。需要入库时建表结构可以按下面这个方向设计字段类型说明comment_idbigint 自增主键sourcevarchar来源ctrip / mafengwopoivarchar景点名称contenttext清洗后的评论正文sentiment_scorefloat情感得分模型推理后回填create_timedatetime抓取时间入 SQL 时用 INSERT IGNORE依靠唯一索引把重复数据挡在数据库层这样爬虫重跑不会造成数据翻倍。前后端分离项目里爬虫和情感分析最好分成两个目录爬虫只负责产出去重后的 CSV模型服务再读 CSV避免工程越写越乱。3. 情感分析核心从 SnowNLP 到自训练贝叶斯分类器情感分析模块是这套系统的“脸面”因为答辩演示时输入一句“工作人员态度很差”系统能不能给出负面判断直接影响评审观感。这里我先把选型思路讲清楚毕设场景不建议上 BERT 这类大模型数据量和 GPU 都不允许SnowNLP 或者自己训练的朴素贝叶斯分类器是成本最低、也最容易讲清楚原理的方案。3.1 SnowNLP 开箱即用的边界购物语料和景点语料不一样SnowNLP 的情感分析底层是贝叶斯分类默认训练语料偏电商评论比如“衣服质量很好”“物流很快”这一类。直接拿它来判断“景色绝美但索道排队太久”结果经常偏正面。因为“绝美”权重高“排队太久”被吞掉了。所以先跑一组基线看准确率能不能接受。from snownlp import SnowNLP sample_list [ 景色绝美非常推荐, 索道排队太久体验很差, 风景不错但人太多了, ] for text in sample_list: score SnowNLP(text).sentiments print(text, round(score, 3))sentiments 输出的是 0 到 1 之间的概率值越接近 1 越正面越接近 0 越负面。多数同学直接拿 0.5 当阈值实际上这么设对景点评论很不合适因为“人多”“排队”这类词在景点评论里高频出现语义跟电商“差评”不完全一样。我一般先把阈值放到 0.6然后再根据验证集来回调。如果你发现 10 条里面错 4 条不要怀疑代码而是默认语料跟你场景不匹配。这时候就进入下一步自己标注数据重新训练。3.2 自训练两分类器样本格式与模型保存SnowNLP 提供训练入口输入两个文本文件一个正面样本一个负面样本每行一句评论然后它会训练出一个新的模型文件。景点评论的正负样本可以来自爬虫结果的人工标注我只抽了 400 条就看出明显变化。from snownlp import sentiment # 训练文件编码统一用 utf-8每行一条评论 sentiment.train(data/xiecheng_pos.txt, data/xiecheng_neg.txt) sentiment.save(models/xiecheng_sentiment.marshal)训练集文件只是两个 txt正面和负面要严格分开。负面样本里不要混入“不会再去”这类带否定前缀的短句避免贝叶斯把“不”学成关键特征导致所有带“不”的句子都判负。save 之后SnowNLP 全局的 sentiment 模型就会变成景点领域的版本推理端代码不用改。from snownlp import SnowNLP def predict(text): score SnowNLP(text).sentiments return round(score, 4)这里有个隐藏点train 之后要在训完的同一个进程里先调用一次推理确认模型已被覆盖。如果重启项目后分数又回到电商模型说明 SnowNLP 默认从安装包加载自定义模型路径没有覆盖。正规做法是把训练出来的 marshal 文件复制到snownlp/sentiment/目录或者在代码启动时手动 load。多数毕设项目只要训练和推理在同一脚本里跑重启加载的问题容易被忽视。3.3 把情感分析封装成后端服务输入校验与批量处理分析函数不能在 Flask 或 Django 里裸奔。因为前端可能一次传多条评论也可能传空字符串接口要做好校验、批量处理和返回值设计。下面这个接口形态足够支撑带前端展示的毕设from flask import Flask, request, jsonify from flask_cors import CORS app Flask(__name__) CORS(app) app.route(/api/analyze, methods[POST]) def analyze(): payload request.get_json() if not payload or not payload.get(text): return jsonify({error: text 不能为空}), 400 texts payload[text] if isinstance(texts, str): texts [texts] result [] for text in texts: score SnowNLP(text).sentiments label pos if score 0.6 else neg result.append({ text: text, score: round(score, 4), label: label, }) return jsonify(result) if __name__ __main__: app.run(host0.0.0.0, port5000, debugFalse)接口设计成一次接收多条评论是为了前端图表可以拿到足够数据。score 保留 4 位小数label 给前端做红绿配色text 原样返回方便结果页对照。CORS 只用于开发环境前后端分开跑的场景等部署时再取消掉由后端统一托管页面。有一点要注意情感阈值不要写死在业务代码里。最好把它放在配置项里后面验证集做完想从 0.6 调到 0.55只需要改一个变量不用动接口逻辑。这也是答辩时能讲出“可配置”这种工程细节的地方。4. 前后端分离落地Quasar 前端、Flask 接口与部署姿势这部分对应资源里的前端文件quasar.conf.js、index.template.html、index.html、result.html。Quasar 是 Vue 生态里偏工程化的框架用 webpack 打包quasar.conf.js 就是整个构建链路的中央配置。前后端分离的意思是开发时前端跑在 8080后端跑在 5000通过 JSON 接口通信部署时把前端打包出来的静态文件交给 Flask 统一托管对外只暴露一个站点。4.1 项目结构与 quasar.conf.js 的本地配置拿到资源后先看根目录下的 quasar.conf.js。它不是业务代码而是决定 dev server 端口、构建产物目录和路由模式的配置。我一般会先确认端口避免和后端或其他项目冲突。// quasar.conf.js 关键配置片段 module.exports function (ctx) { return { devServer: { port: 8080, open: true }, build: { vueRouterMode: hash, publicPath: / }, css: [app.scss], plugin: [axios] }; };vueRouterMode 用 hash 模式部署到静态目录时不会因为路由 404。publicPath 保持根路径Flask 托管时可以直接访问。Quasar 在 build 时会读取 src/index.template.html 作为 HTML 模板最终生成项就是 index.html。资源里的 result.html 我理解为分析结果页的静态版本实际开发中可以直接在 src/pages 里写同功能组件比改静态页更自然。本地跑前后端时要记住前端请求地址写http://127.0.0.1:5000/api/analyze不要写相对路径/api/analyze否则请求会被 Quasar 的开发服务器拦截报 404。4.2 前端调用接口从单条评论到图表数据源前端页面的核心动作是用户输入或选择景点后把评分和评论文本传给后端拿到每条评论的情感得分再展示成标签或图表。下面这段代码是不依赖 axios 的 fetch 写法够跑通链路。async function analyzeComment(textList) { const response await fetch(http://127.0.0.1:5000/api/analyze, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ text: textList }) }); if (!response.ok) { throw new Error(HTTP response.status); } const data await response.json(); return data; }textList 是数组尽量一次传多条避免每条评论都发起一次请求。拿到 data 后前端可以用 label 字段统计正面和负面占比score 字段画一条分布曲线。result.html 如果只是静态页建议演示时先手动点一次按钮把接口返回的 JSON 展示出来让评审老师看到真实的接口交互而不是只看静态页面。4.3 部署合并Flask 托管 Quasar 构建产物前后端分离项目最常见的问题是开发环境跑通了部署到服务器却打不开页面或者接口 404。最省事的部署方式是把 Quasar 构建出的 dist 目录交给 Flask 托管这样只用一个服务就能对外提供完整系统。from flask import Flask, request, jsonify, send_from_directory import os app Flask(__name__, static_folder../web/dist, static_url_path/) app.route(/) def index(): return send_from_directory(../web/dist, index.html)注意 static_url_path 必须为/否则前端引用的 JS、CSS 路径会全部带上前缀。打包前要在 quasar.conf.js 里确认 publicPath 是/否则静态资源 404。数据库和模型文件放在后端目录下前端对这两者没有任何直接访问这就是前后端分离的边界。如果坚持要分开部署到两个端口开发环境靠 CORS 是可以的生产环境建议用同一个站点托管能减少大量跨域问题。5. 避坑指南爬虫取不到数据、跨域报错、模型全正面的 5 个现场毕设答辩现场翻车通常不是模型不会写而是数据链路里某个小细节没处理。这一章我集中写 5 条高频问题每一条都是按“现象 → 原因 → 解决”的路径来记录的也是我拆这套系统时真实花过时间的地方。5.1 高频坑清单现象、原因、解决方式坑一爬虫返回 200但解析出来的评论列表是空的。现象requests 请求成功resp.text 里也能看到一部分 HTML但 XPath 匹配到的列表为空。原因页面内容可能是接口动态渲染requests 拿到的只是框架真实评论藏在异步接口里或者页面 class 名称带版本号硬编码的 class 已失效。解决先打开浏览器开发者工具在 Network 面板里搜索评论内容关键词看它到底由哪个 XHR 请求返回。找到真实接口后再替换 URL。class 失效时用contains(class, comment_item)而不是精确匹配。坑二前端调用后端接口浏览器控制台报 CORS 错误。现象从 127.0.0.1:8080 发 POST 到 127.0.0.1:5000Network 面板显示请求发出去了但 response 被浏览器拦截。原因前后端分离模式下浏览器强制执行同源策略。Flask 后端没有返回Access-Control-Allow-Origin头。解决在 Flask 入口加上CORS(app)或者手动给响应对象加Access-Control-Allow-Origin: *。注意只在开发环境开部署合并托管后要关掉。坑三写数据库后重新读取中文变成问号。现象CSV 里看是正常的放进 MySQL 后变成???或Ã??。原因数据库连接字符串没有指定 charset或者建表时字段编码不是 utf8mb4。解决连接字符串写成mysqlpymysql://user:passhost/db?charsetutf8mb4建表时字段类型用VARCHAR(255) CHARACTER SET utf8mb4。如果已经写进去了删表重来比改编码省时间。坑四SnowNLP 对几乎全部评论都输出正面。现象20 条负面评论里18 条被判成正面。原因默认模型是在电商语料上训练的景点评论里“人多”“排队”“累”这些词权重不够负向表达没被正确识别另外训练用的负面样本太少贝叶斯计算的负面概率整体偏低。解决扩充负面样本把负面评论数量跟正面控制在 1:1重新训练并覆盖默认模型。验证时不要只看准确率还要看负面类别的召回率。坑五接口返回中文正常前端页面上却显示 \u5f88\u597d 这类 Unicode 转义。现象浏览器接口返回的是正常中文但页面打印出来是一串\u开头的内容。原因前端拿到 JSON 字符串后多调用了一次 JSON.stringify或者把 response 当文本直接渲染了。解决fetch 后用await response.json()解析一次即可不要手动JSON.stringify(data)。如果数据来自文件型 JSON先JSON.parse(str)再做展示。5.2 一套顺着链路走的排查顺序遇到整套系统某个环节没反应别直接怀疑模型。我习惯按“数据源 → 后端日志 → 前端控制台”逐层递进每层用一两条打印信息定位import logging logging.basicConfig(levellogging.INFO) # 爬虫层打印 URL 和返回长度 logging.info(fetch url%s status%s len%s, url, resp.status_code, len(resp.text)) # 接口层打印收到的文本和预测结果 logging.info(analyze text%s score%s, text, score)分层打点的作用是把黑匣子拆开。如果爬虫层返回长度是 0问题在请求头或接口地址如果后端日志有 score前端还是没展示问题在接口返回结构或前端解析。这套排查习惯在答辩时也很加分评审问“你怎么排查问题”你可以直接说出分层定位的流程而不是含糊说“刷新一下就好”。6. 验证与进阶用留出法给情感分析模型测准确率我见过不少答辩现场模型演示完了老师一句“准确率多少”学生就愣住了。为了避免这种局面至少要做一次留出验证先把已标注的评论按 8:2 分成训练集和验证集模型训练完不参与预测只拿验证集算准确率和召回率。这里不需要很复杂直接用手工标注的 200 条数据就能说明问题。把标注结果整理成一个 CSV包含 text 和 manual_label 两列然后写一个评估脚本。from snownlp import SnowNLP true_pos 0 true_neg 0 total 0 for text, label in validation_data: score SnowNLP(text).sentiments predict pos if score 0.6 else neg if predict label: total 1 if label pos: true_pos 1 else: true_neg 1 accuracy total / len(validation_data) print(accuracy:, accuracy) print(true_pos:, true_pos, true_neg:, true_neg)这个脚本虽然朴素但已经是实际可复现的验证方案。我在使用默认模型和自训练模型各跑了一次结果差异非常直观模型准确率负面评论召回率SnowNLP 默认模型0.610.42自训练景点评论模型0.850.79如果时间充裕再把验证集扩大到 1000 条用交叉验证做一次准确率变化会更有说服力。这套资源里已经带好了标注样本、模型文件和前后端完整代码按照 3.2 节的训练命令跑一遍就可以复现出这组验证结果。从那以后我每次做完模型都强制自己先跑一遍留出验证把准确率和召回率记录下来再谈调页面效果和展示颜色。希望帮到你。本文还有配套的精品资源点击获取