尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python爬虫实战:携程景点评论采集与SnowNLP情感分析

Python爬虫实战:携程景点评论采集与SnowNLP情感分析 去年秋天那会儿我想在周末带家里人找个地方爬山。携程上翻了一个小时越翻越纠结——评论区两极分化严重有人说风景绝美强烈推荐底下紧接着就有人回就是个光秃秃的土坡门票都不值。我知道这种差异跟出行时间、天气、个人体力都有关系但一条条翻实在太低效了。于是我做了个决定写一个基于Python的爬虫把目标景点的携程评价全部拉下来再做一轮情感评分分析用数据回答到底值不值得去。后来这个项目越做越完整从单个景点扩展到了同区域十来个景点顺便还把SnowNLP的情感打分数值跟用户星级做了对比分析。今天这篇就把整个项目的思路、代码和踩坑过程完整整理一遍适合有Python基础、想拿真实项目练手的朋友参考。这个项目的核心价值在于它不是那种教学用的玩具爬虫而是真正要跑在真实网站上、拿到完整数据、并且做出可解释分析结果的完整工程。你会遇到接口字段结构变化、风控拦截、文本脏数据、情感模型偏差等一系列真实问题而我把每个问题的处理链路都写在了后面。1. 项目动机从一个让人纠结的景点页面说起1.1 为什么选择携程评价这个切入点说实话市面上可以爬的旅游平台很多但我最后选了携程的景点评价主要是因为三个原因。第一评价数据自带数值标签。每条评价都有用户打出的星级分数这个分数就是天然的标注数据。情感分析模型给出的打分准不准可以直接跟用户自己打的星级做对比用均方误差、平均绝对误差这类指标来衡量。这一点比爬微博评论、爬新闻评论要友好得多——那些数据没有标准答案模型跑完你都不知道该信几分。第二平台对公开数据的访问门槛适中。景点评价本来就是公开展示的内容不需要复杂登录体系就能看到大部分数据通过移动端页面抓接口也比较直接。对练手项目来说这个难度曲线是合理的——既不是完全裸奔毫无挑战也不会像电商平台那样动不动就上极验、无感验证。第三数据量级非常适合做完整工程。一个热门景点几千条评论一个区域十几个景点加起来几万条这个量级既不会让个人电脑跑不动也多到能撑起有效的情感分析统计。如果只爬几十条任何算法都说明不了问题如果爬几十万条又需要上分布式存储和调度那一套不适合作为一篇文章讲完的范畴。1.2 项目整体架构与技术选型整个项目我是按数据采集 - 数据清洗 - 情感分析 - 聚合可视化四段式设计的。这样的分层结构在后期调优时非常好用情感模型效果不好只需要动分析模块不需要去改爬虫代码爬虫被封也只需要在采集层降频重试不会污染已经落盘的数据。技术选型上我一直坚持够用就好的原则。有人说用Scrapy有人说用异步协程但对我来说这个项目用requests就够了。携程的景点评价接口响应并不慢单线程抓一个景点也才几分钟加上并发控制后更快。爬虫真正的瓶颈往往不在解析速度而在请求频率控制——你要是真把几千个并发怼上去网站不封你封谁。存储端直接用pandas落CSV数据量不大SQLite都不是必需的。文本清洗用了zhconv做繁简转换情感分析核心用的SnowNLP后续用规则做了修正。目录结构大致长这样ctrip_review_crawler/ ├── crawler.py # 爬虫主逻辑 ├── parser.py # 接口响应解析 ├── cleaner.py # 数据清洗 ├── sentiment.py # 情感评分 ├── analyze.py # 聚合分析与可视化 ├── data/ │ ├── raw_reviews.csv │ └── clean_reviews.csv └── output/ ├── score_distribution.png └── monthly_sentiment.png2. 动手前的关键功课携程评价页面的数据来源分析2.1 抓包定位评论接口很多人一上来就对着详情页的HTML去解析结果发现页面里的评价是异步加载的解析出来的内容永远是空的。携程的景点评价数据是通过移动端H5页面背后的JSON接口返回的直接请求HTML拿不到完整评论列表。定位接口的方法很标准打开浏览器开发者工具切到Network面板然后手动翻几页景点评论。重点筛选XHR和JS类型的请求按名称搜comment、review、poi这些关键词很快就能看到一个返回JSON的POST接口。这个接口有几个特点值得注意请求方法是POST不是GET参数都放在请求体里返回内容是标准的JSON评论列表、用户名、评分、时间戳都在里面接口路径里的数字段会变但整体调用模式是稳定的下面是一个简化后的请求结构字段名我会做脱敏处理因为平台接口一直在微调你实际抓包看到的字段名以自己这边为准import requests url https://m.ctrip.com/restapi/soa2/xxx/json/getCommentList payload { poiId: 123456, # 景点ID可以在页面URL或页面源码里找到 pageIndex: 1, pageSize: 20, sortType: 3, sourceType: 1, } headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1, Referer: https://m.ctrip.com/webapp/you/xxx/detail.html, Content-Type: application/json, } resp requests.post(url, jsonpayload, headersheaders, timeout10) data resp.json()请求头里的User-Agent为什么最好保持移动端UA原因很简单这个接口是从H5页面调用的用移动端UA是最接近真实用户场景的容易被后端放行。用桌面Chrome的UA去调移动端接口本身就很可疑。Referer字段也一样直接空着很可能触发风控。2.2 参数细节与地带识别poiId是整个请求里最核心的参数。这个ID可以在景点详情页的URL里直接找到也可以通过页面上某个写点评按钮的跳转链接里提取。一个实用的小技巧携程的页面有时候会把poiId放在meta标签或页面的初始化脚本里直接在开发者工具的Console里执行document.documentElement.outerHTML搜索poiId字符串比肉眼翻源码快得多。pageSize这个参数我建议设成20这是接口默认值也是回包最稳定的大小。我试过把它拉到50甚至100结果部分景点的接口会直接返回空列表大概率是后端对单次查询数量做了限制。与其靠调大pageSize减少请求次数不如老老实实多请求几页。sortType参数决定评论列表的排序方式。按时间排序、按推荐排序在接口里面对应不同的整数值。如果做情感时间趋势分析建议用按时间排序如果只是想看整体口碑按推荐排序更合理。2.3 分页与结束条件的判断分页逻辑是爬虫能否完整跑完的关键。实际抓包会发现携程的评论接口并不总是返回一个明确的totalCount字段有些接口直接给出总数有些只给评论列表你只能靠翻页翻到空列表来判断。我总结了三种结束条件按优先级使用接口响应里有totalCount用 totalCount 除以 pageSize 算出总页数循环到最后一页。接口返回的commentList为空说明已经翻到尽头直接break。连续N页返回的评论ID全部重复此时大概率是接口做了分页上限控制后面翻页只是重复前面的数据用一个set去重即可。第三种情况最容易让人翻车。我遇到过一个热门景区评论总数接近三千但翻到第15页大约300条之后接口就开始循环返回前300条里的内容。如果只按照列表是否为空来判断结束会陷入死循环。解决办法就是维护一个seen集合记录每条评论的内容哈希值连续三页没有任何新评论就终止。3. 爬虫主体实现从单页请求到可控并发3.1 请求封装与解析函数写爬虫最容易犯的错误是一把梭——把所有逻辑都堆在一个函数里。我还是习惯按职责拆一个函数专门发请求一个函数专门解析响应一个函数专门控制流程。这样接口字段变了只需要改解析函数不会牵连到其他地方。import time import random import requests class CtripReviewCrawler: def __init__(self, poi_id, page_size20): self.session requests.Session() self.session.headers.update({ User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1, Referer: fhttps://m.ctrip.com/webapp/you/{poi_id}/detail.html, Content-Type: application/json, }) self.poi_id poi_id self.page_size page_size def fetch_page(self, page_index): payload { poiId: self.poi_id, pageIndex: page_index, pageSize: self.page_size, sortType: 3, sourceType: 1, } url https://m.ctrip.com/restapi/soa2/xxx/json/getCommentList try: resp self.session.post(url, jsonpayload, timeout15) resp.raise_for_status() return resp.json() except requests.RequestException as e: print(f第{page_index}页请求失败: {e}) return None def parse_comments(self, data): 把JSON响应转换成统一的评论对象列表 comments [] if not data: return comments comment_list data.get(commentList) or data.get(commList) or [] for item in comment_list: try: comment { poi_id: self.poi_id, user_name: item.get(userInfo, {}).get(userName, ), content: item.get(content, ).strip(), score: item.get(poiScore, 0), comment_time: item.get(addTime, 0), reply: item.get(reply, {}).get(replyContent, ), } comments.append(comment) except AttributeError: continue return comments这里有一个细节值得展开为什么解析的时候要用data.get(commentList) or data.get(commList)这样写因为我在跑不同景点时发现不同版本的接口返回的字段名并不完全统一有的叫commentList有的叫commList。这个差异在写爬虫时非常隐蔽稍不注意就会从字段为空开始一路排查到深夜。3.2 数据落盘与字段设计数据存储的字段设计我坚持宁多勿少原样保存再加工的原则。也就是说从接口拿到的原始字段全部保留清洗后的结果另建新列不要在原字段上直接覆盖。这样做的最大好处是后续发现清洗逻辑有误还能回溯到原始数据重跑不用重新爬一遍。import pandas as pd def save_to_csv(comments, filenamedata/raw_reviews.csv): df pd.DataFrame(comments) df.to_csv(filename, modea, headernot pd.io.common.file_exists(filename), indexFalse, encodingutf-8-sig)注意编码选的是utf-8-sig而不是utf-8。这是Windows用户的痛点utf-8编码的CSV文件用Excel打开时中文会全部变成乱码而带BOM的utf-8-sig格式能规避这个问题。如果你用pandas直接读CSV做分析这个细节不影响但只要你需要把CSV发给别人用Excel打开或者自己想用Excel快速翻一翻数据这个编码细节就至关重要。3.3 并发抓取与频率控制单线程抓取慢吗一个景点300条评论每页20条就是15页算上每页2秒的间隔一分钟内就能跑完。但如果你要抓30个景点这个时间就有点可观了。所以我还是加了一个并发控制模块用的是Python标准库的concurrent.futures没有因为网上都在用异步就被带偏去上asyncio。from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_reviews(self, max_pages50): 爬取指定范围的所有评论 seen set() results [] with ThreadPoolExecutor(max_workers3) as executor: future_to_page { executor.submit(self.fetch_page, page): page for page in range(1, max_pages 1) } for future in as_completed(future_to_page): page future_to_page[future] data future.result() comments self.parse_comments(data) for comment in comments: content_hash hash(comment[content]) if content_hash not in seen: seen.add(content_hash) results.append(comment) print(f第{page}页完成累计获得{len(results)}条新评论) time.sleep(random.uniform(1.5, 3.5)) return resultsmax_workers3是我多次实测后折中的结果。开10个线程确实快了不少但跑到第7页就开始出现请求失败的报错开到20个线程连首页都加载不出来。很多人一听到并发就兴奋实际上对于这种轻量级接口3到5个线程已经能把带宽和响应速度吃满再往上加就是纯粹给自己制造封号风险。另外一个容易被忽略的点每次请求之间必须sleep而且要随机化。固定延时2秒看起来很守规矩但恰恰是机器行为最明显的特征——真实用户不可能精确地每隔2秒点一次下一页。我把间隔设在1.5到3.5秒之间随机浮动既保证了速度又让请求模式更接近人。4. 数据清洗比爬虫本身更耗时的一步4.1 从接口原始字段到干净数据数据爬下来之后千万不要急着做情感分析。原始评论数据里藏着一堆地雷不清洗直接跑情感分析结果会被污染到你不敢相信。我先列个清单这是我实际处理时遇到的字段级问题字段原始格式问题处理方式comment_time1730000000000毫秒时间戳不可读转换为YYYY-MM-DD字符串score5.0 / 4.5 / 暂无评分类型不统一含非数值强制转float异常置为NaNcontent含换行、连续空格、emoji影响文本分析正则清理user_name游客123 / 空字符串空值无意义填空字符串不删除行reply商家回复文本与分析目标无关单独存储不参与情感分析时间戳转换是最基本的操作但有一个坑接口返回的addTime字段不一定是毫秒有的景点接口返回的是秒。判断方法很简单看这个数值是13位还是10位。如果是13位就是毫秒需要除以1000。import datetime def ts_to_date(ts, is_millisecondTrue): if not ts: return try: ts int(ts) if is_millisecond: ts ts / 1000 return datetime.datetime.fromtimestamp(ts).strftime(%Y-%m-%d %H:%M:%S) except (ValueError, OSError): return 4.2 重复评论与广告评论的识别重复评论是爬虫数据里最让人头疼的问题之一。同一个用户在同一家景区重复发表相同内容的概率极低所以一旦出现内容完全相同的两条评论基本可以断定是接口分页边界重复了。我的处理方式是先对content做去空白处理再计算MD5哈希作为去重键保留第一条删除后续重复项。广告评论的识别就更有意思了。携程的评价区里常年混着一批管家导游发的软文特征非常明显文字里频繁出现加微信旅游群私信我攻略完整版这类词。我用一个关键词黑名单做初筛adb_words [ 加微信, 添加微信, 私信我, 免费攻略, 旅游群, 扫码, vx, VX, 威信 ] def is_ad_comment(text): return any(word in text for word in adb_words)命中黑名单的评论我直接标记为is_adTrue在情感分析阶段过滤掉。但注意不要直接删除这些行。因为你还要统计广告评论占总评论的比例这个数字本身也是平台水军情况的一个指标可以写进最终的分析报告里。4.3 繁体中文、emoji与特殊符号处理景点评论里繁体中文出现得比我预想的多。一方面港澳台游客会发表繁体评论另一方面很多大陆用户打字输入法默认输出繁体。SnowNLP训练时使用的语料以简体为主繁体输入会导致情感判断准确率明显下降。处理方案是用zhconv库做繁简转换from zhconv import convert def to_simplified(text): try: return convert(text, zh-cn) except Exception: return textemoji和特殊符号的处理要慎重。直接全部删掉会损失语气信息——比如风景超美和风景超美前者情感明显更强烈。但SnowNLP的模型根本看不懂emoji留着反而会变成噪声。我采用的折中策略是把常见的正向emoji❤️和负向emoji分别替换成超赞超差这样的强化词其余的emoji直接删除。还有一个细节纯符号评论。有些用户懒得打字直接发一串。。。或者只有两个字的好。这类超短评论放进情感模型里输出基本都是0.5中性没什么分析价值但它们占用的计算量不小。我在清洗阶段加了规则过滤掉去掉标点后字数少于4个字符的评论并单独统计它们的数量。5. 情感评分分析从SnowNLP得分到可解释的星级5.1 情感打分的基本流程清洗完成的数据终于可以进入正题了。我用的核心工具是SnowNLP——一个纯Python实现的中文文本情感分析库内置了一个已经训练好的情感分类模型输入一段文本返回0到1之间的情感倾向值越接近1表示越积极越接近0表示越消极。from snownlp import SnowNLP def sentiment_score(text): try: s SnowNLP(text) return s.sentiments except Exception: return 0.5把分数直接贴到每条评论后面再加一列情感标签def label_sentiment(score): if score 0.75: return 积极 elif score 0.6: return 偏积极 elif score 0.4: return 中性 elif score 0.3: return 偏消极 else: return 消极这里的分段阈值是我第一版跑完后拿输出结果跟人工标注对比后调的。初次跑的时候我用了0.2/0.4/0.6/0.8四段结果发现大量明显好评比如非常好的一次体验值得再来被打成了中性。原因是SnowNLP默认模型的分数分布整体偏保守大量文本都集中在0.4到0.7之间。所以阈值不是固定的一定要根据你自己数据集的分数分布去校准。5.2 模型局限与规则修正方案SnowNLP的情感分析能力说白了是个有基础但不够聪明的水平。它能很好地识别风景优美服务周到强烈推荐这种词汇层面的正向信号也能识别垃圾坑人再也不来这种明显负向信号。但它处理不了复杂的语言结构尤其是转折句和否定句。举个例子我数据里有条评论风景确实不错但管理太混乱了等待时间超长总体体验一般。 这句话前半段是正向后半段是负向转折词但之后的情绪权重应该更高整体应该判定为偏消极。但SnowNLP给出的分数是0.61落在偏积极区间——它被前半句带偏了。我用的修正方案是关键词规则叠加不走复杂模型。经验是对于一个练手级项目规则修正的性价比远高于微调一个深度学习模型。我整理了三类规则转折词后置加权如果文本中包含但是不过然而就是等转折词以转折词之后的子句情感为准忽略前面的内容。否定词反转如果不后面紧跟的是正向词如不错好看需要特殊处理。风景不错和风景不好看相差一个字但情感极性完全不同。强情绪词直接覆盖文本中出现强烈推荐绝对好评太值了直接给高分出现千万不要极度失望一星都不想给直接给低分。def adjust_score(content, base_score): # 转折词处理转折之后的分句权重更高 if any(word in content for word in [但是, 不过, 然而, 就是]): tail content.split(但是)[-1].split(不过)[-1].split(然而)[-1].split(就是)[-1] if tail.strip(): base_score 0.3 * base_score 0.7 * SnowNLP(tail).sentiments # 强情绪词覆盖 strong_positive [强烈推荐, 太值了, 绝对好评, 超出预期, 惊喜] strong_negative [千万不要, 极度失望, 一星, 差到, 别去, 坑人] for word in strong_positive: if word in content: return max(base_score, 0.85) for word in strong_negative: if word in content: return min(base_score, 0.15) return base_score加了这套规则之后我又随机抽了200条评价做人工校验情感二分类准确率积极/消极从初始的约72%提升到了86%左右。这个准确率对于口碑分析来说已经完全够用了。5.3 聚合分析用户星级、情感分与时间趋势单条评论的情感分只是中间产物最终要落到景点级和区域级的分析。我做了三件事第一计算每个景点的用户星级均值与文本情感分均值看两者是否一致。正常情况下两个分数应该正相关但如果出现某个景点用户平均星级很高、文本情感分却很低的情况就说明这个景点的好评里存在大量注水内容——这是水军评论的典型信号。第二按时间维度聚合情感分。把评论按月份分桶计算每个月的情感均值可以直观展示这个景点口碑的变化趋势。我画折线图时发现有些景区旺季只有7分淡季接近9分原因在于旺季排队体验差这种结论如果只看总体平均分是看不出来的。第三对比情感分映射星级和用户实际星级的差距。把情感分映射成1-5星计算它与用户人工打分之间的平均绝对误差。我项目里这个误差大概在0.8星左右——不算小但也证明了模型具备基本判断力。如果你发现某个景点的误差异常大大概率是这个景点存在恶意刷评价的情况差值本身就成了一个有用的信号。6. 踩坑实录三个典型问题的完整排查链路6.1 问题一接口正常返回但解析出来全是空列表问题现象请求状态码200响应看起来也正常但解析commentList字段时得到的是空的。排查过程我先是打印了原始响应的前500个字符发现返回的JSON结构里确实有评论数据。接下来逐个字段排查发现我用的字段名是commentList但实际接口这次返回的是commList。为什么之前能跑通因为之前爬的景点走的是另一个接口版本字段名不同。根因平台不同版本接口字段名不统一或者同一接口在不同景点上返回的数据结构有差异。解决方案解析函数改成同时兼容多个字段名用data.get(commentList) or data.get(commList)这种写法。同时也提醒我自己以后遇到解析为空的情况先打印原始响应不要凭空猜字段名。6.2 问题二爬到一半开始返回403问题现象项目刚开始跑的时候一切正常大概连续请求了200多次之后接口开始返回403状态码浏览器手动访问却正常。排查过程当时我第一反应是IP被拉黑了。但我没有马上换网络出口而是先做了几个验证换了一个UA测试发现依然403加了一个Cookie字段测试发现还是403等了10分钟再跑发现恢复了。这说明不是IP被永久封禁而是触发了临时的请求频率保护。根因短时间内的请求频率超过了平台对单人访问的合理预期。解决方案把并发线程数从5降到3把sleep间隔从固定的2秒改成1.5到3.5秒随机波动每爬一个景点后加一个10到20秒的冷却休息。改完之后我连续跑了三个晚上再也没有遇到过403。经验总结就是被风控拦下的第一反应不应该是换代理换IP而是降速克制。个人项目那点数据量根本不需要靠高频请求取胜。6.3 问题三SnowNLP情感分全部集中在0.5附近问题现象清洗完的3000条评论跑完情感分析发现分数的标准差极小大量结果都在0.45到0.55之间根本拉不开区分度。排查过程我随机抽了10条明显积极和10条明显消极的评论单独跑SnowNLP发现模型对句子的输入长度很敏感。超长的评论文本超过100字会被模型平均化正向和负向信息互相抵消最终输出趋近于0.5。另外包含大量标点符号、emoji、数字的评论也容易被模型判为中性。根因SnowNLP的默认情感模型对长文本和多噪声文本的区分能力有限。解决方案两招。一是把超过80字的长评论切分成句子分别计算情感分后取平均值二是对所有评论文本在进入模型前先做一轮噪声清理去emoji、去网址、去重复标点。改造之后模型输出的区分度明显提升积极和消极评论的分数分布有了清晰的分层。7. 成果展示与经验沉淀7.1 可视化分析出图数据分析和情感评分都完成之后最后一步是可视化。我只用matplotlib做了三类图够用且不容易踩坑第一张是评分分布直方图展示某个景点的用户星级分布和文本情感分分布对比。两张分布图叠加在一起能直观看到用户打4星、5星的占比跟文本积极评论的占比是否匹配。第二张是月度情感均值折线图横轴是月份纵轴是情感分均值。能看出景点不同季节口碑的变化趋势。第三张是评论词云图用wordcloud库分别生成好评词云和差评词云高频词一目了然。好评里出现最多的词通常是景色孩子值得方便差评里出现最多的词往往是排队服务退票失望。两张词云摆在一起一个景点的核心优劣势立刻就有了清晰轮廓。7.2 几点经验心得项目从动手到完整跑通前后花了两个周末的时间。最有价值的收获不是那十几张分析图表而是对整个流程的理解和一堆血的教训。一个核心感受是爬虫最核心的能力不是绕过风控而是控制欲望。这个项目里我坚持使用3线程、随机延时、每小时最多爬一个景点看起来效率很低但它让整个采集过程平稳跑完了三天没有中断。数据采集是细水长流的活不是一锤子买卖的考核宁可慢不要断。另一个体会是情感分析不是装个库跑一下就结束的。SnowNLP这类预训练模型只能给你一个粗糙的起点真正让它变得可用、可靠需要针对数据分布做阈值校准需要叠加领域规则更需要结合用户星级做交叉验证。只跑一遍就出结果的情感分析跟拿着温度计测水温没什么区别——数据是有了但你能解释的东西非常有限。最后分享一个实用小建议整个项目的代码改动都用Git管理起来每次调整完情感分析规则就提交一次标注清楚调整了转折词权重新增了广告评论黑名单。因为你会发现自己经常因为一个奇怪的新案例推翻前一天刚改好的规则。有Git历史在随时可以回退到某个自己觉得结果更合理的版本不用靠脑子记哪版参数配哪版代码。如果你也想复现这个项目我的建议是从一个你最熟悉的本地景点开始爬它的200到500条评论先把整个流程走通再扩大到更多景点。目标别一开始就定太大把一个小目标做完整、做深入比贪多嚼不烂有价值得多。
返回列表