
简介基于Python的旅游景点评论情感分析系统属于毕业设计级项目集成了携程与马蜂窝评论爬虫并采用前后端分离架构。面向计算机、通信、人工智能、自动化等专业的学生、老师或从业者适合用于课程设计、大作业、毕业设计或毕业项目参考。资源包共102个文件核心包括32个Python后端与爬虫脚本、10个Vue前端组件以及JavaScript、JSON、HTML等配置文件压缩包大小约47.71MB目录结构完整便于按功能模块查阅。目前已有227人学习下载。项目代码经过了调试运行在答辩中获得98分完整度高。通过该项目可学习评论数据抓取、文本预处理、情感判别模型构建以及前端可视化交互等关键环节基础扎实的开发者还可以在现有框架上替换数据源或扩展分析维度快速适配不同旅游场景。1. 旅游景点评论情感分析系统是怎么从一份毕设变成生产级项目的如果你只在携程或马蜂窝上看过景点评论你可能会觉得这只是一堆“好评”“差评”的堆积。但当你把几十万条评论抓下来、清洗完、做成分词和情感打分之后你会发现一个反直觉的现象用户实际表达的满意度和打星之间经常是错位的。比如“景色很美但是人挤人”这条四星评论在细粒度情感分析里对“景色”是正向对“体验”却是负向。这就是评论情感分析比单纯看星级有价值的原因。这套系统的技术栈并不神秘Python 负责爬虫和算法携程、马蜂窝两个数据源解决“没数据”的问题前后端分离负责把分析结果以可视化仪表盘的方式交给用户。真正有门槛的部分在于三点第一携程和马蜂窝的页面结构差异大反爬策略也完全不同不能共用一套解析逻辑第二评论数据噪声极高不做清洗和领域适配情感模型准确率会掉到让用户失去信心的程度第三前后端分离不是简单把 Vue 和 Django 拼在一起而是要明确接口边界、Token 鉴权和数据聚合时机。本文适合正在做毕业设计的学生、刚入门爬虫和 NLP 的开发者以及对“评论数据分析”这件事有真实业务需求的人。下面按一条完整可落地的路径展开先讲清楚爬虫架构怎么做选型和绕过反爬再讲评论数据清洗和情感分析模型的适配接着给出前后端分离的具体接口设计最后补上监控、调试和性能优化的实操细节。2. 携程与马蜂窝评论爬虫的架构选型和反爬应对2.1 两端数据形态差异决定了爬虫不能“一套代码打天下”携程景点评论走的是异步接口数据挂在https://you.ctrip.com/sight/{poiId}/{poiNum}.html页面上但真正的评论列表是页面加载完成后通过 Ajax 请求https://m.ctrip.com/restapi/soa2/{...}/commentList拉取的请求头里带了Cookie、Referer、User-Agent和经过简单编码的_fxpcqlniredt参数。马蜂窝则直接把评论渲染在 HTML 里地址形如https://www.mafengwo.cn/poi/{poiId}.html评论列表在div.review结构下翻页靠修改 URL 里的page参数即可。# 携程评论接口的简化请求示例伪代码级结构 import requests import json headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15, Referer: fhttps://you.ctrip.com/sight/{poi_id}/{poi_num}.html, Origin: https://you.ctrip.com, } payload { arg: { poiId: poi_id, pageIndex: page, pageSize: 20, channel: 7, sortType: 3, } } # 实际的接口地址会带版本号和签名参数这里示意其结构 resp requests.post( https://m.ctrip.com/restapi/soa2/13444/json/getCommentList, headersheaders, datajson.dumps(payload), timeout10, )这段代码说明了一件关键事携程的评论数据是“请求-响应”式的后端返回的是 JSON 片段里面包含comment评论文本、score打分、userName等字段。解析这类接口要用jsonpath或直接走字典取值而不是用 XPath。马蜂窝则只需要requests.get()拿 HTML 再用 XPath 提取逻辑上简单一些但要注意它的评论区域里有“带图评论”和“纯文本评论”两种结构XPath 路径并不完全一致。2.2 爬虫框架选型Scrapy 适合毕设Requests 适合快速验证毕业设计场景里Scrapy 是更稳妥的选择。原因不是它“看起来厉害”而是它天然提供了Item Pipeline、Downloader Middleware、AutoThrottle和日志分级这些在写论文的“系统设计”章节时都是可写的内容。如果你只是做数据采集验证用requests加循环也能跑通但后续要做增量采集、断点续爬、IP 轮换时requests方案要自己写的东西太多。# Scrapy 爬虫核心代码结构景点评论采集示例 import scrapy from scrapy_selenium import SeleniumRequest class CtripCommentSpider(scrapy.Spider): name ctrip_comment def start_requests(self): # 从数据库或CSV中读取景点ID和名称 for poi_id, poi_name in self.poi_list: yield SeleniumRequest( urlfhttps://you.ctrip.com/sight/{poi_id}/0/0-comment.html, callbackself.parse_comment, meta{poi_id: poi_id, poi_name: poi_name}, wait_time3, # 等待页面JS渲染完成 ) def parse_comment(self, response): # 携程评论区经过JS渲染部分字段需用selenium取数 for comment_block in response.css(div.commentItem): yield { poi_name: response.meta[poi_name], content: comment_block.css(div.commentDetail::text).get(), score: comment_block.css(span.score::text).get(), date: comment_block.css(span.time::text).get(), }配合scrapy-selenium中间件能解决携程部分页面需要 JS 渲染才能取到评论内容的问题。但要注意 Selenium 会拖慢采集速度尽量只在接口参数加密太重时才使用。实践里更好的折中方案是先用浏览器开发者工具找到真实接口再用requests或 Scrapy 的FormRequest直连接口这样速度和稳定性都更好。2.3 反爬应对策略Cookie 池、请求频率和 User-Agent 轮换携程的反爬主要集中在接口签名和频控上马蜂窝则更依赖 IP 封禁。两者共同的应对方案有三个维度请求头伪装、频率控制、失败重试。以下是一组在毕设级项目中足够用的配置参数维度推荐参数说明User-Agent 池20 个以上真实 UA 轮换从fake_useragent库生成注意覆盖移动端和桌面端请求间隔3-5 秒随机浮动用time.sleep(random.uniform(3, 5))不要固定值重试机制最多 5 次指数退避429 或 503 状态码时等待时间翻倍Cookie 策略优先使用登录后的会话 Cookie部分评论内容拿到未登录态时会被截断IP 轮换仅在被封时启用代理池毕设场景不建议过多投入先用频率控制规避提示对毕设而言最容易被忽略的是 robots.txt 和网站服务条款。采集频率控制在“不给目标站点造成压力”的范围内既是对他人服务的尊重也是你项目能长期跑下去的前提。2.4 断点续爬与增量更新的实现评论数据是持续产生的你的系统不能每次全量重爬。常见的做法是把已采集的评论 ID或评论内容和日期拼接后的 MD5存到数据库爬虫每抓取一条就做去重判断。携程评论自带commentId字段马蜂窝则需要用“用户名发布时间评论内容”做指纹。# 增量去重伪代码用Redis或数据库判断评论是否已存在 def is_duplicate(comment_id: str) - bool: # 使用 Redis SET 做快速去重key 为 comment:seen return redis_client.sismember(comment:seen, comment_id) def mark_seen(comment_id: str) - None: redis_client.sadd(comment:seen, comment_id)这里用 Redis 而不是数据库查询的原因是其SISMEMBER时间复杂度为 O(1)几十万条评论的去重判断在毫秒级完成。如果没有 Redis 环境用 MySQL 的UNIQUE KEY配合INSERT IGNORE也可以但采集速度会受限于数据库连接池。3. 评论数据清洗与旅游领域情感分析的模型适配3.1 原始评论的噪声形态和清洗流程爬下来的评论远没有你想象的干净。常见的噪声包括HTML 标签残留马蜂窝的带图评论里常见、表情符号和 emoji“景色”这类、繁体字和火星文港澳台用户、广告和灌水评论“加微信XXX”、重复评论用户手滑或系统 bug。清洗流程一般按五步走去 HTML → 去 emoji 和特殊符号 → 繁体转简体 → 去短评论 → 去重。# 评论清洗核心代码 import re import emoji from opencc import OpenCC def clean_comment(text: str) - str: # 1. 去除HTML标签 text re.sub(r[^], , text) # 2. 去除emoji用emoji库转换后过滤 text emoji.demojize(text) text re.sub(r:[a-z_]:, , text) # 3. 繁体转简体 text OpenCC(t2s).convert(text) # 4. 去掉纯符号和无意义短句 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9\s。,.!?], , text) text re.sub(r\s, , text).strip() return text清洗后的文本才能进入情感分析管道。注意OpenCC的转换库需要安装opencc-python-reimplemented包而不是pyopencc后者在 Windows 下经常编译失败。emoji 处理用emoji库是因为它不仅能识别还能把表情转成文本描述这样在后续做特征工程时可以保留部分语义信息比如“开心”和“”其实表达的情感方向接近。3.2 情感分析模型选型SnowNLP 基线到 BERT 微调的路线评论情感分析在 NLP 里属于句子级情感分类任务。毕设场景最常见的路径是先跑 SnowNLP 做基线再根据效果决定是否升级到预训练模型。SnowNLP 的问题在于它基于电商购物评论训练在旅游场景下“性价比”这类词的判断经常是反的。路线选择建议如果你只是想做出“系统能跑、论文有结果”用 SnowNLP 加自定义情感词典准确率 70% 左右够演示。如果你的数据量在 1 万条以上且标注了 2000 条以上用bert-base-chinese做微调准确率能到 85% 甚至更高。如果你没有标注数据可以用transformers里的pipeline(sentiment-analysis)先做预标注再人工抽检修正。# 基于SnowNLP的情感打分与阈值映射 from snownlp import SnowNLP def analyze_sentiment(text: str) - dict: s SnowNLP(text) score s.sentiments # 输出 0~1 之间的情感倾向 if score 0.6: label pos elif score 0.4: label neg else: label neu return {score: round(score, 4), label: label}SnowNLP.sentiments输出的是一个概率值可以简单理解为“这条评论是正面的概率有多大”。0.6 和 0.4 的阈值不是固定的你需要在清洗完的真实数据上跑一遍分布然后根据分布曲线选择拐点。很多人的模型“好像不准”其实是阈值没调用的是默认的 0.5 一刀切。3.3 领域情感词典构建解决 SnowNLP 在旅游场景的偏差SnowNLP 对“景色宜人”“值得一去”这类明显正面的表达判断准确但遇到“排队两小时游玩五分钟”这种隐含负面情绪的句子时它倾向于给出中等偏正的值因为“排队”和“游玩”这两个词在购物评论里没有明显情感色彩。解决方式是构建旅游领域词典用附加规则修正模型输出。# 领域情感词典修正逻辑 domain_neg_words [排队, 拥挤, 商业化, 门票贵, 不值, 人多, 脏乱] domain_pos_words [震撼, 壮观, 值得, 原生态, 人少景美, 性价比高] def domain_adjust(score: float, text: str) - float: neg_hits sum(1 for w in domain_neg_words if w in text) pos_hits sum(1 for w in domain_pos_words if w in text) if neg_hits 0 and pos_hits 0: return max(0.1, score - 0.2 * neg_hits) if pos_hits neg_hits: return min(0.95, score 0.15 * (pos_hits - neg_hits)) return score这是朴素的规则修正但效果立竿见影。更工程化的做法是把这个词典外置成 JSON 文件或数据库表方便后续维护——因为不同景区的负面高频词差异很大比如山岳型景区高频负面词是“台阶多”“索道排队”古镇型景区则是“商业味浓”“同质化”。3.4 多模态情感分析的扩展可能如果你觉得纯文本情感分析不够“有技术含量”可以引入图片分析。马蜂窝的评论里大量出现配图可以把图片下载后提取特征但深度上不做训练只是用预训练模型打分然后和文本情感做加权融合。# 多模态情感分数融合示例文本 图片 def fusion_sentiment(text_score: float, image_score: float, text_weight: float 0.7) - float: # image_score 通过预训练vgg或resnet模型输出的情感分布计算得到 final_score text_weight * text_score (1 - text_weight) * image_score return round(final_score, 4)这个思路可以写进论文的创新点里但要注意图片情感分类的预训练模型多数是在社交数据上训练的对风景照的“愉悦度”打分不一定准。建议把图片维度定义为“景观吸引力指数”而非“情感倾向”更符合景点评论的语义。4. 前后端分离架构下的系统设计与接口实现4.1 为什么选前后端分离毕业设计展示时的天然优势前后端分离在这个系统里不是装点门面而是实际需求。你的后端要处理爬虫任务调度、数据分析、情感计算这些是 CPU 密集和 I/O 密集混合的任务前端要做的是把结果可视化包括情感分布饼图、评论词云、舆情趋势折线图。两者通过 RESTful API 交互后端不关心页面长什么样前端不关心数据怎么算出来的。技术栈选择上后端用 Django Django REST Framework 或 Flask Flask-RESTful 都可以前端用 Vue 3 Element Plus 或 ECharts。如果你的毕设需要展示“工程能力”用 Spring Boot Vue 也是常见搭配但标题里写明“基于 Python”后端就锁死在 Python 系。4.2 核心接口设计一次前端调用对应一个聚合数据视图前端不直接请求爬虫或模型的底层接口而是通过一个聚合接口拿数据。这个设计能有效减少前端代码量和后端请求压力。# Django REST Framework 视图代码示例情感分布统计接口 from rest_framework.views import APIView from rest_framework.response import Response from .models import Comment, SentimentResult class SentimentStatsView(APIView): def get(self, request, poi_id): # 查询该景点下的所有评论情感分析结果 rows SentimentResult.objects.filter(poi_idpoi_id) stats { pos: rows.filter(labelpos).count(), neu: rows.filter(labelneu).count(), neg: rows.filter(labelneg).count(), total: rows.count(), } # 计算正面占比避免除零 stats[pos_ratio] round(stats[pos] / stats[total], 4) if stats[total] else 0 return Response(stats)接口地址按 RESTful 风格设计如下表方法路径功能返回内容GET/api/poi/list景点列表景点 ID、名称、评论数GET/api/poi/{id}/stats情感统计三类情感数量、正面占比GET/api/poi/{id}/wordcloud词云数据高频词及其权重GET/api/poi/{id}/trend情感趋势按周/月聚合的情感分值POST/api/crawl/start触发爬虫任务任务 ID 和状态GET/api/crawl/{task_id}/status查询爬虫进度状态、已采集数、失败数4.3 Vue 前端请求 Token 处理和 axios 封装前后端分离后用户登录和 API 鉴权是一个绕不开的细节。毕设系统常见做法是后端用 JWT 生成 Token前端把 Token 存在localStorage每次请求在Authorization头带上。你还需要在 axios 的拦截器里统一处理 401Token 过期和 403权限不足。// axios 请求拦截器自动附加 JWT Token import axios from axios const service axios.create({ baseURL: /api, timeout: 15000, }) service.interceptors.request.use( (config) { const token localStorage.getItem(access_token) if (token) { config.headers[Authorization] Bearer ${token} } return config }, (error) Promise.reject(error) ) service.interceptors.response.use( (response) response.data, (error) { if (error.response error.response.status 401) { // Token 过期跳转登录页并清除本地缓存 localStorage.removeItem(access_token) window.location.href /login } return Promise.reject(error) } ) export default service这段拦截器代码解决了两个问题一是避免每个页面手动拼请求头二是统一处理登录态失效的场景。注意window.location.href /login这种硬跳转在大型应用里不如 Vue Router 的router.push优雅但对毕设级别项目简单直接更不容易出 bug。4.4 使用 FastAPI 替代 Django 的轻量方案如果你更熟悉 Python 异步编程FastAPI Vue 也是一个合理选择。FastAPI 天然支持async/await适合对接爬虫的任务调度而且自动生成 Swagger 文档做答辩演示时可以直接打开/docs界面给评审老师看接口列表视觉冲击力强。# FastAPI 简易情感分析接口 from fastapi import FastAPI from pydantic import BaseModel import uvicorn app FastAPI() class CommentIn(BaseModel): text: str app.post(/api/analyze) async def analyze_comment(item: CommentIn): # 实际项目中这里会调用模型服务而不是直接import from services.sentiment import analyze_sentiment result analyze_sentiment(item.text) return {result: result} if __name__ __main__: uvicorn.run(app, host0.0.0.0, port8000)FastAPI 的BaseModel定义了一个请求体结构前端传{text: 非常好的景区值得再来}就能拿到情感打分结果。相比 DRF 的Serializer它的类型提示更友好而且不强制你用 Django 的 ORM可以直接操作 Pandas DataFrame 或 MySQL。如果你的爬虫已经用了 ScrapyScrapy 的 Item 结构和 FastAPI 的 Pydantic 模型都是基于类型声明的代码风格统一读起来顺畅。5. 爬虫并发设计、代理与监控体系的进阶配置5.1 并发设计多线程、多进程、协程的取舍爬虫在毕设里往往能跑就行但如果数据量大——比如要爬 100 个景点的所有评论——串行采集的时间会让人崩溃。并发设计有两条主线一是用 Scrapy 自带的CONCURRENT_REQUESTS配置二是用 Python 标准库的concurrent.futures手动控制。前者适合跑大批量任务后者适合单条快速验证。# Scrapy 并发参数配置示例settings.py CONCURRENT_REQUESTS 8 # 全局并发请求数 CONCURRENT_REQUESTS_PER_DOMAIN 4 # 单域名并发限制防封 DOWNLOAD_DELAY 1.5 # 同一域名下的请求间隔 RANDOMIZE_DOWNLOAD_DELAY True # 间隔随机浮动避免规律性 AUTOTHROTTLE_ENABLED True # 自动限速根据服务器响应调整 AUTOTHROTTLE_START_DELAY 2.0 AUTOTHROTTLE_MAX_DELAY 10.0这组参数的核心思路是“把并发控制在目标站点允许的范围内”。CONCURRENT_REQUESTS 8不是越高越好对携程这种有 WAF 的站点超过 10 个并发很容易触发验证码。DOWNLOAD_DELAY和AUTOTHROTTLE配合使用时Scrapy 会自动根据响应时间调整请求频率响应变慢说明服务器在拒绝你就自动拉长间隔。5.2 Requests 方式的线程池并发实现如果你没有用 Scrapy而是用requests写的爬虫线程池是最容易落地的并发方案。注意requests不具备 Scrapy 那样的爬虫去重、深度控制能力并发只是解决“请求发送”这一层的效率。# 使用 ThreadPoolExecutor 并发采集马蜂窝评论 from concurrent.futures import ThreadPoolExecutor, as_completed import requests def fetch_page(poi_id: int, page: int): url fhttps://www.mafengwo.cn/poi/{poi_id}.html?page{page} headers {User-Agent: fake_user_agent()} resp requests.get(url, headersheaders, timeout10) return resp.text poi_ids [1001, 1002, 1003] pages_to_fetch [(pid, p) for pid in poi_ids for p in range(1, 6)] with ThreadPoolExecutor(max_workers5) as executor: future_map {executor.submit(fetch_page, pid, p): (pid, p) for pid, p in pages_to_fetch} for future in as_completed(future_map): pid, p future_map[future] try: html future.result() # 解析并存储 except Exception as exc: print(fPOI {pid} page {p} failed: {exc})这段代码里max_workers5是经验值。线程数不是越多越好因为 Python 的 GIL 限制了 CPU 密集型任务的并行而爬虫属于 I/O 密集任务线程数在 5-10 之间可以达到较好的吞吐超过 20 后收益急剧下降反而会触发目标站的频控。5.3 爬虫代理池的选择与配置原则“python 爬虫 ip 代理”是讨论度一直很高的话题但在毕设场景里我建议谨慎投入。首先免费代理的可用率很低往往测 100 个只剩 10 个能用浪费调试时间其次携程和马蜂窝对代理 IP 的识别非常敏感数据中心 IP 一请求就会被识别出来。如果你确实需要代理优先使用响应速度稳定、带宽充足的付费代理并只在采集被连续封禁时启用。# requests 中使用代理的写法 proxies { http: http://user:passyour_proxy_host:port, https: https://user:passyour_proxy_host:port, } resp requests.get(url, headersheaders, proxiesproxies, timeout10)在 Scrapy 里则需要启用scrapy-proxy-middleware插件在下载中间件中动态读取代理池里的 IP。本质逻辑都是一样的把proxies参数变成每次请求的附加信息而不是硬编码在代码里。代理池的维护建议用 Redis 存一组可用代理地址采集进程启动时拉取每完成若干次请求后验证一次可用性失效的则剔除。5.4 爬虫监控与日志分级爬虫跑了多久、成功多少、失败多少、卡在哪一页这些信息必须实时可见。不要用print输出用 Python 的logging模块做分级日志同时在数据库里记录任务状态。# 爬虫日志配置按级别输出到控制台和文件 import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(name)s: %(message)s, handlers[ logging.StreamHandler(), logging.FileHandler(crawler.log, encodingutf-8), ], ) logger logging.getLogger(ctrip_crawler) logger.info(开始采集景点ID: %s, 第 %s 页, poi_id, page) logger.warning(请求失败状态码: %s, 重试第 %s 次, resp.status_code, retry_count) logger.error(连续失败超过3次放弃采集: %s, url)这里的关键点是使用logger.info(...%s..., poi_id, page)而不是logger.info(f...{poi_id}...)。前者是延迟格式化字符串拼接只在需要输出时才做对性能有微弱但真实的改善。日志文件在排错时极其有用因为爬虫中断后你要回答的核心问题是“断在哪了”有日志就能精准定位到目标 URL 和页数。6. 用 Redis 队列解耦爬虫与情感分析的任务调度爬虫和情感分析是两个独立的计算阶段直接用函数调用串联会带来一个问题如果情感分析模型加载需要 5 秒爬虫采集完 1000 条评论后阻塞在模型推理上采集速度被拖慢。常见的做法是用 Redis 的列表结构做任务队列爬虫只管写入、分析服务只管消费两端通过队列解耦各自独立扩展。# 采集端把评论写入Redis队列 import redis import json redis_client redis.Redis(hostlocalhost, port6379, db0) def push_to_queue(comment_data: dict): # comment_data 包含 poi_id, content, score, date 等字段 redis_client.lpush(comment:queue, json.dumps(comment_data, ensure_asciiFalse)) # 消费端从队列取出并做情感分析 def consume_queue(): while True: raw redis_client.rpop(comment:queue) if raw is None: break comment json.loads(raw) result analyze_with_domain_adjust(comment[content]) save_to_db(comment, result)这段示例展示了解耦的核心逻辑生产者爬虫只负责lpush消费者情感分析服务只负责rpop。不需要生产者知道消费者是谁、分析逻辑怎么变两端只需要约定好comment:queue这个 key 和 JSON 的数据结构即可。这种设计在你后面想换成 RabbitMQ 或 Kafka 时代码改动量是最小的。队列方案还有一个额外的好处你可以分多次启动消费者。比如先在本地跑一个消费者验证模型效果再到服务器上部署 3 个消费者实例处理全量数据不需要修改生产者的任何代码。如果你的毕设答辩要演示“架构设计能力”这个 Redis 队列是比单纯爬虫分析更有层次感的设计。本文还有配套的精品资源点击获取