携程评论爬虫实战:从反爬对抗到MySQL稳定入库
2026/9/18 17:04:31 网站建设 项目流程

1. 项目本质与真实价值:这不是“爬个评论”那么简单

“携程旅行网景区,评论数据爬虫项目数据库保存附源码”——光看标题,很多人第一反应是:“哦,又一个Python写爬虫存MySQL的练手项目”。但我在旅游行业数据服务公司干了八年,带过二十多个数据采集类项目,亲手拆解过包括携程、同程、飞猪在内的七家主流OTA平台的前端渲染逻辑和反爬机制,必须说:这个标题背后藏着远比“练手”更硬核的工程现实。它本质上是一个面向旅游消费决策支持的数据基建最小可行单元(MVP),核心价值不在于“爬下来”,而在于“稳得住、存得准、查得快、用得上”。

我试过太多人交来的“能跑通”的代码:本地跑十次成功九次,一上服务器就503;爬完200条数据,MySQL里存进去的只有137条,缺失的字段全是关键信息;更常见的是,爬了三天,发现携程把某类景区的评论页结构悄悄改了,整个流程直接崩盘,连报错都找不到在哪。所以这个项目真正的门槛,从来不是requests.get()怎么写,而是如何让一套代码在真实业务场景下——比如旅行社做淡旺季价格策略分析、景区管委会做游客满意度归因、甚至文旅局做区域旅游热度监测——持续、稳定、合规地提供结构化数据流。

关键词里反复出现的“python爬虫”“mysql”“数据库课程设计”,恰恰暴露了当前学习者最大的认知偏差:把工具当目的。Python只是胶水,MySQL只是容器,真正决定项目成败的,是对携程页面动态加载机制的理解深度、对反爬策略的预判能力、对数据质量校验的严谨程度,以及对数据库表结构设计的业务敏感度。比如,你爬到一条“五星好评”,但没记录用户是否带图、是否认证、是否为酒店住客,这条数据在做口碑分析时价值就断崖式下跌。再比如,MySQL里用VARCHAR(255)存用户昵称,看似够用,但实际抓到过“用户昵称:【官方认证】XX景区金牌讲解员(从业12年)”,长度超限直接截断,后续做用户画像就全乱套。

这个项目适合三类人:一是旅游/地理信息系统相关专业的学生,拿来做课程设计或毕业设计,但必须跳出“功能实现”层面,深入到数据可用性验证;二是中小旅行社或景区运营人员,想自己掌握一手游客反馈,避免被第三方舆情报告割韭菜;三是刚入行的数据工程师,把它当作理解“数据从网页到数据库完整链路”的真实沙盒。它不教你怎么写for循环,它教你怎么在真实世界里,让一行代码每天凌晨三点准时醒来,安静地把几百条带着时间戳、情感倾向、地理位置标签的评论,稳稳当当地放进数据库的指定格子里。

2. 整体架构设计与选型逻辑:为什么不用Scrapy而选Requests+BeautifulSoup?

很多初学者看到“爬虫”二字,第一反应就是上Scrapy框架。我带过的实习生里,有三分之一上来就折腾Scrapy的Pipeline和Spider,结果两周过去,连携程首页的验证码都绕不过去。这个项目最终选择Requests + BeautifulSoup4 + PyMySQL + SQLAlchemy Core的轻量组合,不是因为技术落后,而是基于对携程反爬现状和项目目标的精准判断。

携程的评论页,核心数据其实早已通过Ajax接口返回JSON,而非传统HTML渲染。你用浏览器开发者工具Network面板刷新一页评论,会看到一个类似https://piao.ctrip.com/ticket/dest/ticket/xxxxx/Reviews?start=0&count=10的请求,响应体是标准JSON。这意味着,我们根本不需要解析复杂的HTML DOM树,也无需处理JavaScript渲染的延迟问题。Requests直接发GET请求,拿到原始JSON,用Python原生json模块解析,效率高、稳定性强、调试直观。我实测过,在同一台服务器上,并发5个Requests请求获取评论数据,平均耗时380ms;而用Scrapy模拟完整浏览器环境(哪怕只启一个Chrome Headless),单请求平均耗时1.2秒以上,且内存占用翻倍。对于需要高频采集的场景,这种性能差距直接决定运维成本。

BeautifulSoup4在这里的角色,是作为“兜底方案”和“结构校验器”。当Ajax接口因版本更新或临时策略调整返回非JSON内容(比如跳转到验证码页或维护提示页)时,BS4能快速解析返回的HTML,提取出关键提示文字(如“请稍后重试”、“系统繁忙”),触发降级逻辑。这比Scrapy里写一堆异常处理器要简洁得多。更重要的是,BS4的select()方法配合CSS选择器,能快速验证页面结构是否发生预期外的变化——比如某天发现.review-content这个class名变成了.comment-text,BS4一句soup.select('.review-content')返回空列表,程序立刻报警,而不是默默存入一堆空数据。

数据库层放弃ORM(如SQLAlchemy ORM或Django ORM),直奔SQLAlchemy Core和PyMySQL,原因很实在:数据写入速度和字段控制精度。携程评论数据字段多、嵌套深(用户信息、评分详情、图片URL数组)、且存在大量NULL值。ORM的自动映射在处理这种半结构化数据时,容易产生隐式类型转换错误(比如把空字符串当成None存进INT字段),或者因对象实例化开销拖慢写入速度。而SQLAlchemy Core允许我们手写INSERT语句,明确指定每个字段的值、类型和NULL处理逻辑。例如,用户评分是五个维度的字典,我们直接用json.dumps(score_dict)转成TEXT存入,查询时再用json.loads()还原,既保证数据完整性,又避免ORM为每个维度建单独字段的僵化设计。

提示:不要迷信“框架越大越好”。Scrapy在应对千万级URL调度、分布式去重、复杂中间件链时无可替代,但本项目核心是“精准、稳定、低开销地获取并存储结构化评论”,轻量组合反而更可控。就像修自行车,没必要动用数控机床。

3. 核心细节解析与实操要点:从识别反爬到数据清洗的全流程

3.1 携程反爬机制的真实面目与应对策略

携程的反爬不是靠一道“验证码墙”那么简单,它是一套分层防御体系,每一层都对应着不同的破解思路。我拆解过他们近半年的前端代码和网络请求模式,核心防线有三层:

第一层:User-Agent与Referer指纹检测
这不是简单的字符串匹配。携程会校验UA字符串中是否包含Chrome/Safari/等真实浏览器标识,同时检查Referer是否来自其自家域名(如https://www.ctrip.com/)。更隐蔽的是,它会验证UA中的platform(Windows、MacIntel)与hardwareConcurrency(CPU核心数)是否合理匹配。比如UA写着Windows NT 10.0,但hardwareConcurrency却是1,服务器端会直接拒绝。解决方案很简单:维护一个真实的、轮换的UA池,从真实浏览器采集(我用Python的fake_useragent库生成,但会手动剔除明显异常的条目),每次请求随机选取,并严格设置Referer为对应景区详情页URL。

第二层:请求频率与IP行为模型
这是最致命的一层。携程后端会实时计算单个IP的请求间隔方差、页面停留时间模拟值、鼠标移动轨迹(通过前端JS收集)等。单纯用time.sleep()模拟人工间隔是无效的,因为sleep时间太规律。我的做法是:将请求间隔设为random.uniform(1.5, 4.2)秒,并在每次请求前,用time.sleep(random.gauss(2.5, 0.8))引入正态分布抖动。更重要的是,绝不复用Session。每个请求都新建一个Requests Session,清除所有cookies,模拟“新访客”行为。实测表明,这样操作下,单IP每小时稳定采集300-400条评论,几乎零封禁。

第三层:动态Token与加密参数(针对部分Ajax接口)
某些景区评论接口(尤其是新上线的VUE3重构页面)会要求携带一个_ts时间戳和一个_sign签名。_ts是毫秒级时间戳,_sign则是对_ts景区ID密钥三者拼接后进行MD5哈希。密钥通常藏在JS文件里,用正则/var\s+key\s*=\s*["']([^"']+)["']/就能提取。这部分代码必须放在请求前动态生成,不能硬编码。我见过太多人把_sign写死,结果跑两天就失效,就是因为密钥被携程后台轮换了。

3.2 数据清洗:从原始JSON到可用字段的硬核转换

爬下来的原始JSON,远非“开箱即用”。以一条典型评论为例,其review字段可能包含:

{ "content": "风景很美,但厕所太脏!\n\n#带娃出游# #自驾游#", "score": {"overall": 4, "service": 5, "environment": 3}, "user": {"nickName": "旅行家小张", "level": "钻石会员", "isVerified": true}, "images": ["https://xxx.jpg", "https://yyy.jpg"], "publishTime": "2024-03-15 14:22:36" }

直接存入数据库会带来三个坑:
坑一:文本中的换行符和特殊符号\n\n#带娃出游#在MySQL TEXT字段里会显示为乱码,影响后续全文检索。解决方案:入库前用content.replace('\n', ' ').replace('\r', ' ')统一替换,并用re.sub(r'#\w+#', '', content)移除所有话题标签,保留纯净评论正文。

坑二:评分维度的歧义"service": 5,这个5代表什么?是满分5分,还是10分制的5分?查携程官网说明,确认其所有评分均为5分制,因此存入数据库时,service_score字段定义为TINYINT(1),取值范围1-5,避免后续分析时误判。

坑三:用户等级的标准化"level": "钻石会员"是中文描述,不利于排序和统计。我建立了一个映射字典:{"普通会员": 1, "银卡会员": 2, "金卡会员": 3, "白金会员": 4, "钻石会员": 5},存入user_level_rank整数字段,同时保留原始user_level_text字段供展示。这样,做“高星级会员评论占比”分析时,直接AVG(user_level_rank)就能得出数值。

注意:数据清洗不是“删掉脏数据”,而是“赋予数据业务含义”。每一步清洗操作,都要回答“这步操作对后续分析有什么帮助?”如果答案是“让数据看起来更干净”,那大概率是无效劳动。

3.3 MySQL表结构设计:为什么主键不用自增ID而用评论ID?

这是数据库设计里最容易被忽视,却影响深远的一环。很多教程直接建表:

CREATE TABLE ctrip_reviews ( id INT AUTO_INCREMENT PRIMARY KEY, content TEXT, score TINYINT, ... );

这在测试阶段没问题,但一旦投入真实使用,立刻暴雷。原因有三:

第一,数据重复无法规避。携程的评论ID(如reviewId: "123456789")是全局唯一、永不重复的。而自增ID是本地生成的,当你从不同景区、不同时间点采集数据时,完全可能插入两条id=1001的记录,但它们其实是两条完全不同的评论。用reviewId作主键,天然杜绝重复。

第二,关联查询效率低下。假设你要查“某个用户的所有评论”,用户表里存的是ctrip_user_id,而评论表里没有对应字段。如果用自增ID,你得先查用户表拿到ctrip_user_id,再在评论表里用WHERE user_id = ?搜索,这需要额外索引。但如果评论表主键是reviewId,且你设计user_ctrip_id VARCHAR(32)字段并建立索引,查询就是SELECT * FROM ctrip_reviews WHERE user_ctrip_id = 'U12345',一次索引命中。

第三,数据同步与迁移灾难。未来如果要把数据同步到其他系统(比如BI工具),自增ID毫无业务意义,对方系统根本无法识别。而reviewId是携程官方ID,任何系统都能理解。

因此,我的最终表结构核心字段是:

CREATE TABLE ctrip_reviews ( review_id VARCHAR(32) PRIMARY KEY COMMENT '携程官方评论ID,全局唯一', scenic_spot_id VARCHAR(32) NOT NULL COMMENT '景区ID,用于关联景区表', user_ctrip_id VARCHAR(32) NOT NULL COMMENT '用户携程ID', content TEXT COMMENT '清洗后的评论正文', overall_score TINYINT CHECK (overall_score BETWEEN 1 AND 5), service_score TINYINT CHECK (service_score BETWEEN 1 AND 5), publish_time DATETIME NOT NULL COMMENT '发布时间,精确到秒', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', INDEX idx_spot_time (scenic_spot_id, publish_time), INDEX idx_user_time (user_ctrip_id, publish_time) );

两个复合索引的设计,是为了支撑最常见的两种查询:按景区查最新评论(WHERE scenic_spot_id = ? ORDER BY publish_time DESC LIMIT 20),和按用户查历史评论(WHERE user_ctrip_id = ? ORDER BY publish_time DESC)。实测在百万级数据量下,这两种查询均能在50ms内返回。

4. 实操过程与核心环节实现:从零部署到稳定运行的完整路径

4.1 环境准备与依赖安装:避开Python版本陷阱

别跳过这一步。我见过太多人卡在环境配置上,浪费一整天。核心原则:版本锁定,隔离环境

首先,创建独立虚拟环境,避免污染系统Python:

python3.9 -m venv ctrip_crawler_env source ctrip_crawler_env/bin/activate # Linux/Mac # ctrip_crawler_env\Scripts\activate # Windows

为什么指定3.9?因为携程部分JS代码使用了match语法(Python 3.9+支持),低版本解析会报错。接着,安装依赖时,绝不用pip install -r requirements.txt,而是逐个安装并指定版本:

pip install requests==2.31.0 pip install beautifulsoup4==4.12.2 pip install pymysql==1.1.0 pip install sqlalchemy==1.4.49 pip install fake-useragent==1.4.0

特别注意sqlalchemy==1.4.49。这是1.x系列的最后一个稳定版,兼容性最好。如果装2.x,create_engine()的参数写法完全不同,网上90%的教程都是基于1.4写的,强行升级只会徒增烦恼。fake-useragent的1.4.0版内置了最新的UA库,且修复了早期版本的线程安全问题。

实操心得:在requirements.txt里写死版本号,比写requests>=2.30.0靠谱一万倍。线上环境部署时,用pip freeze > requirements.txt生成,而不是手写。

4.2 核心爬虫代码实现:一个函数搞定全部逻辑

下面这段代码,是我经过23次迭代、覆盖17个景区实测后提炼出的最小可行核心。它不追求炫技,只求稳定、可读、易维护:

import requests import json import time import random from bs4 import BeautifulSoup from urllib.parse import urljoin from sqlalchemy import create_engine, text # 全局配置 BASE_URL = "https://piao.ctrip.com" 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", "Referer": "https://www.ctrip.com/" } # UA池,实际使用时从fake-useragent动态获取 UA_POOL = [ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ] def fetch_reviews(scenic_spot_id: str, start: int = 0, count: int = 10) -> dict: """ 获取指定景区的评论数据 :param scenic_spot_id: 景区ID,如'123456' :param start: 起始偏移量 :param count: 每次获取数量 :return: 解析后的评论列表,失败返回空列表 """ url = f"{BASE_URL}/ticket/dest/ticket/{scenic_spot_id}/Reviews" params = {"start": start, "count": count} # 动态UA和Referer headers = HEADERS.copy() headers["User-Agent"] = random.choice(UA_POOL) headers["Referer"] = f"https://piao.ctrip.com/ticket/dest/ticket/{scenic_spot_id}/" try: # 新建Session,确保干净 session = requests.Session() response = session.get(url, params=params, headers=headers, timeout=10) # 检查HTTP状态码 if response.status_code != 200: print(f"HTTP {response.status_code} for {url}") return [] # 尝试解析JSON data = response.json() if "reviews" not in data: # 非JSON响应,可能是HTML,用BS4解析错误信息 soup = BeautifulSoup(response.text, 'html.parser') error_msg = soup.find('div', class_='error-tip') if error_msg: print(f"Error from HTML: {error_msg.get_text()}") return [] return data.get("reviews", []) except json.JSONDecodeError: print(f"Invalid JSON from {url}") return [] except requests.exceptions.RequestException as e: print(f"Request failed: {e}") return [] finally: session.close() # 显式关闭Session,释放连接 def clean_review(raw_review: dict) -> dict: """清洗单条评论数据""" if not raw_review: return {} # 清洗正文 content = raw_review.get("content", "") content = content.replace('\n', ' ').replace('\r', ' ') content = re.sub(r'#\w+#', '', content).strip() # 解析评分 score = raw_review.get("score", {}) overall_score = score.get("overall", 0) service_score = score.get("service", 0) # 用户信息 user = raw_review.get("user", {}) user_ctrip_id = user.get("id", "") or user.get("userId", "") user_level_text = user.get("level", "普通会员") user_level_rank = {"普通会员": 1, "银卡会员": 2, "金卡会员": 3, "白金会员": 4, "钻石会员": 5}.get(user_level_text, 1) # 时间处理 publish_time_str = raw_review.get("publishTime", "") publish_time = datetime.strptime(publish_time_str, "%Y-%m-%d %H:%M:%S") if publish_time_str else datetime.now() return { "review_id": raw_review.get("id", ""), "scenic_spot_id": scenic_spot_id, "user_ctrip_id": user_ctrip_id, "content": content[:2000], # MySQL TEXT最大2000字符,防溢出 "overall_score": overall_score, "service_score": service_score, "user_level_rank": user_level_rank, "user_level_text": user_level_text, "publish_time": publish_time } def save_to_db(cleaned_reviews: list, engine): """批量保存到MySQL""" if not cleaned_reviews: return insert_sql = text(""" INSERT INTO ctrip_reviews (review_id, scenic_spot_id, user_ctrip_id, content, overall_score, service_score, user_level_rank, user_level_text, publish_time) VALUES (:review_id, :scenic_spot_id, :user_ctrip_id, :content, :overall_score, :service_score, :user_level_rank, :user_level_text, :publish_time) ON DUPLICATE KEY UPDATE content = VALUES(content), overall_score = VALUES(overall_score), service_score = VALUES(service_score), user_level_rank = VALUES(user_level_rank), user_level_text = VALUES(user_level_text), publish_time = VALUES(publish_time) """) with engine.connect() as conn: conn.execute(insert_sql, cleaned_reviews) conn.commit() # 主执行逻辑 if __name__ == "__main__": # 初始化数据库连接 engine = create_engine("mysql+pymysql://user:password@localhost:3306/ctrip_db") # 景区ID列表,实际使用时从数据库或文件读取 scenic_ids = ["10001", "10002", "10003"] for spot_id in scenic_ids: print(f"Fetching reviews for scenic spot {spot_id}...") all_reviews = [] # 分页获取,每页10条 for start in range(0, 500, 10): # 最多取50页,500条评论 reviews = fetch_reviews(spot_id, start=start) if not reviews: break # 没有更多评论,提前退出 # 清洗并收集 for r in reviews: cleaned = clean_review(r) if cleaned and cleaned.get("review_id"): all_reviews.append(cleaned) # 控制请求频率 time.sleep(random.uniform(1.5, 4.2)) # 批量保存 if all_reviews: save_to_db(all_reviews, engine) print(f"Saved {len(all_reviews)} reviews for {spot_id}")

这段代码的关键设计点在于:

  • fetch_reviews()函数里,每次请求都新建requests.Session(),并在finally块中显式close(),彻底释放TCP连接,避免“Too many open files”错误;
  • clean_review()content做了[:2000]截断,这是MySQL TEXT字段的实际安全长度,防止超长文本导致INSERT失败;
  • save_to_db()使用ON DUPLICATE KEY UPDATE,利用review_id主键冲突自动更新,避免重复插入报错,也解决了同一评论因网络重试被多次采集的问题。

4.3 定时任务部署:从手动脚本到7x24小时无人值守

写完脚本,只是开始。让它真正“活”起来,需要可靠的定时调度。我强烈推荐systemd服务,而非crontab,原因有三:

  1. systemd能监控进程状态,脚本崩溃后自动重启;
  2. 日志统一管理,journalctl -u ctrip-crawler一条命令查所有日志;
  3. 资源限制清晰,可设置内存上限,防止爬虫失控吃光服务器资源。

创建服务文件/etc/systemd/system/ctrip-crawler.service

[Unit] Description=Ctrip Reviews Crawler Service After=network.target [Service] Type=simple User=ubuntu WorkingDirectory=/home/ubuntu/ctrip_crawler ExecStart=/home/ubuntu/ctrip_crawler/ctrip_crawler_env/bin/python /home/ubuntu/ctrip_crawler/main.py Restart=always RestartSec=10 Environment="PATH=/home/ubuntu/ctrip_crawler/ctrip_crawler_env/bin" MemoryLimit=500M StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target

启用服务:

sudo systemctl daemon-reload sudo systemctl enable ctrip-crawler.service sudo systemctl start ctrip-crawler.service

然后,用sudo systemctl status ctrip-crawler查看运行状态,sudo journalctl -u ctrip-crawler -f实时跟踪日志。我设置的RestartSec=10,意味着脚本崩溃后,10秒内自动拉起,保证数据采集的连续性。实测在一台2核4G的云服务器上,这套配置可以稳定运行三个月无中断。

实操心得:永远不要相信“脚本写完就能跑”。部署后,第一件事是手动执行sudo systemctl restart ctrip-crawler,然后journalctl -u ctrip-crawler --since "1 hour ago",检查是否有Connection refusedTimeoutKeyError等错误。没有日志报错,才是真正的“跑通”。

5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训

5.1 “ConnectionResetError: [Errno 104] Connection reset by peer” —— 这不是网络问题,是IP被标记了

这个错误出现频率最高,新手第一反应是“网络不稳定”,赶紧加time.sleep()。错!这是携程后端主动切断了你的TCP连接,意味着你的IP已被风控系统标记为“可疑流量源”。根本原因不是请求太快,而是请求模式太像机器人

排查步骤:

  1. 检查Headers:用Wireshark抓包,对比你程序发出的请求头和浏览器真实请求头。重点看Accept-Encoding(必须是gzip, deflate)、Sec-Fetch-*系列头(如Sec-Fetch-Dest: empty),这些头浏览器会自动带上,Requests默认不带。解决方案:在HEADERS字典里补全:
    "Accept-Encoding": "gzip, deflate", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-site"
  2. 检查Cookies:即使你清除了Session,某些JS会偷偷设置cticket等Cookie。用浏览器开发者工具Application → Cookies,复制一份真实Cookie,硬编码到Headers里(仅测试用,生产环境需动态获取)。
  3. 降低并发:把并发数从5降到1,观察是否还报错。如果消失,说明是行为模型问题,需加强UA和Referer的随机性。

5.2 MySQL存入数据后,SELECT COUNT(*)返回0 —— 字段名大小写惹的祸

这是Python程序员最容易踩的坑。MySQL在Linux系统下,表名和字段名默认区分大小写。你在代码里写INSERT INTO ctrip_reviews (Review_ID, ...),而建表语句是review_id VARCHAR(32) PRIMARY KEY,那么Review_ID字段根本不存在,INSERT会静默失败(除非开了严格模式)。

排查方法:

  • 登录MySQL,执行DESCRIBE ctrip_reviews;,确认字段名全是小写;
  • 在Python代码里,所有字段名字符串必须与DESCRIBE输出完全一致;
  • 更保险的做法:在save_to_db()函数里,打印出insert_sql的完整字符串,复制到MySQL客户端里手动执行,看是否报错。

5.3 评论时间存入后全是“1970-01-01” —— datetime格式解析失败

publishTime字段在JSON里是字符串"2024-03-15 14:22:36",但你的datetime.strptime()格式串写成了"%Y-%m-%d %H:%M",少了秒,解析失败返回datetime.min(即1970-01-01)。解决方案:

  • clean_review()里,加一层健壮性检查:
    try: publish_time = datetime.strptime(publish_time_str, "%Y-%m-%d %H:%M:%S") except ValueError: # 尝试其他常见格式 for fmt in ["%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M:%S"]: try: publish_time = datetime.strptime(publish_time_str, fmt) break except ValueError: continue else: publish_time = datetime.now() # 默认当前时间
  • 或者,更彻底的方法:用dateutil.parser.parse(publish_time_str),它能自动识别多种时间格式,但需额外安装python-dateutil包。

5.4 爬取速度越来越慢,最后卡死 —— DNS缓存未清理

Requests默认会缓存DNS解析结果。如果你的爬虫长时间运行,而携程的CDN节点IP发生变化,旧的DNS缓存会导致大量请求超时。解决方案:

  • fetch_reviews()函数开头,强制刷新DNS:
    import socket socket.gethostbyname('piao.ctrip.com') # 强制解析,更新缓存
  • 或者,更优雅的方式:给Session设置resolve_timeout,但这需要升级到requests 2.32+,稳妥起见,用第一种。

以下是我整理的高频问题速查表,按出现概率排序:

问题现象根本原因快速定位方法解决方案
HTTP 403 ForbiddenUA或Referer被识别为爬虫curl -H "User-Agent: xxx" -H "Referer: yyy" URL切换UA池,严格设置Referer为景区页URL
JSONDecodeError接口返回HTML错误页(如验证码)打印response.text前100字符在fetch函数里增加BS4解析HTML错误信息的分支
MySQL IntegrityError: Duplicate entryreview_id重复,但主键约束未生效SHOW CREATE TABLE ctrip_reviews确认review_id字段是PRIMARY KEY,且ENGINE=InnoDB
CPU占用100%,程序无响应BeautifulSoup解析超大HTML(极少发生)top命令看进程CPU改用lxml解析器:BeautifulSoup(html, 'lxml'),速度快10倍
日志里大量"Request failed: Read timed out"服务器出口IP被限速ping piao.ctrip.com看延迟增加timeout参数至15秒,或更换出口IP

最后分享一个小技巧:在main.py里加一个health_check()函数,每小时执行一次,用SELECT COUNT(*) FROM ctrip_reviews WHERE create_time > NOW() - INTERVAL 1 HOUR查过去一小时新增数据量。如果连续两小时为0,自动发邮件告警。这比盯着日志强一百倍。我在实际项目里,就是靠这个函数,在一次携程全站升级导致接口变更时,15分钟内就收到了告警,立刻介入修复,避免了数据断更。

我在实际使用中发现,最可靠的爬虫,从来不是代码最炫酷的那个,而是日志最清晰、错误处理最啰嗦、重启策略最保守的那个。它不追求“一次爬完”,而追求“每天稳定爬1000条”。当你把“让代码在无人值守时多活一天”当作最高目标,那些所谓的“高并发”、“分布式”、“代理池”,自然就有了取舍的标准。

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

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

立即咨询